Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ jobs:
- wordpress-service
- worktree-context-projections
- workspace-installation
- workspace-materialization
- wp-cli-machine-output
- wp-config-permissions
steps:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,8 @@ operator-entrypoints/wp-coding-agents-setup/setup.md
| --- | --- |
| `--runtime <name>` | Coding runtime: `opencode`, `claude-code`, or `codex`. Auto-detected when omitted. |
| `--source-mode <name>` | `workspace` (default) or `owned`. See [Source Mode](#source-mode). `--posture` is a deprecated alias. |
| `--workspace-repository <absolute-git-checkout>` | Primary Git checkout authority for workspace mode. Repeatable; each path must already be a Git checkout. |
| `--workspace-repository <absolute-git-checkout>` | Existing primary Git checkout authority for workspace mode. Repeatable. |
| `--workspace-repository-clone <git-remote> <absolute-destination>` | Materialize and declare a missing primary checkout. Existing destinations are validated and never overwritten; credential-free declarations persist for upgrade recovery. Repeatable. |
| `--owned-source <path>` | wp-content path the site owns and may edit under `--source-mode owned`. Repeatable. |
| `--owned-writable <path>` | Denied path to re-open for editing (e.g. `wp-config.php`). Not captured. Repeatable. |
| `--log-path <path>` | Absolute path outside the site root the agent may read. Repeatable. |
Expand Down
12 changes: 7 additions & 5 deletions docs/workspace-ownership.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Workspace ownership after DMC

Status: target architecture for [#526](https://github.com/Extra-Chill/wp-coding-agents/issues/526), prerequisite to [#525](https://github.com/Extra-Chill/wp-coding-agents/issues/525).
Status: active architecture for [#526](https://github.com/Extra-Chill/wp-coding-agents/issues/526), completed as a prerequisite to [#525](https://github.com/Extra-Chill/wp-coding-agents/issues/525).

This document defines where coding work happens and which layer owns each operation after DMC is removed. It is a target contract, not a description of the current installer. The migration inventory remains the source of truth for current dependencies and persisted-state gates.
This document defines where coding work happens and which layer owns each operation after DMC is removed. The migration inventory remains the source of truth for current dependencies and persisted-state gates.

## Invariants

Expand All @@ -29,9 +29,11 @@ Homeboy is an optional orchestration axis, not a source mode. When it is unavail

## Repository contract

The desired-state profile must carry an explicit list of repository roots for workspace mode. The storage syntax is implementation work, but the following semantics are fixed:
The desired-state profile carries an explicit list of repository roots for workspace mode. Existing checkouts are declared by absolute path. A repository object with `path` and credential-free `remote` fields additionally authorizes setup and upgrade to clone that primary checkout when the destination is missing.

- Each entry is an absolute, canonical path to one primary Git checkout accessible to the coding runtime.
- Each entry is an absolute path to one primary Git checkout accessible to the coding runtime. Filesystem aliases are resolved when proving checkout-root identity.
- Materialization creates only a missing destination. An existing destination must already be the primary checkout root and its `origin` must exactly match the declaration; setup and upgrade never replace it.
- Materialization rejects direct symlink destinations and user-controlled symlink ancestors. Privileged materialization additionally requires root-owned ancestors that are not group/world-writable; root-owned aliases in protected system directories remain valid.
- The list is the sole repository authority for runtime permissions, guidance, verification, and optional Homeboy component attachment.
- The WordPress site root is read-only reference unless it is separately and explicitly listed as a repository root. Local path coincidence is not authority.
- Directory scanning, plugin inventories, `homeboy.json`, and DMC options may help migrate an existing install, but none is ongoing repository discovery authority.
Expand Down Expand Up @@ -77,7 +79,7 @@ The following are not target resident capabilities: repository discovery; file m

## Failure behavior

- Missing or invalid repository roots make workspace verification unhealthy and leave installed WordPress source read-only.
- Missing materialized repositories are restored from their declarations during setup or upgrade. Missing path-only repositories and invalid destinations make workspace verification unhealthy and leave installed WordPress source read-only.
- Missing Homeboy leaves native primary-checkout workflow available and all orchestrated-worktree guidance absent.
- Homeboy configured but unhealthy fails the Homeboy seam explicitly; WordPress does not assume its lifecycle operations.
- An unavailable external WordPress transport blocks WordPress operations but does not change local repository authority.
Expand Down
14 changes: 12 additions & 2 deletions lib/desired-state-reconciler.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ installation_profile_value() {
chat_bridge) printf '%s' "$INSTALLATION_PROFILE_CHAT_BRIDGE" ;;
homeboy_mode) printf '%s' "$INSTALLATION_PROFILE_HOMEBOY_MODE" ;;
workspace_repositories) printf '%s' "$INSTALLATION_PROFILE_WORKSPACE_REPOSITORIES" ;;
workspace_repository_clones) printf '%s' "$INSTALLATION_PROFILE_WORKSPACE_REPOSITORY_CLONES" ;;
components) printf '%s' "${INSTALLATION_PROFILE_COMPONENTS[*]}" ;;
plugin_candidates) printf '%s' "${INSTALLATION_PROFILE_PLUGIN_CANDIDATES[*]}" ;;
*) return 1 ;;
Expand Down Expand Up @@ -63,6 +64,10 @@ installation_profile_normalize() {
fi
INSTALLATION_PROFILE_HOMEBOY_MODE="${HOMEBOY_MODE:-auto}"
INSTALLATION_PROFILE_WORKSPACE_REPOSITORIES="${WORKSPACE_REPOSITORIES:-}"
INSTALLATION_PROFILE_WORKSPACE_REPOSITORY_CLONES=""
if [ -n "${WORKSPACE_REPOSITORY_CLONES:-}" ]; then
INSTALLATION_PROFILE_WORKSPACE_REPOSITORY_CLONES="$(source_policy_workspace_clone_specs_encode)"
fi
INSTALLATION_PROFILE_PLUGIN_CANDIDATES=(data-machine wp-codebox)
INSTALLATION_PROFILE_CARRIED_PLUGINS=()
if [ "$INSTALLATION_PROFILE_EXTERNAL_WORDPRESS" != true ]; then
Expand Down Expand Up @@ -120,7 +125,7 @@ installation_profile_write() {
local key
umask 077
: > "$tmp"
for key in operation site_path local_mode external_wordpress studio source_mode runtime install_chat chat_bridge homeboy_mode workspace_repositories components plugin_candidates; do
for key in operation site_path local_mode external_wordpress studio source_mode runtime install_chat chat_bridge homeboy_mode workspace_repositories workspace_repository_clones components plugin_candidates; do
printf '%s=%s\n' "$key" "$(installation_profile_value "$key")" >> "$tmp"
done
mv "$tmp" "$file"
Expand Down Expand Up @@ -149,7 +154,12 @@ installation_profile_load() {
;;
chat_bridge) [ -n "${CHAT_BRIDGE:-}" ] || CHAT_BRIDGE="$value" ;;
homeboy_mode) [ "${HOMEBOY_MODE:-auto}" != auto ] || HOMEBOY_MODE="$value" ;;
workspace_repositories) [ -n "${WORKSPACE_REPOSITORIES:-}" ] || WORKSPACE_REPOSITORIES="$value" ;;
workspace_repositories) [ "${WORKSPACE_REPOSITORIES_EXPLICIT:-false}" = true ] || WORKSPACE_REPOSITORIES="$value" ;;
workspace_repository_clones)
if [ "${WORKSPACE_REPOSITORIES_EXPLICIT:-false}" != true ] && [ -n "$value" ]; then
WORKSPACE_REPOSITORY_CLONES="$(source_policy_workspace_clone_specs_decode "$value")"
fi
;;
esac
done < "$file"
}
Expand Down
158 changes: 158 additions & 0 deletions lib/source-policy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,164 @@ source_policy_workspace_repositories() {
done
}

