diff --git a/.github/workflows/shell.yml b/.github/workflows/shell.yml index 450ce28..3f1d328 100644 --- a/.github/workflows/shell.yml +++ b/.github/workflows/shell.yml @@ -286,6 +286,7 @@ jobs: - wordpress-service - worktree-context-projections - workspace-installation + - workspace-materialization - wp-cli-machine-output - wp-config-permissions steps: diff --git a/README.md b/README.md index 68bfdc8..12d1fef 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,8 @@ operator-entrypoints/wp-coding-agents-setup/setup.md | --- | --- | | `--runtime ` | Coding runtime: `opencode`, `claude-code`, or `codex`. Auto-detected when omitted. | | `--source-mode ` | `workspace` (default) or `owned`. See [Source Mode](#source-mode). `--posture` is a deprecated alias. | -| `--workspace-repository ` | Primary Git checkout authority for workspace mode. Repeatable; each path must already be a Git checkout. | +| `--workspace-repository ` | Existing primary Git checkout authority for workspace mode. Repeatable. | +| `--workspace-repository-clone ` | Materialize and declare a missing primary checkout. Existing destinations are validated and never overwritten; credential-free declarations persist for upgrade recovery. Repeatable. | | `--owned-source ` | wp-content path the site owns and may edit under `--source-mode owned`. Repeatable. | | `--owned-writable ` | Denied path to re-open for editing (e.g. `wp-config.php`). Not captured. Repeatable. | | `--log-path ` | Absolute path outside the site root the agent may read. Repeatable. | diff --git a/docs/workspace-ownership.md b/docs/workspace-ownership.md index 85d530f..16ee924 100644 --- a/docs/workspace-ownership.md +++ b/docs/workspace-ownership.md @@ -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 @@ -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. @@ -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. diff --git a/lib/desired-state-reconciler.sh b/lib/desired-state-reconciler.sh index 29c5d86..3dbb607 100644 --- a/lib/desired-state-reconciler.sh +++ b/lib/desired-state-reconciler.sh @@ -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 ;; @@ -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 @@ -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" @@ -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" } diff --git a/lib/source-policy.sh b/lib/source-policy.sh index 2b42468..4d70cfa 100644 --- a/lib/source-policy.sh +++ b/lib/source-policy.sh @@ -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 diff --git a/operator-entrypoints/wp-coding-agents-setup/interview.md b/operator-entrypoints/wp-coding-agents-setup/interview.md index eef19b6..5b2c7c6 100644 --- a/operator-entrypoints/wp-coding-agents-setup/interview.md +++ b/operator-entrypoints/wp-coding-agents-setup/interview.md @@ -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, diff --git a/scripts/compile-setup-profile.mjs b/scripts/compile-setup-profile.mjs index 624f496..5be95d7 100755 --- a/scripts/compile-setup-profile.mjs +++ b/scripts/compile-setup-profile.mjs @@ -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") @@ -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") } @@ -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) { @@ -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) + } } } @@ -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), diff --git a/setup.sh b/setup.sh index dcf9143..317f2c9 100755 --- a/setup.sh +++ b/setup.sh @@ -79,6 +79,9 @@ CHAT_BRIDGE_EXPLICIT=false HOMEBOY_MODE="auto" SOURCE_MODE="" SOURCE_MODE_EXPLICIT=false +WORKSPACE_REPOSITORY_CLONES="" +WORKSPACE_REPOSITORIES_EXPLICIT=false +WORKSPACE_REPOSITORY_CLONES_EXPLICIT=false OWNED_SOURCES="" OWNED_SOURCES_EXPLICIT=false OWNED_WRITABLE="" @@ -267,9 +270,15 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --workspace-repository) + source_policy_begin_workspace_repository_declarations source_policy_add_workspace_repository "$2" shift 2 ;; + --workspace-repository-clone) + source_policy_begin_workspace_repository_declarations + source_policy_add_workspace_repository_clone "$2" "$3" + shift 3 + ;; --not-owned) owned_discovery_add_exclusion "$2" shift 2 @@ -375,8 +384,13 @@ OPTIONS: repeating the flag. (--posture is accepted as a deprecated alias; engineering=workspace, managed=owned.) --workspace-repository - Declares a repository authority for workspace mode. - Repeatable; no repository path is inferred. + Declares a repository authority for workspace mode. + Repeatable; no repository path is inferred. + --workspace-repository-clone + Declares and materializes a missing primary checkout. + Repeatable. Existing destinations are validated and + never overwritten. The credential-free declaration is + recorded so upgrade can restore a missing checkout. --not-owned Plugin or theme slug that is NOT the site's despite classifying as owned — a premium or vendor plugin, typically. Repeatable. Recorded on the install. @@ -589,6 +603,7 @@ external_wordpress_validate # The source mode must resolve BEFORE anything that enforces it: the plugin set, the # runtime permission surfaces, and the AGENTS.md guidance all derive from it. source_policy_resolve_mode +source_policy_materialize_workspace_repositories source_policy_validate_workspace_repositories source_policy_resolve_owned_sources source_policy_resolve_writable_paths diff --git a/tests/setup-profile-compiler.mjs b/tests/setup-profile-compiler.mjs index 79204a5..4817fb3 100644 --- a/tests/setup-profile-compiler.mjs +++ b/tests/setup-profile-compiler.mjs @@ -183,12 +183,35 @@ for (const selection of ["auto", "codex", "claude-code", "multiple"]) { assert.ok(!plan.warnings.some((warning) => warning.includes("Legacy profile has no source declaration"))) } +{ + const plan = compile({ + install_target: "local", + target: { wordpress_path: "/tmp/site" }, + runtime: { selection: "opencode" }, + chat_bridge: { selection: "none" }, + source: { + mode: "workspace", + workspace_repositories: [ + { path: "/work/new checkout", remote: "https://github.com/example/project.git" }, + ], + }, + overlays: {}, + }) + assert.match(plan.commands.apply, /--workspace-repository-clone https:\/\/github\.com\/example\/project\.git '[/]work[/]new checkout'/) + assert.deepEqual(plan.summary.workspace_repositories, ["/work/new checkout"]) + assert.deepEqual(plan.summary.workspace_repository_clones, [ + { path: "/work/new checkout", remote: "https://github.com/example/project.git" }, + ]) +} + for (const source of [ null, { mode: "workspace" }, { mode: "workspace", workspace_repositories: [] }, { mode: "workspace", workspace_repositories: ["relative"] }, { mode: "workspace", workspace_repositories: ["/work/repo", "/work/repo"] }, + { mode: "workspace", workspace_repositories: [{ path: "/work/repo", remote: "https://token@example.com/repo.git" }] }, + { mode: "workspace", workspace_repositories: [{ path: "/work/repo", remote: "ssh://git:secret@example.com/repo.git" }] }, { mode: "owned", workspace_repositories: ["/work/repo"] }, ]) { const result = spawnSync("node", ["scripts/compile-setup-profile.mjs"], { diff --git a/tests/workspace-materialization.sh b/tests/workspace-materialization.sh new file mode 100644 index 0000000..113b9ea --- /dev/null +++ b/tests/workspace-materialization.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# Primary repository materialization is host setup state, never WordPress state. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +source "$ROOT/lib/common.sh" +source "$ROOT/lib/source-policy.sh" +source "$ROOT/lib/desired-state-reconciler.sh" + +ORIGIN="$TMP/origin.git" +SEED="$TMP/seed" +SITE_PATH="$TMP/site" +DESTINATION="$TMP/workspace/materialized repository" +mkdir -p "$SEED" "$SITE_PATH" +git -C "$SEED" init -q +git -C "$SEED" config user.email fixture@example.test +git -C "$SEED" config user.name Fixture +printf 'materialized\n' > "$SEED/plugin.php" +git -C "$SEED" add plugin.php +git -C "$SEED" commit -qm initial +git clone -q --bare "$SEED" "$ORIGIN" + +SOURCE_MODE=workspace +WORKSPACE_REPOSITORIES="" +WORKSPACE_REPOSITORY_CLONES="" +WORKSPACE_REPOSITORIES_EXPLICIT=false +WORKSPACE_REPOSITORY_CLONES_EXPLICIT=false +DRY_RUN=false +LOCAL_MODE=true +EXTERNAL_WORDPRESS=false +IS_STUDIO=false +RUNTIME=opencode +INSTALL_CHAT=false +CHAT_BRIDGE="" +HOMEBOY_MODE=disabled +DETECTED_RUNTIMES=(opencode) + +source_policy_add_workspace_repository_clone "$ORIGIN" "$DESTINATION" +source_policy_materialize_workspace_repositories +test -f "$DESTINATION/plugin.php" +CANONICAL_DESTINATION="$(cd "$DESTINATION" && pwd -P)" +test "$(git -C "$DESTINATION" rev-parse --show-toplevel)" = "$CANONICAL_DESTINATION" +test "$(source_policy_workspace_repositories)" = "$DESTINATION" +source_policy_materialize_workspace_repositories + +installation_profile_normalize "$INSTALLATION_OPERATION_SETUP" +installation_profile_write +PROFILE="$(installation_profile_file)" +grep -q '^workspace_repository_clones=' "$PROFILE" +if grep -q "$ORIGIN" "$PROFILE"; then + echo "FAIL: clone declaration was not encoded" >&2 + exit 1 +fi + +WORKSPACE_REPOSITORIES="" +WORKSPACE_REPOSITORY_CLONES="" +WORKSPACE_REPOSITORY_CLONES_EXPLICIT=false +SOURCE_MODE_EXPLICIT=false +installation_profile_load +test "$WORKSPACE_REPOSITORIES" = "$DESTINATION" +case "$WORKSPACE_REPOSITORY_CLONES" in + "$ORIGIN"$'\t'"$DESTINATION") ;; + *) echo "FAIL: clone declaration did not round-trip" >&2; exit 1 ;; +esac + +rm -rf "$DESTINATION" +source_policy_materialize_workspace_repositories +test -f "$DESTINATION/plugin.php" + +git -C "$DESTINATION" config remote.origin.url "$TMP/wrong-origin.git" +if bash -c 'source "$1/lib/common.sh"; source "$1/lib/source-policy.sh"; SOURCE_MODE=workspace; WORKSPACE_REPOSITORY_CLONES="$2"$'\''\t'\''"$3"; source_policy_materialize_workspace_repositories' _ "$ROOT" "$ORIGIN" "$DESTINATION" >/dev/null 2>&1; then + echo "FAIL: existing checkout with the wrong origin was accepted" >&2 + exit 1 +fi +git -C "$DESTINATION" config remote.origin.url "$ORIGIN" + +source_policy_begin_workspace_repository_declarations +source_policy_add_workspace_repository "$SEED" +installation_profile_load +test -z "$WORKSPACE_REPOSITORY_CLONES" +test "$WORKSPACE_REPOSITORIES" = "$(cd "$SEED" && pwd -P)" + +WORKSPACE_REPOSITORIES="" +WORKSPACE_REPOSITORY_CLONES="$ORIGIN"$'\t'"$DESTINATION" +WORKSPACE_REPOSITORIES_EXPLICIT=false + +rm -rf "$DESTINATION" +mkdir -p "$DESTINATION" +printf 'preserve\n' > "$DESTINATION/sentinel" +if bash -c 'source "$1/lib/common.sh"; source "$1/lib/source-policy.sh"; SOURCE_MODE=workspace; WORKSPACE_REPOSITORY_CLONES="$2"$'\''\t'\''"$3"; source_policy_materialize_workspace_repositories' _ "$ROOT" "$ORIGIN" "$DESTINATION" >/dev/null 2>&1; then + echo "FAIL: existing non-Git destination was overwritten" >&2 + exit 1 +fi +test "$(cat "$DESTINATION/sentinel")" = preserve + +WORKSPACE_REPOSITORIES="" +WORKSPACE_REPOSITORY_CLONES="" +if bash -c 'source "$1/lib/common.sh"; source "$1/lib/source-policy.sh"; source_policy_add_workspace_repository_clone "$2" "$3"' _ "$ROOT" 'https://token@example.com/repo.git' "$TMP/credential-target" >/dev/null 2>&1; then + echo "FAIL: credential-bearing remote was accepted" >&2 + exit 1 +fi +if bash -c 'source "$1/lib/common.sh"; source "$1/lib/source-policy.sh"; source_policy_add_workspace_repository_clone "$2" "$3"' _ "$ROOT" 'ssh://git:secret@example.com/repo.git' "$TMP/credential-target" >/dev/null 2>&1; then + echo "FAIL: SSH credential-bearing remote was accepted" >&2 + exit 1 +fi + +ln -s "$TMP/workspace" "$TMP/workspace-link" +if bash -c 'source "$1/lib/common.sh"; source "$1/lib/source-policy.sh"; SOURCE_MODE=workspace; WORKSPACE_REPOSITORY_CLONES="$2"$'\''\t'\''"$3"; source_policy_materialize_workspace_repositories' _ "$ROOT" "$ORIGIN" "$TMP/workspace-link/redirected" >/dev/null 2>&1; then + echo "FAIL: user-controlled symlink ancestor was accepted" >&2 + exit 1 +fi + +echo "PASS: workspace repositories materialize from persisted host intent" diff --git a/upgrade.sh b/upgrade.sh index f6d9cdb..046179b 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -136,6 +136,9 @@ IS_STUDIO=false CHAT_BRIDGE="" HOMEBOY_MODE="${HOMEBOY_MODE:-auto}" WITH_HOMEBOY="${WITH_HOMEBOY:-false}" +WORKSPACE_REPOSITORY_CLONES="" +WORKSPACE_REPOSITORIES_EXPLICIT=false +WORKSPACE_REPOSITORY_CLONES_EXPLICIT=false # True when the operator forced the identity via --root / --non-root. # Suppresses adopt_service_identity_from_units (existing-unit adoption). SERVICE_USER_FORCED=false @@ -171,7 +174,8 @@ while [[ $# -gt 0 ]]; do --ai-gateway-api-model) AI_GATEWAY_API_MODEL_ID="$2"; shift 2 ;; --rotate-ai-gateway-token) ROTATE_AI_GATEWAY_TOKEN=true; shift ;; --source-mode|--posture) SOURCE_MODE="$2"; SOURCE_MODE_EXPLICIT=true; shift 2 ;; - --workspace-repository) source_policy_add_workspace_repository "$2"; shift 2 ;; + --workspace-repository) source_policy_begin_workspace_repository_declarations; source_policy_add_workspace_repository "$2"; shift 2 ;; + --workspace-repository-clone) source_policy_begin_workspace_repository_declarations; source_policy_add_workspace_repository_clone "$2" "$3"; shift 3 ;; --not-owned) owned_discovery_add_exclusion "$2"; shift 2 ;; --owned-source|--managed-source) OWNED_SOURCES="${OWNED_SOURCES}${OWNED_SOURCES:+ }$2"; OWNED_SOURCES_EXPLICIT=true; shift 2 ;; --owned-writable|--managed-writable) OWNED_WRITABLE="${OWNED_WRITABLE}${OWNED_WRITABLE:+ }$2"; OWNED_WRITABLE_EXPLICIT=true; shift 2 ;; @@ -240,6 +244,7 @@ USAGE: profile and its managed capability configuration. ./upgrade.sh --source-mode ./upgrade.sh --workspace-repository + ./upgrade.sh --workspace-repository-clone Where code changes land: workspace | owned (default: the mode recorded at setup time). Two shapes, not two levels. --posture is @@ -457,6 +462,7 @@ detect_environment # upgrade converges a managed install instead of silently reverting it to # engineering; --posture overrides and re-records. source_policy_resolve_mode +source_policy_materialize_workspace_repositories source_policy_validate_workspace_repositories source_policy_resolve_owned_sources source_policy_resolve_writable_paths diff --git a/verify.sh b/verify.sh index 2a3383e..7036339 100755 --- a/verify.sh +++ b/verify.sh @@ -38,6 +38,7 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/common.sh" +source "$SCRIPT_DIR/lib/source-policy.sh" QUIET=false JSON=false @@ -139,8 +140,13 @@ if [ "$SOURCE_MODE" != "workspace" ]; then else PROFILE="$SITE_PATH/.wp-coding-agents/installation-profile" DECLARED_WORKSPACE_REPOSITORIES="" + DECLARED_WORKSPACE_CLONES="" if [ -f "$PROFILE" ]; then DECLARED_WORKSPACE_REPOSITORIES="$(awk -F= '$1 == "workspace_repositories" { print substr($0, index($0, "=") + 1); exit }' "$PROFILE")" + ENCODED_WORKSPACE_CLONES="$(awk -F= '$1 == "workspace_repository_clones" { print substr($0, index($0, "=") + 1); exit }' "$PROFILE")" + if [ -n "$ENCODED_WORKSPACE_CLONES" ]; then + DECLARED_WORKSPACE_CLONES="$(source_policy_workspace_clone_specs_decode "$ENCODED_WORKSPACE_CLONES" 2>/dev/null)" || fail "workspace repository clone declarations are invalid" + fi fi if [ -z "$DECLARED_WORKSPACE_REPOSITORIES" ]; then @@ -167,6 +173,14 @@ PY fi done < <(printf '%s\n' "$DECLARED_WORKSPACE_REPOSITORIES" | tr ':' '\n') fi + while IFS=$'\t' read -r remote repository; do + [ -n "$repository" ] || continue + if [ "$(git -C "$repository" config --get remote.origin.url 2>/dev/null || true)" = "$remote" ]; then + pass "materialized repository $repository matches its declared origin" + else + fail "materialized repository $repository does not match its declared origin" + fi + done <<< "$DECLARED_WORKSPACE_CLONES" fi # ---------------------------------------------------------------------------