source_policy_workspace_clone_specs_encode() {
printf '%s' "${WORKSPACE_REPOSITORY_CLONES:-}" | python3 -c 'import base64, sys; print(base64.urlsafe_b64encode(sys.stdin.buffer.read()).decode("ascii").rstrip("="))'
}

source_policy_workspace_clone_specs_decode() {
[ -n "${1:-}" ] || return 0
printf '%s' "$1" | python3 -c 'import base64, sys; data = sys.stdin.buffer.read(); sys.stdout.buffer.write(base64.urlsafe_b64decode(data + b"=" * (-len(data) % 4)))'
}

source_policy_validate_workspace_repository_clone() {
local remote="$1" repository="$2" authority userinfo
[ -n "$remote" ] || { error "--workspace-repository-clone requires a Git remote URL"; return 1; }
case "$remote" in
-*|*$'\n'*|*$'\t'*) error "Workspace repository clone remotes must not begin with a dash or contain tabs or newlines"; return 1 ;;
esac
case "$repository" in
*:*|*$'\n'*|*$'\t'*) error "Workspace repository clone destinations must not contain colons, tabs, or newlines"; return 1 ;;
esac
case "$repository" in
/*) ;;
*) error "--workspace-repository-clone destination must be absolute: $repository"; return 1 ;;
esac
case "$remote" in
/*|file:///*|git@*:* ) ;;
https://*)
authority="${remote#*://}"
authority="${authority%%/*}"
case "$authority" in
*@*) error "Workspace repository remotes must not contain embedded credentials"; return 1 ;;
esac
;;
ssh://*)
authority="${remote#*://}"
authority="${authority%%/*}"
case "$authority" in
*@*)
userinfo="${authority%@*}"
case "$userinfo" in
*:*) error "Workspace repository remotes must not contain embedded credentials"; return 1 ;;
esac
;;
esac
;;
*) error "Workspace repository remotes must use an absolute local path, file, HTTPS, or SSH URL"; return 1 ;;
esac
}

source_policy_begin_workspace_repository_declarations() {
if [ "${WORKSPACE_REPOSITORIES_EXPLICIT:-false}" != true ]; then
WORKSPACE_REPOSITORIES=""
WORKSPACE_REPOSITORY_CLONES=""
WORKSPACE_REPOSITORIES_EXPLICIT=true
WORKSPACE_REPOSITORY_CLONES_EXPLICIT=true
fi
}

source_policy_assert_trusted_workspace_ancestors() {
local ancestor="$(dirname "$1")" container link_owner container_owner container_mode ancestor_owner ancestor_mode permissions group_digit other_digit
while [ "$ancestor" != / ]; do
if [ -L "$ancestor" ]; then
container="$(dirname "$ancestor")"
link_owner="$(stat -c '%u' "$ancestor" 2>/dev/null || stat -f '%u' "$ancestor" 2>/dev/null || true)"
container_owner="$(stat -c '%u' "$container" 2>/dev/null || stat -f '%u' "$container" 2>/dev/null || true)"
container_mode="$(stat -c '%a' "$container" 2>/dev/null || stat -f '%Lp' "$container" 2>/dev/null || true)"
permissions="${container_mode: -3}"
group_digit="${permissions:1:1}"
other_digit="${permissions:2:1}"
if [ "$link_owner" != 0 ] || [ "$container_owner" != 0 ] || [ -z "$group_digit" ] || [ -z "$other_digit" ] || \
(( (group_digit & 2) != 0 || (other_digit & 2) != 0 )); then
error "Refusing workspace repository path with an untrusted symlink ancestor: $ancestor"
return 1
fi
fi
if [ "${EUID:-$(id -u)}" = 0 ] && [ -e "$ancestor" ]; then
ancestor_owner="$(stat -c '%u' "$ancestor" 2>/dev/null || stat -f '%u' "$ancestor" 2>/dev/null || true)"
ancestor_mode="$(stat -c '%a' "$ancestor" 2>/dev/null || stat -f '%Lp' "$ancestor" 2>/dev/null || true)"
permissions="${ancestor_mode: -3}"
group_digit="${permissions:1:1}"
other_digit="${permissions:2:1}"
if [ "$ancestor_owner" != 0 ] || [ -z "$group_digit" ] || [ -z "$other_digit" ] || \
(( (group_digit & 2) != 0 || (other_digit & 2) != 0 )); then
error "Root workspace materialization requires root-owned, non-writable ancestors: $ancestor"
return 1
fi
fi
ancestor="$(dirname "$ancestor")"
done
}

source_policy_add_workspace_repository_clone() {
local remote="$1" repository="${2%/}"
source_policy_validate_workspace_repository_clone "$remote" "$repository"

local existing_remote existing_path
while IFS=$'\t' read -r existing_remote existing_path; do
[ -n "$existing_path" ] || continue
if [ "$existing_path" = "$repository" ]; then
[ "$existing_remote" = "$remote" ] || {
error "Workspace repository destination is already declared with a different remote: $repository"
return 1
}
return 0
fi
done <<< "${WORKSPACE_REPOSITORY_CLONES:-}"

WORKSPACE_REPOSITORY_CLONES="${WORKSPACE_REPOSITORY_CLONES}${WORKSPACE_REPOSITORY_CLONES:+$'\n'}${remote}"$'\t'"${repository}"
case ":${WORKSPACE_REPOSITORIES:-}:" in
*":$repository:"*) ;;
*) WORKSPACE_REPOSITORIES="${WORKSPACE_REPOSITORIES:+$WORKSPACE_REPOSITORIES:}$repository" ;;
esac
WORKSPACE_REPOSITORY_CLONES_EXPLICIT=true
}

source_policy_materialize_workspace_repositories() {
source_policy_workspace_enabled || return 0
local remote repository parent root actual_remote canonical_repository
while IFS=$'\t' read -r remote repository; do
[ -n "$repository" ] || continue
source_policy_validate_workspace_repository_clone "$remote" "$repository"
source_policy_assert_trusted_workspace_ancestors "$repository"
if [ -e "$repository" ] || [ -L "$repository" ]; then
[ ! -L "$repository" ] || { error "Refusing symlinked workspace repository destination: $repository"; return 1; }
[ "$(git -C "$repository" rev-parse --is-inside-work-tree 2>/dev/null || true)" = true ] || {
error "Workspace repository destination exists but is not a Git checkout: $repository"
return 1
}
root="$(git -C "$repository" rev-parse --show-toplevel 2>/dev/null)" || return 1
canonical_repository="$(cd "$repository" && pwd -P)" || return 1
[ "$(cd "$root" && pwd -P)" = "$canonical_repository" ] || {
error "Workspace repository destination is not the primary checkout root: $repository"
return 1
}
actual_remote="$(git -C "$repository" config --get remote.origin.url 2>/dev/null || true)"
[ "$actual_remote" = "$remote" ] || {
error "Workspace repository origin does not match its declaration: $repository"
return 1
}
continue
fi

parent="$(dirname "$repository")"
if [ "${DRY_RUN:-false}" = true ]; then
log "[dry-run] Would materialize workspace repository $remote at $repository"
continue
fi
mkdir -p "$parent" || { error "Could not create workspace repository parent: $parent"; return 1; }
source_policy_assert_trusted_workspace_ancestors "$repository"
[ ! -L "$parent" ] || { error "Refusing symlinked workspace repository parent: $parent"; return 1; }
log "Materializing workspace repository $remote at $repository"
git_clone_with_retry "$remote" "$repository" || {
error "Could not materialize workspace repository: $repository"
return 1
}
source_policy_assert_trusted_workspace_ancestors "$repository"
[ ! -L "$repository" ] || { error "Workspace repository destination became a symlink during materialization: $repository"; return 1; }
done <<< "${WORKSPACE_REPOSITORY_CLONES:-}"
}

source_policy_add_workspace_repository() {
local repository="${1%/}" root canonical existing
case "$repository" in
Expand Down
8 changes: 7 additions & 1 deletion operator-entrypoints/wp-coding-agents-setup/interview.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,13 @@ Return the profile as JSON in this shape so the compiler script can map it deter
"codex_path": "not-applicable | codebox-minions | external-openai-compatible-endpoint",
"source": {
"mode": "workspace | owned",
"workspace_repositories": ["/absolute/path/to/primary-checkout"]
"workspace_repositories": [
"/absolute/path/to/existing-primary-checkout",
{
"path": "/absolute/path/to/missing-primary-checkout",
"remote": "https://github.com/example/project.git"
}
]
},
"overlays": {
"homeboy": false,
Expand Down
43 changes: 36 additions & 7 deletions scripts/compile-setup-profile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ function normalizeBridge(profile, availableBridges) {
function normalizeSource(profile) {
const source = profile.source
if (source === undefined) {
return { mode: "workspace", repositories: [], legacy: true }
return { mode: "workspace", repositories: [], entries: [], legacy: true }
}
if (!source || typeof source !== "object") {
throw new Error("source must be an object")
Expand All @@ -115,10 +115,33 @@ function normalizeSource(profile) {
throw new Error("source.mode must be workspace or owned")
}

const repositories = source.workspace_repositories
if (!Array.isArray(repositories) || repositories.some((repository) => typeof repository !== "string" || !repository || !path.isAbsolute(repository))) {
throw new Error("source.workspace_repositories must be an array of absolute paths")
const declarations = source.workspace_repositories
if (!Array.isArray(declarations)) {
throw new Error("source.workspace_repositories must be an array")
}
const entries = declarations.map((repository) => {
if (typeof repository === "string") return { path: repository }
if (!repository || typeof repository !== "object" || Array.isArray(repository)) {
throw new Error("source.workspace_repositories entries must be absolute paths or {path, remote} objects")
}
return { path: repository.path, remote: repository.remote }
})
if (entries.some((repository) => typeof repository.path !== "string" || !repository.path || !path.isAbsolute(repository.path))) {
throw new Error("source.workspace_repositories paths must be absolute")
}
if (entries.some((repository) => repository.remote !== undefined && (typeof repository.remote !== "string" || !repository.remote))) {
throw new Error("source.workspace_repositories remote must be a non-empty string")
}
if (entries.some((repository) => repository.remote && (/^-|[\t\n]/.test(repository.remote) || /[:\t\n]/.test(repository.path)))) {
throw new Error("materialized workspace repository remotes and destinations contain invalid characters")
}
if (entries.some((repository) => /^https:\/\/[^/]*@/.test(repository.remote || "") || /^ssh:\/\/[^/@]*:[^/@]*@/.test(repository.remote || ""))) {
throw new Error("source.workspace_repositories remotes must not contain embedded credentials")
}
if (entries.some((repository) => repository.remote && !(/^(\/|file:\/\/\/|https:\/\/|ssh:\/\/|git@[^:]+:)/.test(repository.remote)))) {
throw new Error("source.workspace_repositories remotes must use an absolute local path, file, HTTPS, or SSH URL")
}
const repositories = entries.map((repository) => repository.path)
if (new Set(repositories).size !== repositories.length) {
throw new Error("source.workspace_repositories must not contain duplicate paths")
}
Expand All @@ -129,7 +152,7 @@ function normalizeSource(profile) {
throw new Error("owned source mode must not declare source.workspace_repositories")
}

return { mode, repositories, legacy: false }
return { mode, repositories, entries, legacy: false }
}

function compile(profile) {
Expand Down Expand Up @@ -206,8 +229,13 @@ function compile(profile) {

if (!source.legacy) {
addFlag(command, "--source-mode", source.mode)
for (const repository of source.repositories) {
addFlag(command, "--workspace-repository", repository)
for (const repository of source.entries) {
if (repository.remote) {
addFlag(command, "--workspace-repository-clone", repository.remote)
command.push(repository.path)
} else {
addFlag(command, "--workspace-repository", repository.path)
}
}
}

Expand Down Expand Up @@ -286,6 +314,7 @@ function compile(profile) {
systems_capabilities: systemsCapabilities.profile || "none",
source_mode: source.mode,
workspace_repositories: source.repositories,
workspace_repository_clones: source.entries.filter((repository) => repository.remote),
},
commands: {
dry_run: formatCommand(env, command, true),
Expand Down
Loading
Loading