diff --git a/.agents/skills/akua-intelligence-bootstrap/SKILL.md b/.agents/skills/akua-intelligence-bootstrap/SKILL.md new file mode 100644 index 000000000..a6192789c --- /dev/null +++ b/.agents/skills/akua-intelligence-bootstrap/SKILL.md @@ -0,0 +1,113 @@ +--- +name: akua-intelligence-bootstrap +description: "Bootstrap or recover a dedicated Agent OS intelligence cluster through Akua's native API, MCP, CLI, Akua packages, and Kubernetes without adding an Agent OS provisioning service." +user-invocable: false +metadata: + internal: true +--- + +# Akua intelligence bootstrap + +Load this skill before provisioning, recovering, or handing off a Firstmate in an Akua-managed intelligence cluster. + +## Boundary + +- The Akua workspace and Hetzner project must be dedicated to Agent OS infrastructure and contain no product, production, or customer resources. +- Provisioning machines, creating or revoking credentials, retrieving kubeconfig, and granting cluster-admin require the captain's explicit approval for the exact run. +- An available credential proves capability, not approval. +- Use Akua's native Platform MCP, REST API, CLI, and packages directly. +- Do not build or invoke an Agent OS provisioning wrapper, controller, Install, or GitOps workflow. +- Use a fresh idempotency key for each intended create and reuse that key only when retrying the same intended operation. +- Never put an Akua token, Hetzner token, kubeconfig, or authorization header in Git, a prompt, a status file, command arguments, or retained evidence. + +## Native API surface + +The current public API base is `https://api.akua.dev/v1`. +When using REST, keep a complete HTTP authorization header in a mode-`0400` file and pass it with curl's native `-H @file` support. +Write secret-bearing JSON through protected files or stdin, never shell arguments. + +Use the Platform MCP `search` tool again before a mutation so current schemas outrank this routing table. + +| Outcome | Native endpoint | +| --- | --- | +| Store the approved Hetzner token | `POST /secrets`, kind `cloud_provider/hcloud` | +| Validate the provider token | `POST /secrets/validate_token` | +| Create managed KaaS | `POST /clusters` | +| Wait for cluster creation | `POST /operations/{id}:wait?timeout=60` | +| Create Hetzner compute configuration | `POST /compute/configs` | +| Inspect available capacity and price | `GET /compute/instance_types?config={config}` | +| Create a worker | `POST /compute/machines` | +| Inspect worker state | `GET /compute/machines/{providerId}` | +| Retrieve the approved owner kubeconfig | `GET /clusters/{id}/kubeconfig` | +| Create the clustered-Firstmate token | `POST /api_tokens` | +| Inventory tokens | `GET /api_tokens` | +| Revoke the local bootstrap token | `DELETE /api_tokens/{id}` | + +## Authorization overlay contract + +The public Firstmate package never accepts an Akua credential input or renders an Akua Secret reference. +Akua authorization belongs to the separate namespace-local integration overlay under `deploy/akua/`. +The grant patch mounts only the selected Secret at `/var/run/secrets/agent-os/akua` and sets `AKUA_AUTH_HEADER_FILE=/var/run/secrets/agent-os/akua/authorization`. +The Secret must contain one `authorization` key with the complete header and must live in the same namespace as the Firstmate StatefulSet. +Never put the Secret value into the overlay, package inputs, or command arguments. + +Set `context` and `namespace` from the separately approved target and grant the overlay through the serialized integration helper: + +```sh +AGENT_OS_CONTEXT="$context" AGENT_OS_NAMESPACE="$namespace" bin/agent-os-akua-auth.sh grant "$secret_name" +``` + +The helper holds the stable control Lease and namespace fleet Lease in that order, validates exact StatefulSet UID and resourceVersion, verifies the named Secret reference without reading Secret bytes, applies one CAS strategic patch, and verifies both the retained StatefulSet overlay and its exact-owned Pod. +If grant verification fails, the helper removes the overlay by CAS, verifies the fail-closed rollout, records only bounded Secret metadata evidence, and requires an explicit revoke before a different Secret identity can be granted. + +Revocation is owned by the same integration boundary. +Revoke the Akua API token and prove it fails first, then revoke the overlay through the same serialized helper: + +```sh +AGENT_OS_CONTEXT="$context" AGENT_OS_NAMESPACE="$namespace" bin/agent-os-akua-auth.sh revoke "$secret_name" +``` + +Delete only the named namespace-local Secret after explicit cleanup approval. +The revoke patch removes the environment entry, volume mount, and volume without changing the public package or any other Firstmate setting. + +## Bootstrap procedure + +1. Record the approved workspace ID, intended region, machine constraints, expiration or cleanup condition, and evidence directory without recording secrets. +2. Read the live workspace, Secret, cluster, compute configuration, machine, and token inventories before creating anything. +3. Stop if the workspace or Hetzner project contains production resources or if an existing matching resource makes intent ambiguous. +4. Validate the provider token without storing it, then create or select the `cloud_provider/hcloud` Secret and require server validation state `valid` before creating compute. +5. Create the managed cluster with an idempotency key, preserve its operation ID, and wait for terminal `SUCCEEDED` state. +6. Create the compute configuration from the validated Secret, inspect available instance types, and select the smallest type satisfying the approved CPU, RAM, disk, architecture, price, and region constraints. +7. Create the worker, then verify both Akua machine state and Kubernetes Node readiness. +8. Retrieve kubeconfig only into a protected temporary file and verify the cluster identity before applying anything. +9. Build or select a published Agent OS image by immutable digest. +10. Render `tools/agent-os/packages/firstmate/package.k` with `akua render`, inspect the ordinary credential-free YAML, and apply it with `kubectl`. +11. Create a distinct clustered-Firstmate API token, create the namespace-local authorization Secret from a protected header file, and apply the grant overlay exactly as defined above. +12. Verify the StatefulSet is ready, Herdr responds, the persistent home is writable, the `in-cluster` context is cluster-admin only in this intelligence cluster, and the Pod can list its Akua workspace through `curl -H @"$AKUA_AUTH_HEADER_FILE"`. +13. Probe the approved primary model route with a bounded request and record only provider, model, result, timing, and cost. + If there is no independently approved fallback provider, record the single-provider availability risk instead of presenting the fleet as quota-resilient. +14. From the clustered Firstmate, inventory all workspace token IDs and prove the new token can perform the required Akua and Kubernetes reads. +15. Only after that handoff succeeds, revoke the known local bootstrap token and prove it now receives an authentication failure. +16. Destroy every protected temporary file and record only non-secret resource IDs, operation IDs, timestamps, states, image digest, test results, costs, and interventions. + +## Recovery and cleanup + +- A Pod restart reuses the Firstmate PVC and the authorization mount declared by the separate overlay. +- A worker replacement must preserve or reattach every PVC holding unique work before deleting the old worker. +- Do not claim cluster-loss recovery until an external encrypted backup has been restored in a clean cluster. +- Machine, cluster, authorization overlay, Secret, token, and workspace deletion are separate destructive actions. +- Execute only the approved cleanup scope and verify retained resources afterward. + +## Completion evidence + +The bootstrap is complete only when current external state proves all of these: + +- Akua operation IDs reached `SUCCEEDED`; +- the worker is ready in Akua and Kubernetes; +- the Firstmate StatefulSet and Herdr server are ready; +- at least one approved model route completes a bounded request; +- the home and installed-tool paths survive a Pod replacement; +- the clustered token works from the Pod; +- the bootstrap token is revoked and fails authentication; +- no product or production resource exists in the intelligence workspace; and +- the evidence record contains no secret values. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 67bffd886..704f457d3 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -87,6 +87,8 @@ The supported launch-profile flags below were verified locally on 2026-06-30 wit | pi | `--model ` | `--thinking ` | Verified on pi 0.80.2. `max` prints an invalid-thinking warning, so firstmate omits Pi effort when the requested effort is `max`. | | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | +When a Pi provider route is known, pass a provider-qualified model id such as `openai-codex/gpt-5.6-terra`; unqualified names may resolve to an unauthenticated provider. + When a requested effort value is outside the harness-specific accepted set, `fm-spawn` records the requested `effort=` in meta but emits no effort flag for that harness. This preserves launch success instead of passing a known-bad value. diff --git a/.agents/skills/kubernetes-fleet/SKILL.md b/.agents/skills/kubernetes-fleet/SKILL.md new file mode 100644 index 000000000..33e543a58 --- /dev/null +++ b/.agents/skills/kubernetes-fleet/SKILL.md @@ -0,0 +1,51 @@ +--- +name: kubernetes-fleet +description: "Operate Agent OS crewmates as Kubernetes Pods with persistent homes and explicit authority boundaries." +user-invocable: false +metadata: + internal: true +--- + +# Kubernetes fleet + +Load this skill only when the current firstmate is running in Kubernetes or is explicitly managing Kubernetes-backed crewmates. + +## Operating contract + +- Keep every crewmate general-purpose; its brief, tools, and authority specialize it for the current task. +- A crewmate normally communicates only with its parent through its terminal, status files, reports, and delivered Git state. +- The image includes Akua's CLI and the canonical runtime template lives at `tools/agent-os/packages/firstmate/crewmate.yaml` inside the one public Firstmate package. +- The optional persistent controller package lives at `tools/agent-os/packages/firstmate/`; load `akua-intelligence-bootstrap` before using the separate Akua authorization overlay against Akua-managed infrastructure. +- Use the bundled K9s terminal UI when it makes live Kubernetes inspection faster than individual `kubectl` reads. +- Render the package with `akua render`, inspect or edit its ordinary YAML when useful, and apply it with `kubectl`. +- Treat AI credentials as explicit per-mate grants, never as ambient inheritance merely because Firstmate can read them. +- Create or select a namespace-local Kubernetes Secret only after its AI authority is explicitly authorized. +- The Secret must contain the selected provider's `auth.json` key and must be provisioned independently instead of cloning or sharing the primary credential. +- Pass only its name through `AGENT_OS_AI_SECRET` when invoking `bin/agent-os-crewmate.sh create `. +- The helper never reads or discovers the Secret value, and the default Role intentionally has no Secret-read permission. +- Kubernetes projects only the selected `auth.json` key into a dedicated read-only runtime directory, and the entrypoint links that file into the writable PVC-backed Pi state without copying credential bytes. +- A missing Secret or key keeps the Pod unready, makes create fail, removes the non-running Pod, and retains the PVC for an authorized retry. +- Probe the selected model route before launching work; when quota is unavailable, use another explicitly granted provider or report the capacity blocker instead of repeatedly spawning agents. +- Give every launched Herdr agent a task-unique name, close only a confirmed dead restored pane before reuse, and never replace a live agent. +- Grade completion by the promised artifact or delivered Git state; Herdr `idle` alone is not a completion signal. +- Use `bin/agent-os-crewmate.sh create ` to create a separate Pod and persistent home. +- Every mutating crewmate operation acquires the stable control Lease, namespace fleet Lease, and per-crewmate Lease in that order and releases them in reverse order. +- Use `status` to inspect it, `stop` to remove only the Pod, and `restart` to replace only the Pod on its retained PVC. +- The ambiguous `delete` command is rejected. +- `purge --yes` is the only operation that destroys a persistent home. +- Stopping the owned Pod invalidates earlier checkpoint evidence and records a non-secret `agent-os.dev/quiesced-operation` generation only after proving Pod absence. +- Before purge, independently checkpoint or deliver unique work from the stopped home, then annotate the owned PVC with `agent-os.dev/checkpoint-state=clean`, a non-secret RFC3339 `agent-os.dev/checkpoint-at` value, and `agent-os.dev/checkpoint-operation` equal to the current `agent-os.dev/quiesced-operation` value. +- Purge verifies exact installation and crewmate ownership, displays the target, requires its own confirmation, and records requested and completed phases in `AGENT_OS_PURGE_EVIDENCE_FILE` or `$FM_HOME/data/crewmate-purge-evidence.log`. +- Purge evidence contains only time, namespace, crewmate ID, resource names, phase, and checkpoint time. +- Never mount the primary home into a child Pod. +- The demo child receives no Kubernetes ServiceAccount token by default. +- A Pod with an authorized ServiceAccount gets a token-file-backed `in-cluster` kubeconfig automatically, so Firstmate does not need to copy a bearer token into a temporary kubeconfig. +- The OrbStack primary's cluster-admin binding is a local-demo trust decision, not a production-safe default. +- Pin an explicit host context for host-side `kubectl`; inside an authorized Pod use the generated `in-cluster` context. + +For normal credential rotation, update the explicitly authorized namespace-local Secret, restart the owned Pod with the same PVC, prove a bounded request uses the replacement credential, and revoke the old credential only after that proof succeeds. +For urgent revocation, stop the owned Pod first, revoke the old credential, update or select the approved replacement Secret, and restart only when that replacement is ready. +Never print, copy, persist, or place credential values in command arguments or evidence during either flow. + +Use ordinary `kubectl exec`, Herdr, files, and Git to supervise the child. +Do not add a custom inter-agent chat protocol or Task/Run service. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index c8febd24d..ceb0ba77e 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -74,7 +74,7 @@ This is secondmate-only: crewmate/scout model resolution is untouched by this fi This section is the single owner of the secondmate sync and inheritable-config propagation contract; `AGENTS.md` sections 3 and 4 point here. Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The locked session-start bootstrap sweep runs the same guarded fast-forward for every live secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). -That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. +That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a mutable-source standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. The same launch and the same locked bootstrap sweep also propagate the primary's declared inheritable local config, currently `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`, into the secondmate home's `config/`. Because `config/` is gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index 015bc1350..0ba0cae96 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -1,6 +1,8 @@ --- name: updatefirstmate -description: Self-update a running firstmate and its secondmates to the latest from origin. Use when the captain invokes /updatefirstmate (e.g. "/updatefirstmate", "update firstmate", "pull the latest firstmate"). Fast-forwards this firstmate repo's default branch and every secondmate home from origin (fast-forward only, never forced, never disruptive), then re-reads AGENTS.md and nudges each updated secondmate to do the same, so the whole tree runs the latest bin/ and instructions. +description: >- + Update a mutable-source firstmate and its secondmates to the latest from origin, or report that an immutable candidate/release image must be upgraded by digest. + Use when the captain invokes /updatefirstmate (e.g. "/updatefirstmate", "update firstmate", "pull the latest firstmate"). user-invocable: true metadata: internal: true @@ -8,26 +10,33 @@ metadata: # updatefirstmate -Self-update firstmate in place. -Firstmate is its own repo, behind the same no-mistakes gate as any project, so new tracked material (`AGENTS.md`, `bin/`, `.agents/skills/`, and public `skills/`) reaches `main` and then sits there until each running firstmate pulls it. +Update firstmate according to its persisted source policy. +Firstmate is its own repo, behind the same no-mistakes gate as any project, so new tracked material (`AGENTS.md`, `bin/`, `.agents/skills/`, and public `skills/`) reaches `main` and then waits for a mutable installation to pull it or an immutable installation to upgrade to an image that contains it. Only `AGENTS.md`, `bin/`, and `.agents/skills/` are a running firstmate instruction surface; public `skills/` is installer-facing and is not loaded by firstmate. -This skill performs that pull for the running main firstmate and every secondmate, without disturbing any in-flight work. +For a mutable-source installation, this skill performs that pull for the running main firstmate and every secondmate without disturbing any in-flight work. The update is **fast-forward only** - the same sanctioned self-write as the fleet sync firstmate already runs. It never forces, never creates a merge commit, never stashes, and advances a target only on a clean fast-forward; anything dirty, diverged, offline, or on the wrong branch is skipped and reported. A tracked-files fast-forward leaves the gitignored operational dirs (data/, state/, config/, projects/, .no-mistakes/) untouched, so a secondmate's in-flight work is never disrupted. This touches only the firstmate repo and its own worktrees, never anything under `projects/`. +Candidate and release images use a different, immutable source policy. +Their persisted runtime-source provenance pins the exact source commit and archive digest selected by the image, including the policy recorded on secondmate homes. +When `bin/fm-update.sh` detects that policy, it exits with `error: self-update is disabled for immutable image source` before fetching or changing any checkout. +Do not remove, rewrite, or bypass the provenance marker to force an origin pull. +Update that installation by selecting a new published image digest and running the Kubernetes package upgrade documented in `docs/kubernetes.md`; image startup then verifies the new exact source and converges eligible secondmate homes to it. + ## What it does 1. **Run the updater:** ```sh bin/fm-update.sh ``` - It fast-forwards this firstmate repo's default branch from origin, then fast-forwards every registered secondmate home (each a treehouse worktree of this same repo, leased at a detached HEAD on the default branch) the same way. + On mutable source, it fast-forwards this firstmate repo's default branch from origin, then fast-forwards every registered secondmate home (each a treehouse worktree of this same repo, leased at a detached HEAD on the default branch) the same way. It prints one status line per target (`updated ..` / `already current` / `skipped: `), followed by two action lines that tell you exactly what to do next: - `reread-firstmate: yes|no` - `nudge-secondmates: fm-...|none` + On immutable image source, it prints the refusal above and exits non-zero; stop the in-place update flow and report that the installation needs an image-digest upgrade. 2. **Re-read AGENTS.md if your own instructions changed.** When the updater printed `reread-firstmate: yes`, the tracked instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) just advanced under you. @@ -53,8 +62,10 @@ This touches only the firstmate repo and its own worktrees, never anything under - **Fast-forward only.** A target that has diverged, is dirty, is offline, or is on a non-default branch is skipped and reported, never forced or stashed. Nothing with unlanded work is ever discarded - this is prime directive #3. +- **Immutable image source stays pinned.** + Candidate and release runtimes never fetch or fast-forward through this command; changing their source requires an image-digest upgrade. - **Only the firstmate repo and its worktrees** are touched, never `projects/`. It is the same sanctioned self-write as the fleet sync. - **Secondmates are never disrupted.** - A secondmate gets a tracked-files fast-forward (safe while it is mid-task, since its work lives in gitignored operational dirs and separate project worktrees) plus a gentle re-read nudge. + On mutable source, a secondmate gets a tracked-files fast-forward (safe while it is mid-task, since its work lives in gitignored operational dirs and separate project worktrees) plus a gentle re-read nudge. It is never torn down, interrupted, or forced. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..3904be96e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +** +!Dockerfile +!image/ +image/** +!image/debian.sources +!image/npm/ +image/npm/** +!image/npm/package.json +!image/npm/package-lock.json +!image/agent-os-source.tar +!image/agent-os-bootstrap.tar +!image/agent-os-source.attestation diff --git a/.github/workflows/agent-os-image.yml b/.github/workflows/agent-os-image.yml new file mode 100644 index 000000000..5e27e5b80 --- /dev/null +++ b/.github/workflows/agent-os-image.yml @@ -0,0 +1,1056 @@ +name: Agent OS image + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: agent-os-image-${{ github.ref }} + cancel-in-progress: false + +env: + IMAGE: ghcr.io/akua-dev/agent-os + +jobs: + behavior: + name: Exact-source behavior gate + uses: ./.github/workflows/ci.yml + + provenance: + if: github.event_name == 'push' + name: Protected-main source provenance + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify publication source + env: + GH_TOKEN: ${{ github.token }} + REF_PROTECTED: ${{ github.ref_protected }} + run: | + set -eu + trusted_git() { + env -i HOME=/nonexistent PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + /usr/bin/git -c credential.helper= -c core.hooksPath=/dev/null \ + -c http.proxy= -c https.proxy= "$@" + } + if [ "$GITHUB_REF" = refs/heads/main ]; then + [ "$REF_PROTECTED" = true ] || { echo "::error::main is not reported protected"; exit 1; } + elif [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then + tag=${GITHUB_REF#refs/tags/} + [ "$(trusted_git cat-file -t "$GITHUB_REF")" = tag ] || { echo "::error::release tag must be annotated"; exit 1; } + record_commit=$(trusted_git for-each-ref --format='%(contents)' "$GITHUB_REF" | sed -n 's/^release-record-commit=\([0-9a-f]\{40\}\)$/\1/p') + [ -n "$record_commit" ] || { echo "::error::release tag lacks immutable record commit"; exit 1; } + trusted_git fetch --no-tags https://github.com/akua-dev/agent-os.git refs/heads/main:refs/remotes/origin/main + trusted_git merge-base --is-ancestor "$record_commit" refs/remotes/origin/main + gh api "repos/$GITHUB_REPOSITORY/branches/main/protection" >/dev/null + trusted_git show "$record_commit:image/releases/$tag.json" > "$RUNNER_TEMP/release-record.json" + trusted_git merge-base --is-ancestor "$(jq -r .commit "$RUNNER_TEMP/release-record.json")" refs/remotes/origin/main + ruleset_id=$(jq -r .tag_ruleset_id "$RUNNER_TEMP/release-record.json") + gh api "repos/$GITHUB_REPOSITORY/rulesets/$ruleset_id" > "$RUNNER_TEMP/tag-ruleset.json" + ruleset_sha=$(jq -cS . "$RUNNER_TEMP/tag-ruleset.json" | sha256sum | awk '{print $1}') + [ "$ruleset_sha" = "$(jq -r .tag_ruleset_sha256 "$RUNNER_TEMP/release-record.json")" ] || { + echo "::error::release tag ruleset differs from immutable record" + exit 1 + } + jq -e ' + .enforcement == "active" and .target == "tag" and + (.bypass_actors | length == 0) and + ([.rules[].type] | index("deletion") != null and index("non_fast_forward") != null)' \ + "$RUNNER_TEMP/tag-ruleset.json" >/dev/null || { + echo "::error::release tag ruleset is not active, immutable, tag-targeted, and bypass-free" + exit 1 + } + ruby -rjson - "$GITHUB_REF" "$RUNNER_TEMP/tag-ruleset.json" <<'RUBY' + ref, path = ARGV + names = JSON.parse(File.read(path)).fetch("conditions").fetch("ref_name") + include_patterns = names.fetch("include") + exclude_patterns = names.fetch("exclude") + patterns = include_patterns + exclude_patterns + abort "release tag ruleset contains unsupported ref syntax" unless patterns.all? do |pattern| + pattern.is_a?(String) && !pattern.include?("\\") && !pattern.include?("[^") && + (!pattern.start_with?("~") || pattern == "~ALL") + end + matches = lambda do |pattern| + pattern == "~ALL" || File.fnmatch?(pattern, ref, File::FNM_PATHNAME) + end + unless !include_patterns.empty? && include_patterns.any?(&matches) && !exclude_patterns.any?(&matches) + abort "release tag is not covered exclusively by the recorded tag ruleset" + end + RUBY + else + echo "::error::unsupported publication ref $GITHUB_REF" + exit 1 + fi + + validate: + name: Validate linux/amd64 and linux/arm64 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Prepare exact source bundle + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + id: source + run: | + set -eu + trusted_git() { + env -i HOME=/nonexistent PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + /usr/bin/git -c credential.helper= -c core.hooksPath=/dev/null \ + -c http.proxy= -c https.proxy= "$@" + } + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + [ "$(trusted_git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" ] + AGENT_OS_SOURCE_MODE=event AGENT_OS_SOURCE_EVENT_COMMIT="${{ github.event.pull_request.head.sha }}" \ + bin/agent-os-source-bundle.sh > "$RUNNER_TEMP/source-metadata" + else + bin/agent-os-source-bundle.sh > "$RUNNER_TEMP/source-metadata" + fi + cat "$RUNNER_TEMP/source-metadata" >> "$GITHUB_OUTPUT" + + - if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 + + - if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Build without publication credentials + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true + build-args: | + AGENT_OS_SOURCE_COMMIT=${{ steps.source.outputs.commit }} + AGENT_OS_SOURCE_TREE=${{ steps.source.outputs.tree }} + AGENT_OS_SOURCE_BRANCH=${{ steps.source.outputs.branch }} + AGENT_OS_SOURCE_ORIGIN=${{ steps.source.outputs.origin }} + AGENT_OS_SOURCE_MODE=${{ steps.source.outputs.mode }} + AGENT_OS_SOURCE_REF=${{ steps.source.outputs.ref }} + + publish: + if: github.event_name == 'push' + needs: [behavior, provenance, validate] + name: Publish linux/amd64 and linux/arm64 + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + packages: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Prepare exact release candidate source + if: github.ref == 'refs/heads/main' + id: candidate_source + run: | + set -eu + AGENT_OS_SOURCE_MODE=candidate bin/agent-os-source-bundle.sh > "$RUNNER_TEMP/candidate-source-metadata" + cat "$RUNNER_TEMP/candidate-source-metadata" >> "$GITHUB_OUTPUT" + + - if: github.ref == 'refs/heads/main' + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 + with: + version: 1.3.0 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare immutable release source + if: startsWith(github.ref, 'refs/tags/') + id: release_source + run: | + set -eu + trusted_git() { + env -i HOME=/nonexistent PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + /usr/bin/git -c credential.helper= -c core.hooksPath=/dev/null \ + -c http.proxy= -c https.proxy= "$@" + } + record_commit=$(trusted_git for-each-ref --format='%(contents)' "$GITHUB_REF" | sed -n 's/^release-record-commit=\([0-9a-f]\{40\}\)$/\1/p') + trusted_git show "$record_commit:image/releases/${GITHUB_REF#refs/tags/}.json" > "$RUNNER_TEMP/release-record.json" + candidate_record_digest=$(jq -er '.candidate_record_digest' "$RUNNER_TEMP/release-record.json") + oras manifest fetch "$IMAGE@$candidate_record_digest" > "$RUNNER_TEMP/release-source-candidate-manifest.json" + [ "sha256:$(sha256sum "$RUNNER_TEMP/release-source-candidate-manifest.json" | awk '{print $1}')" = "$candidate_record_digest" ] + bootstrap_blob=$(jq -er '[.layers[] | select(.mediaType == "application/vnd.akua.agent-os.bootstrap.v1+tar" and .annotations["org.opencontainers.image.title"] == "agent-os-bootstrap.tar")] | select(length == 1) | .[0].digest' "$RUNNER_TEMP/release-source-candidate-manifest.json") + oras blob fetch --output "$RUNNER_TEMP/agent-os-bootstrap.tar" "$IMAGE@$bootstrap_blob" + [ "$(sha256sum "$RUNNER_TEMP/agent-os-bootstrap.tar" | awk '{print $1}')" = "$(jq -r .bootstrap_archive_sha256 "$RUNNER_TEMP/release-record.json")" ] + AGENT_OS_SOURCE_MODE=release AGENT_OS_SOURCE_RELEASE_TAG="${GITHUB_REF#refs/tags/}" \ + AGENT_OS_RELEASE_RECORD_COMMIT="$record_commit" \ + AGENT_OS_RELEASE_BOOTSTRAP_ARCHIVE="$RUNNER_TEMP/agent-os-bootstrap.tar" \ + bin/agent-os-source-bundle.sh > "$RUNNER_TEMP/release-source-metadata" + [ "$(sha256sum "$RUNNER_TEMP/release-record.json" | awk '{print $1}')" = \ + "$(sed -n 's/^release_record_sha256=//p' "$RUNNER_TEMP/release-source-metadata")" ] + cat "$RUNNER_TEMP/release-source-metadata" >> "$GITHUB_OUTPUT" + + - name: Reserve or recover commit-indexed release candidate + if: github.ref == 'refs/heads/main' + id: candidate_reservation + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + claim_prefix="refs/agent-os/candidate-reservations/$GITHUB_SHA" + source_tree_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$GITHUB_SHA" --jq .tree.sha) + create_claim() { + claim_ref=$1 + owner_run_id=$2 + claim_message=$(printf 'agent-os-candidate-reservation-v1\nsource-commit=%s\nowner-run-id=%s' \ + "$GITHUB_SHA" "$owner_run_id") + claim_commit=$(gh api --method POST "repos/$GITHUB_REPOSITORY/git/commits" \ + --raw-field message="$claim_message" --raw-field tree="$source_tree_sha" \ + --raw-field "parents[]=$GITHUB_SHA" --jq .sha) + if gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + --raw-field ref="$claim_ref" --raw-field sha="$claim_commit" \ + > "$RUNNER_TEMP/candidate-claim-create.json" \ + 2> "$RUNNER_TEMP/candidate-claim-create.err"; then + return 0 + fi + if grep -Eiq '422|Reference already exists' "$RUNNER_TEMP/candidate-claim-create.err"; then + return 1 + fi + cat "$RUNNER_TEMP/candidate-claim-create.err" >&2 + exit 1 + } + load_claim() { + claim_ref=$1 + claim_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/${claim_ref#refs/}" --jq .object.sha) + gh api "repos/$GITHUB_REPOSITORY/git/commits/$claim_sha" \ + > "$RUNNER_TEMP/candidate-claim-commit.json" + jq -e --arg source "$GITHUB_SHA" --arg tree "$source_tree_sha" \ + '.tree.sha == $tree and (.parents | length == 1) and .parents[0].sha == $source' \ + "$RUNNER_TEMP/candidate-claim-commit.json" >/dev/null + CLAIM_OWNER=$(jq -r .message "$RUNNER_TEMP/candidate-claim-commit.json" | + sed -n 's/^owner-run-id=\([0-9][0-9]*\)$/\1/p') + [[ "$CLAIM_OWNER" =~ ^[0-9]+$ ]] + expected_message=$(printf 'agent-os-candidate-reservation-v1\nsource-commit=%s\nowner-run-id=%s' \ + "$GITHUB_SHA" "$CLAIM_OWNER") + [ "$(jq -r .message "$RUNNER_TEMP/candidate-claim-commit.json")" = "$expected_message" ] + CLAIM_SHA=$claim_sha + } + load_build_attempt() { + owner_run_id=$1 + expected_claim_sha=$2 + expected_reservation_digest=$3 + attempt_ref="refs/agent-os/candidate-build-attempts/$GITHUB_SHA/$owner_run_id" + BUILD_ATTEMPT_STATE= + if gh api "repos/$GITHUB_REPOSITORY/git/ref/${attempt_ref#refs/}" \ + > "$RUNNER_TEMP/candidate-build-attempt-ref.json" \ + 2> "$RUNNER_TEMP/candidate-build-attempt-ref.err"; then + : + else + if grep -Eq '^gh: Not Found \(HTTP 404\)$' \ + "$RUNNER_TEMP/candidate-build-attempt-ref.err"; then + BUILD_ATTEMPT_STATE=absent + return 0 + fi + cat "$RUNNER_TEMP/candidate-build-attempt-ref.err" >&2 + echo "::error::candidate build attempt reference is unreadable" + exit 1 + fi + attempt_sha=$(jq -er .object.sha "$RUNNER_TEMP/candidate-build-attempt-ref.json") || { + echo "::error::candidate build attempt reference metadata is invalid" + exit 1 + } + gh api "repos/$GITHUB_REPOSITORY/git/commits/$attempt_sha" \ + > "$RUNNER_TEMP/candidate-build-attempt-commit.json" || { + echo "::error::candidate build attempt commit is unreadable" + exit 1 + } + jq -e --arg source_tree "$source_tree_sha" --arg claim "$expected_claim_sha" \ + '.tree.sha == $source_tree and (.parents | length == 1) and .parents[0].sha == $claim' \ + "$RUNNER_TEMP/candidate-build-attempt-commit.json" >/dev/null || { + echo "::error::candidate build attempt ownership metadata is invalid" + exit 1 + } + expected_attempt_message=$(printf 'agent-os-candidate-build-attempt-v1\nsource-commit=%s\nsource-tree=%s\nreservation-digest=%s\nowner-run-id=%s\nclaim-commit=%s' \ + "$GITHUB_SHA" "$source_tree_sha" "$expected_reservation_digest" "$owner_run_id" "$expected_claim_sha") + [ "$(jq -r .message "$RUNNER_TEMP/candidate-build-attempt-commit.json")" = "$expected_attempt_message" ] || { + echo "::error::candidate build attempt message is invalid" + exit 1 + } + BUILD_ATTEMPT_STATE=attempted + } + load_latest_claim() { + claim_ref="$claim_prefix/initial" + load_claim "$claim_ref" + CLAIM_OWNERS="|$CLAIM_OWNER|" + CLAIM_CHAIN_FILE="$RUNNER_TEMP/candidate-claim-chain.tsv" + printf '%s\t%s\n' "$CLAIM_OWNER" "$CLAIM_SHA" > "$CLAIM_CHAIN_FILE" + depth=0 + while [ "$depth" -lt 32 ]; do + next_ref="$claim_prefix/recover-$CLAIM_OWNER" + if gh api "repos/$GITHUB_REPOSITORY/git/ref/${next_ref#refs/}" \ + > "$RUNNER_TEMP/candidate-next-claim.json" \ + 2> "$RUNNER_TEMP/candidate-next-claim.err"; then + claim_ref=$next_ref + load_claim "$claim_ref" + case "$CLAIM_OWNERS" in + *"|$CLAIM_OWNER|"*) + echo "::error::candidate reservation recovery chain repeats an owner" + exit 1 + ;; + esac + CLAIM_OWNERS="${CLAIM_OWNERS}${CLAIM_OWNER}|" + printf '%s\t%s\n' "$CLAIM_OWNER" "$CLAIM_SHA" >> "$CLAIM_CHAIN_FILE" + depth=$((depth + 1)) + continue + fi + if grep -Eiq '404|Not Found' "$RUNNER_TEMP/candidate-next-claim.err"; then + return 0 + fi + cat "$RUNNER_TEMP/candidate-next-claim.err" >&2 + exit 1 + done + echo "::error::candidate reservation recovery chain is invalid" + exit 1 + } + ensure_build_owner() { + load_latest_claim + if [ "$CLAIM_OWNER" = "$GITHUB_RUN_ID" ]; then + return 0 + fi + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$CLAIM_OWNER" \ + > "$RUNNER_TEMP/candidate-owner-run.json" + jq -e --arg sha "$GITHUB_SHA" --arg repo "$GITHUB_REPOSITORY" \ + '.status == "completed" and .head_sha == $sha and .event == "push" and + .head_branch == "main" and .repository.full_name == $repo' \ + "$RUNNER_TEMP/candidate-owner-run.json" >/dev/null || { + echo "::error::candidate reservation is owned by a live or foreign workflow run" + exit 1 + } + recovery_ref="$claim_prefix/recover-$CLAIM_OWNER" + echo "::notice::recovering incomplete candidate reservation from run $CLAIM_OWNER" + create_claim "$recovery_ref" "$GITHUB_RUN_ID" || true + load_latest_claim + [ "$CLAIM_OWNER" = "$GITHUB_RUN_ID" ] || { + echo "::error::candidate reservation recovery was claimed by another workflow run" + exit 1 + } + } + validate_artifact_run() { + artifact_run_id=$1 + case "$CLAIM_OWNERS" in + *"|$artifact_run_id|"*) ;; + *) + echo "::error::candidate artifact run is outside the authorized claim chain" + exit 1 + ;; + esac + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$artifact_run_id" \ + > "$RUNNER_TEMP/candidate-artifact-run.json" + jq -e --argjson run "$artifact_run_id" --arg sha "$GITHUB_SHA" \ + --arg repo "$GITHUB_REPOSITORY" ' + .id == $run and .head_sha == $sha and .event == "push" and + .head_branch == "main" and .repository.full_name == $repo and + .path == ".github/workflows/agent-os-image.yml"' \ + "$RUNNER_TEMP/candidate-artifact-run.json" >/dev/null || { + echo "::error::candidate artifact run is not an authorized publication workflow" + exit 1 + } + } + load_exact_build_artifact() { + exact_build_owner=$1 + validate_artifact_run "$exact_build_owner" + build_artifact_id=$(jq -er --arg sha "$GITHUB_SHA" --argjson owner "$exact_build_owner" \ + '[.artifacts[] | select(.expired == false and .workflow_run.head_sha == $sha and .workflow_run.id == $owner)] | .[0].id' \ + "$RUNNER_TEMP/candidate-build-artifacts.json") + rm -rf "$RUNNER_TEMP/candidate-build-artifact" + mkdir "$RUNNER_TEMP/candidate-build-artifact" + gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$build_artifact_id/zip" \ + > "$RUNNER_TEMP/candidate-build-artifact.zip" + unzip -q "$RUNNER_TEMP/candidate-build-artifact.zip" -d "$RUNNER_TEMP/candidate-build-artifact" + jq -e --arg commit '${{ steps.candidate_source.outputs.commit }}' \ + --arg tree '${{ steps.candidate_source.outputs.tree }}' \ + --arg reservation "$reservation_digest" --arg repository "$IMAGE" \ + --argjson owner "$exact_build_owner" ' + .schema_version == 1 and .commit == $commit and .tree == $tree and + .reservation_digest == $reservation and .image_repository == $repository and + .owner_run_id == $owner and + (.image_digest | test("^sha256:[0-9a-f]{64}$")) and + (.oci_archive_sha256 | test("^[0-9a-f]{64}$"))' \ + "$RUNNER_TEMP/candidate-build-artifact/candidate-build.json" >/dev/null + EXACT_BUILD_IMAGE_DIGEST=$(jq -er .image_digest \ + "$RUNNER_TEMP/candidate-build-artifact/candidate-build.json") + EXACT_BUILD_ARCHIVE_SHA=$(jq -er .oci_archive_sha256 \ + "$RUNNER_TEMP/candidate-build-artifact/candidate-build.json") + [ "$(sha256sum "$RUNNER_TEMP/candidate-build-artifact/candidate-image.tar" | awk '{print $1}')" = \ + "$EXACT_BUILD_ARCHIVE_SHA" ] + cp "$RUNNER_TEMP/candidate-build-artifact/candidate-image.tar" \ + "$RUNNER_TEMP/candidate-image.tar" + } + create_claim "$claim_prefix/initial" "$GITHUB_RUN_ID" || true + load_latest_claim + printf '{}\n' > "$RUNNER_TEMP/candidate-reservation-config.json" + jq -cnS \ + --arg commit '${{ steps.candidate_source.outputs.commit }}' \ + --arg tree '${{ steps.candidate_source.outputs.tree }}' \ + --arg source '${{ steps.candidate_source.outputs.source_sha256 }}' \ + '{schema_version:1,commit:$commit,tree:$tree,source_archive_sha256:$source}' \ + > "$RUNNER_TEMP/candidate-reservation.json" + oras blob push --media-type application/vnd.oci.empty.v1+json --descriptor \ + "$IMAGE" "$RUNNER_TEMP/candidate-reservation-config.json" > "$RUNNER_TEMP/candidate-reservation-config-descriptor.json" + oras blob push --media-type application/vnd.akua.agent-os.candidate-reservation.v1+json --descriptor \ + "$IMAGE" "$RUNNER_TEMP/candidate-reservation.json" > "$RUNNER_TEMP/candidate-reservation-descriptor.json" + config_descriptor=$(jq -cS '{mediaType,digest,size}' "$RUNNER_TEMP/candidate-reservation-config-descriptor.json") + reservation_descriptor=$(jq -cS '{mediaType,digest,size,annotations:{"org.opencontainers.image.title":"candidate-reservation.json"}}' "$RUNNER_TEMP/candidate-reservation-descriptor.json") + jq -cnS --argjson config "$config_descriptor" --argjson reservation "$reservation_descriptor" \ + '{schemaVersion:2,mediaType:"application/vnd.oci.image.manifest.v1+json",artifactType:"application/vnd.akua.agent-os.candidate-reservation.v1",config:$config,layers:[$reservation]}' \ + > "$RUNNER_TEMP/candidate-reservation-manifest.json" + reservation_digest="sha256:$(sha256sum "$RUNNER_TEMP/candidate-reservation-manifest.json" | awk '{print $1}')" + reservation_size=$(wc -c < "$RUNNER_TEMP/candidate-reservation-manifest.json" | tr -d ' ') + if oras manifest fetch "$IMAGE@$reservation_digest" > "$RUNNER_TEMP/candidate-reservation-existing.json" 2> "$RUNNER_TEMP/candidate-reservation.err"; then + cmp "$RUNNER_TEMP/candidate-reservation-manifest.json" "$RUNNER_TEMP/candidate-reservation-existing.json" + elif grep -Eiq 'manifest unknown|not found|404' "$RUNNER_TEMP/candidate-reservation.err"; then + oras manifest push --descriptor "$IMAGE@$reservation_digest" \ + "$RUNNER_TEMP/candidate-reservation-manifest.json" > "$RUNNER_TEMP/candidate-reservation-push.json" + [ "$(jq -r .digest "$RUNNER_TEMP/candidate-reservation-push.json")" = "$reservation_digest" ] + else + cat "$RUNNER_TEMP/candidate-reservation.err" >&2 + exit 1 + fi + oras manifest fetch "$IMAGE@$reservation_digest" > "$RUNNER_TEMP/candidate-reservation-readback.json" + cmp "$RUNNER_TEMP/candidate-reservation-manifest.json" "$RUNNER_TEMP/candidate-reservation-readback.json" + printf 'reservation_digest=%s\nreservation_size=%s\n' "$reservation_digest" "$reservation_size" >> "$GITHUB_OUTPUT" + oras discover --distribution-spec v1.1-referrers-api --artifact-type application/vnd.akua.agent-os.release-candidate.v1 \ + --format json --depth 1 "$IMAGE@$reservation_digest" > "$RUNNER_TEMP/candidate-reservation-referrers.json" + referrer_count=$(jq -er '.referrers | length' "$RUNNER_TEMP/candidate-reservation-referrers.json") + if [ "$referrer_count" -eq 0 ]; then + artifact_name="agent-os-candidate-record-$GITHUB_SHA" + gh api --method GET "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$artifact_name&per_page=100" \ + > "$RUNNER_TEMP/candidate-trusted-artifacts.json" + artifact_name="agent-os-candidate-build-$GITHUB_SHA" + gh api --method GET "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$artifact_name&per_page=100" \ + > "$RUNNER_TEMP/candidate-build-artifacts.json" + chain_state="$RUNNER_TEMP/candidate-claim-chain-state.tsv" + : > "$chain_state" + while IFS=$'\t' read -r owner claim_sha; do + load_build_attempt "$owner" "$claim_sha" "$reservation_digest" + record_count=$(jq -er --arg sha "$GITHUB_SHA" --argjson owner "$owner" \ + '[.artifacts[] | select(.expired == false and .workflow_run.head_sha == $sha and .workflow_run.id == $owner)] | length' \ + "$RUNNER_TEMP/candidate-trusted-artifacts.json") + build_count=$(jq -er --arg sha "$GITHUB_SHA" --argjson owner "$owner" \ + '[.artifacts[] | select(.expired == false and .workflow_run.head_sha == $sha and .workflow_run.id == $owner)] | length' \ + "$RUNNER_TEMP/candidate-build-artifacts.json") + [ "$record_count" -le 1 ] && [ "$build_count" -le 1 ] || { + echo "::error::candidate claim owner has conflicting durable evidence" + exit 1 + } + printf '%s\t%s\t%s\t%s\t%s\n' "$owner" "$claim_sha" \ + "$BUILD_ATTEMPT_STATE" "$record_count" "$build_count" >> "$chain_state" + done < "$CLAIM_CHAIN_FILE" + decision=$(bin/agent-os-candidate-state.sh "$chain_state") + IFS=$'\t' read -r candidate_action artifact_owner paired_build_owner < "$RUNNER_TEMP/candidate-trusted-artifact.zip" + unzip -q "$RUNNER_TEMP/candidate-trusted-artifact.zip" -d "$RUNNER_TEMP/candidate-trusted-artifact" + candidate_record_digest="sha256:$(sha256sum "$RUNNER_TEMP/candidate-trusted-artifact/candidate-manifest.json" | awk '{print $1}')" + jq -e --arg reservation "$reservation_digest" ' + .schemaVersion == 2 and + .artifactType == "application/vnd.akua.agent-os.release-candidate.v1" and + .subject.digest == $reservation and + ([.layers[] | select(.mediaType == "application/vnd.akua.agent-os.release-candidate.v1+json")] | length == 1) and + ([.layers[] | select(.mediaType == "application/vnd.akua.agent-os.bootstrap.v1+tar" and .annotations["org.opencontainers.image.title"] == "agent-os-bootstrap.tar")] | length == 1)' \ + "$RUNNER_TEMP/candidate-trusted-artifact/candidate-manifest.json" >/dev/null + record_blob=$(jq -er '[.layers[] | select(.mediaType == "application/vnd.akua.agent-os.release-candidate.v1+json")] | .[0].digest' \ + "$RUNNER_TEMP/candidate-trusted-artifact/candidate-manifest.json") + bootstrap_blob=$(jq -er '[.layers[] | select(.mediaType == "application/vnd.akua.agent-os.bootstrap.v1+tar")] | .[0].digest' \ + "$RUNNER_TEMP/candidate-trusted-artifact/candidate-manifest.json") + [ "$record_blob" = "sha256:$(sha256sum "$RUNNER_TEMP/candidate-trusted-artifact/candidate-record.json" | awk '{print $1}')" ] + [ "$bootstrap_blob" = "sha256:$(sha256sum "$RUNNER_TEMP/candidate-trusted-artifact/agent-os-bootstrap.tar" | awk '{print $1}')" ] + jq -e \ + --arg commit '${{ steps.candidate_source.outputs.commit }}' \ + --arg tree '${{ steps.candidate_source.outputs.tree }}' \ + --arg source '${{ steps.candidate_source.outputs.source_sha256 }}' \ + --arg bootstrap '${{ steps.candidate_source.outputs.bootstrap_sha256 }}' \ + --arg repository "$IMAGE" ' + .schema_version == 1 and .source_mode == "candidate" and + .commit == $commit and .tree == $tree and + .source_archive_sha256 == $source and .bootstrap_archive_sha256 == $bootstrap and + .image_repository == $repository and + (.image_digest | test("^sha256:[0-9a-f]{64}$"))' \ + "$RUNNER_TEMP/candidate-trusted-artifact/candidate-record.json" >/dev/null + cp "$RUNNER_TEMP/candidate-trusted-artifact/agent-os-bootstrap.tar" image/agent-os-bootstrap.tar + image_digest=$(jq -er .image_digest "$RUNNER_TEMP/candidate-trusted-artifact/candidate-record.json") + bootstrap_sha=$(jq -er .bootstrap_archive_sha256 "$RUNNER_TEMP/candidate-trusted-artifact/candidate-record.json") + if [ "$candidate_action" = reuse-record-pair ]; then + [ "$build_artifact_count" -eq 1 ] || exit 1 + load_exact_build_artifact "$build_artifact_owner" + [ "$EXACT_BUILD_IMAGE_DIGEST" = "$image_digest" ] || { + echo "::error::paired candidate build and record evidence disagree" + exit 1 + } + else + [ "$build_artifact_count" -eq 0 ] || exit 1 + fi + ensure_build_owner + printf 'build=false\nrecord_artifact=true\ncandidate_record_digest=%s\nimage_digest=%s\nbootstrap_sha256=%s\n' \ + "$candidate_record_digest" "$image_digest" "$bootstrap_sha" >> "$GITHUB_OUTPUT" + echo "::notice::candidate reservation recovered from durable trusted artifact" + exit 0 + fi + if [ "$candidate_action" = reuse-build ]; then + [ "$build_artifact_count" -eq 1 ] || exit 1 + load_exact_build_artifact "$artifact_owner" + image_digest=$EXACT_BUILD_IMAGE_DIGEST + ensure_build_owner + printf 'build=false\npublish_image=true\nrecord_artifact=false\nimage_digest=%s\nbootstrap_sha256=%s\n' \ + "$image_digest" '${{ steps.candidate_source.outputs.bootstrap_sha256 }}' >> "$GITHUB_OUTPUT" + echo "::notice::candidate reservation recovered from exact build evidence" + exit 0 + fi + [ "$candidate_action" = build ] + ensure_build_owner + printf 'build=true\nrecord_artifact=false\nbootstrap_sha256=%s\nclaim_sha=%s\nclaim_owner=%s\n' \ + '${{ steps.candidate_source.outputs.bootstrap_sha256 }}' "$CLAIM_SHA" "$CLAIM_OWNER" >> "$GITHUB_OUTPUT" + exit 0 + fi + [ "$referrer_count" -eq 1 ] || { echo "::error::candidate reservation has conflicting records"; exit 1; } + candidate_record_digest=$(jq -er '.referrers[0].digest' "$RUNNER_TEMP/candidate-reservation-referrers.json") + oras manifest fetch "$IMAGE@$candidate_record_digest" > "$RUNNER_TEMP/candidate-recovered-manifest.json" + artifact_name="agent-os-candidate-record-$GITHUB_SHA" + gh api --method GET "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$artifact_name&per_page=100" \ + > "$RUNNER_TEMP/candidate-trusted-artifacts.json" + trusted_artifact_count=$(jq -er --arg sha "$GITHUB_SHA" \ + '[.artifacts[] | select(.expired == false and .workflow_run.head_sha == $sha)] | length' \ + "$RUNNER_TEMP/candidate-trusted-artifacts.json") + [ "$trusted_artifact_count" -eq 1 ] || { + echo "::error::partial candidate reservation requires trusted recovery" + exit 1 + } + trusted_artifact_id=$(jq -er --arg sha "$GITHUB_SHA" \ + '[.artifacts[] | select(.expired == false and .workflow_run.head_sha == $sha)] | .[0].id' \ + "$RUNNER_TEMP/candidate-trusted-artifacts.json") + artifact_run_id=$(jq -er --arg sha "$GITHUB_SHA" \ + '[.artifacts[] | select(.expired == false and .workflow_run.head_sha == $sha)] | .[0].workflow_run.id' \ + "$RUNNER_TEMP/candidate-trusted-artifacts.json") + validate_artifact_run "$artifact_run_id" + mkdir "$RUNNER_TEMP/candidate-trusted-artifact" + gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$trusted_artifact_id/zip" \ + > "$RUNNER_TEMP/candidate-trusted-artifact.zip" + unzip -q "$RUNNER_TEMP/candidate-trusted-artifact.zip" -d "$RUNNER_TEMP/candidate-trusted-artifact" + cmp "$RUNNER_TEMP/candidate-trusted-artifact/candidate-manifest.json" \ + "$RUNNER_TEMP/candidate-recovered-manifest.json" + record_blob=$(jq -er '[.layers[] | select(.mediaType == "application/vnd.akua.agent-os.release-candidate.v1+json")] | select(length == 1) | .[0].digest' "$RUNNER_TEMP/candidate-recovered-manifest.json") + bootstrap_blob=$(jq -er '[.layers[] | select(.mediaType == "application/vnd.akua.agent-os.bootstrap.v1+tar" and .annotations["org.opencontainers.image.title"] == "agent-os-bootstrap.tar")] | select(length == 1) | .[0].digest' "$RUNNER_TEMP/candidate-recovered-manifest.json") + oras blob fetch --output "$RUNNER_TEMP/candidate-recovered-record.json" "$IMAGE@$record_blob" + oras blob fetch --output "$RUNNER_TEMP/candidate-recovered-bootstrap.tar" "$IMAGE@$bootstrap_blob" + cmp "$RUNNER_TEMP/candidate-trusted-artifact/candidate-record.json" "$RUNNER_TEMP/candidate-recovered-record.json" + cmp "$RUNNER_TEMP/candidate-trusted-artifact/agent-os-bootstrap.tar" "$RUNNER_TEMP/candidate-recovered-bootstrap.tar" + cp "$RUNNER_TEMP/candidate-trusted-artifact/agent-os-bootstrap.tar" image/agent-os-bootstrap.tar + image_digest=$(jq -er '.image_digest | select(test("^sha256:[0-9a-f]{64}$"))' "$RUNNER_TEMP/candidate-trusted-artifact/candidate-record.json") + bootstrap_sha=$(jq -er '.bootstrap_archive_sha256 | select(test("^[0-9a-f]{64}$"))' "$RUNNER_TEMP/candidate-trusted-artifact/candidate-record.json") + [ "$(sha256sum image/agent-os-bootstrap.tar | awk '{print $1}')" = "$bootstrap_sha" ] + printf 'build=false\nrecord_artifact=true\ncandidate_record_digest=%s\nimage_digest=%s\nbootstrap_sha256=%s\n' \ + "$candidate_record_digest" "$image_digest" "$bootstrap_sha" >> "$GITHUB_OUTPUT" + + - name: Reject legacy candidate coordinates + if: github.ref == 'refs/heads/main' + run: | + set -eu + legacy_ref="$IMAGE:release-candidate-$GITHUB_SHA" + if oras manifest fetch --descriptor "$legacy_ref" > "$RUNNER_TEMP/legacy-candidate.json" 2> "$RUNNER_TEMP/legacy-candidate.err"; then + echo "::error::pre-existing release candidate lacks an independently trusted record" + exit 1 + elif grep -Eiq 'manifest unknown|not found|404' "$RUNNER_TEMP/legacy-candidate.err"; then + : + else + cat "$RUNNER_TEMP/legacy-candidate.err" >&2 + exit 1 + fi + + - name: Claim owner-bound candidate build attempt + if: github.ref == 'refs/heads/main' && steps.candidate_reservation.outputs.build == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + claim_sha='${{ steps.candidate_reservation.outputs.claim_sha }}' + claim_owner='${{ steps.candidate_reservation.outputs.claim_owner }}' + reservation_digest='${{ steps.candidate_reservation.outputs.reservation_digest }}' + [[ "$claim_sha" =~ ^[0-9a-f]{40}$ ]] + [ "$claim_owner" = "$GITHUB_RUN_ID" ] + [[ "$reservation_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + source_tree_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$GITHUB_SHA" --jq .tree.sha) + [ "$source_tree_sha" = '${{ steps.candidate_source.outputs.tree }}' ] + attempt_ref="refs/agent-os/candidate-build-attempts/$GITHUB_SHA/$GITHUB_RUN_ID" + attempt_message=$(printf 'agent-os-candidate-build-attempt-v1\nsource-commit=%s\nsource-tree=%s\nreservation-digest=%s\nowner-run-id=%s\nclaim-commit=%s' \ + "$GITHUB_SHA" "$source_tree_sha" "$reservation_digest" "$GITHUB_RUN_ID" "$claim_sha") + attempt_commit=$(gh api --method POST "repos/$GITHUB_REPOSITORY/git/commits" \ + --raw-field message="$attempt_message" --raw-field tree="$source_tree_sha" \ + --raw-field "parents[]=$claim_sha" --jq .sha) + if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + --raw-field ref="$attempt_ref" --raw-field sha="$attempt_commit" \ + > "$RUNNER_TEMP/candidate-build-attempt-create.json" \ + 2> "$RUNNER_TEMP/candidate-build-attempt-create.err"; then + cat "$RUNNER_TEMP/candidate-build-attempt-create.err" >&2 + echo "::error::candidate build attempt already exists or could not be reserved; refusing rebuild" + exit 1 + fi + readback_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/${attempt_ref#refs/}" --jq .object.sha) + [ "$readback_sha" = "$attempt_commit" ] + gh api "repos/$GITHUB_REPOSITORY/git/commits/$readback_sha" \ + > "$RUNNER_TEMP/candidate-build-attempt-readback.json" + jq -e --arg tree "$source_tree_sha" --arg claim "$claim_sha" --arg message "$attempt_message" \ + '.tree.sha == $tree and (.parents | length == 1) and .parents[0].sha == $claim and .message == $message' \ + "$RUNNER_TEMP/candidate-build-attempt-readback.json" >/dev/null + + - name: Build release candidate once + if: github.ref == 'refs/heads/main' && steps.candidate_reservation.outputs.build == 'true' + id: candidate_build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: . + platforms: linux/amd64,linux/arm64 + outputs: type=oci,dest=${{ runner.temp }}/candidate-image.tar,oci-mediatypes=true + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true + build-args: | + AGENT_OS_SOURCE_COMMIT=${{ steps.candidate_source.outputs.commit }} + AGENT_OS_SOURCE_TREE=${{ steps.candidate_source.outputs.tree }} + AGENT_OS_SOURCE_BRANCH=${{ steps.candidate_source.outputs.branch }} + AGENT_OS_SOURCE_ORIGIN=${{ steps.candidate_source.outputs.origin }} + AGENT_OS_SOURCE_MODE=${{ steps.candidate_source.outputs.mode }} + AGENT_OS_SOURCE_REF=${{ steps.candidate_source.outputs.ref }} + + - name: Prepare exact candidate build evidence + if: github.ref == 'refs/heads/main' && steps.candidate_reservation.outputs.build == 'true' + run: | + set -eu + image_digest='${{ steps.candidate_build.outputs.digest }}' + [[ "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + archive_sha=$(sha256sum "$RUNNER_TEMP/candidate-image.tar" | awk '{print $1}') + jq -cnS \ + --arg commit '${{ steps.candidate_source.outputs.commit }}' \ + --arg tree '${{ steps.candidate_source.outputs.tree }}' \ + --arg reservation '${{ steps.candidate_reservation.outputs.reservation_digest }}' \ + --arg repository "$IMAGE" --arg image "$image_digest" \ + --arg archive "$archive_sha" \ + --argjson owner "$GITHUB_RUN_ID" \ + '{schema_version:1,commit:$commit,tree:$tree,reservation_digest:$reservation,image_repository:$repository,image_digest:$image,oci_archive_sha256:$archive,owner_run_id:$owner}' \ + > "$RUNNER_TEMP/candidate-build.json" + + - name: Stage exact candidate build evidence + if: github.ref == 'refs/heads/main' && steps.candidate_reservation.outputs.build == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: agent-os-candidate-build-${{ github.sha }} + path: | + ${{ runner.temp }}/candidate-build.json + ${{ runner.temp }}/candidate-image.tar + if-no-files-found: error + retention-days: 90 + + - name: Publish exact evidenced candidate image + if: github.ref == 'refs/heads/main' && (steps.candidate_reservation.outputs.build == 'true' || steps.candidate_reservation.outputs.publish_image == 'true') + env: + CANDIDATE_BUILD_DIGEST: ${{ steps.candidate_build.outputs.digest || steps.candidate_reservation.outputs.image_digest }} + run: | + set -eu + image_digest=$CANDIDATE_BUILD_DIGEST + [[ "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + archive_sha=$(jq -er .oci_archive_sha256 "$RUNNER_TEMP/candidate-build.json" 2>/dev/null || \ + jq -er .oci_archive_sha256 "$RUNNER_TEMP/candidate-build-artifact/candidate-build.json") + [ "$(sha256sum "$RUNNER_TEMP/candidate-image.tar" | awk '{print $1}')" = "$archive_sha" ] + oras cp --from-oci-layout "$RUNNER_TEMP/candidate-image.tar@$image_digest" "$IMAGE" + oras manifest fetch "$IMAGE@$image_digest" > "$RUNNER_TEMP/candidate-image-readback.json" + [ "sha256:$(sha256sum "$RUNNER_TEMP/candidate-image-readback.json" | awk '{print $1}')" = "$image_digest" ] + + - name: Prepare immutable release candidate record + if: github.ref == 'refs/heads/main' + id: candidate_record + env: + CANDIDATE_BUILD_DIGEST: ${{ steps.candidate_build.outputs.digest || steps.candidate_reservation.outputs.image_digest }} + run: | + set -eu + verify_candidate_attestations() { + printf '%s' "$platform_manifests" | jq -r '.[].digest' | LC_ALL=C sort > "$RUNNER_TEMP/candidate-platform-digests" + : > "$RUNNER_TEMP/candidate-platform-subjects" + : > "$RUNNER_TEMP/candidate-sbom-digests" + : > "$RUNNER_TEMP/candidate-provenance-digests" + jq -r '.manifests[] | select(.annotations["vnd.docker.reference.type"] == "attestation-manifest") | .digest' \ + "$RUNNER_TEMP/candidate-index.json" > "$RUNNER_TEMP/candidate-attestation-manifests" + [ "$(wc -l < "$RUNNER_TEMP/candidate-attestation-manifests" | tr -d ' ')" -eq 2 ] + while IFS= read -r manifest_digest; do + manifest_file="$RUNNER_TEMP/candidate-attestation-${manifest_digest#sha256:}.json" + sbom_file="$RUNNER_TEMP/candidate-sbom-${manifest_digest#sha256:}.json" + provenance_file="$RUNNER_TEMP/candidate-provenance-${manifest_digest#sha256:}.json" + docker buildx imagetools inspect --raw "$IMAGE@$manifest_digest" > "$manifest_file" + subject_digest=$(jq -er --arg manifest "$manifest_digest" ' + .manifests[] | select(.digest == $manifest) + | .annotations["vnd.docker.reference.digest"] | select(test("^sha256:[0-9a-f]{64}$"))' \ + "$RUNNER_TEMP/candidate-index.json") + printf '%s\n' "$subject_digest" >> "$RUNNER_TEMP/candidate-platform-subjects" + sbom_digest=$(jq -er '[.layers[] | select(.annotations["in-toto.io/predicate-type"] == "https://spdx.dev/Document")] | select(length == 1) | .[0].digest' "$manifest_file") + provenance_digest=$(jq -er '[.layers[] | select(.annotations["in-toto.io/predicate-type"] == "https://slsa.dev/provenance/v0.2")] | select(length == 1) | .[0].digest' "$manifest_file") + oras blob fetch --output "$sbom_file" "$IMAGE@$sbom_digest" + oras blob fetch --output "$provenance_file" "$IMAGE@$provenance_digest" + jq -e --arg subject "${subject_digest#sha256:}" ' + .predicateType == "https://spdx.dev/Document" and + (.subject | length == 1) and .subject[0].digest.sha256 == $subject' \ + "$sbom_file" >/dev/null || { + echo "::error::release candidate SBOM does not bind its exact platform manifest" + exit 1 + } + jq -e --arg subject "${subject_digest#sha256:}" \ + --arg commit '${{ steps.candidate_source.outputs.commit }}' \ + --arg tree '${{ steps.candidate_source.outputs.tree }}' \ + --arg branch '${{ steps.candidate_source.outputs.branch }}' \ + --arg origin '${{ steps.candidate_source.outputs.origin }}' \ + --arg mode '${{ steps.candidate_source.outputs.mode }}' \ + --arg ref '${{ steps.candidate_source.outputs.ref }}' ' + .predicateType == "https://slsa.dev/provenance/v0.2" and + (.subject | length == 1) and .subject[0].digest.sha256 == $subject and + .predicate.buildType == "https://mobyproject.org/buildkit@v1" and + .predicate.invocation.configSource.entryPoint == "Dockerfile" and + .predicate.metadata.completeness.parameters == true and + .predicate.invocation.parameters.args["build-arg:AGENT_OS_SOURCE_COMMIT"] == $commit and + .predicate.invocation.parameters.args["build-arg:AGENT_OS_SOURCE_TREE"] == $tree and + .predicate.invocation.parameters.args["build-arg:AGENT_OS_SOURCE_BRANCH"] == $branch and + .predicate.invocation.parameters.args["build-arg:AGENT_OS_SOURCE_ORIGIN"] == $origin and + .predicate.invocation.parameters.args["build-arg:AGENT_OS_SOURCE_MODE"] == $mode and + .predicate.invocation.parameters.args["build-arg:AGENT_OS_SOURCE_REF"] == $ref' \ + "$provenance_file" >/dev/null || { + echo "::error::release candidate image provenance conflicts with the exact protected-main source" + exit 1 + } + printf '%s\n' "$sbom_digest" >> "$RUNNER_TEMP/candidate-sbom-digests" + printf '%s\n' "$provenance_digest" >> "$RUNNER_TEMP/candidate-provenance-digests" + done < "$RUNNER_TEMP/candidate-attestation-manifests" + LC_ALL=C sort -o "$RUNNER_TEMP/candidate-platform-subjects" "$RUNNER_TEMP/candidate-platform-subjects" + cmp "$RUNNER_TEMP/candidate-platform-digests" "$RUNNER_TEMP/candidate-platform-subjects" || { + echo "::error::release candidate attestations do not map one-to-one onto both platforms" + exit 1 + } + LC_ALL=C sort -o "$RUNNER_TEMP/candidate-sbom-digests" "$RUNNER_TEMP/candidate-sbom-digests" + LC_ALL=C sort -o "$RUNNER_TEMP/candidate-provenance-digests" "$RUNNER_TEMP/candidate-provenance-digests" + } + actual_image_digest=$CANDIDATE_BUILD_DIGEST + [[ "$actual_image_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + docker buildx imagetools inspect --raw "$IMAGE@$actual_image_digest" > "$RUNNER_TEMP/candidate-index.json" + [ "sha256:$(sha256sum "$RUNNER_TEMP/candidate-index.json" | awk '{print $1}')" = "$actual_image_digest" ] + platform_manifests=$(jq -cS '[.manifests[] | select(.platform.os == "linux" and (.platform.architecture == "amd64" or .platform.architecture == "arm64")) | {platform:(.platform.os + "/" + .platform.architecture),digest,mediaType,size}] | sort_by(.platform)' "$RUNNER_TEMP/candidate-index.json") + printf '%s' "$platform_manifests" | jq -e '[.[].platform] == ["linux/amd64","linux/arm64"]' >/dev/null + verify_candidate_attestations + platform_manifests_sha256=$(printf '%s' "$platform_manifests" | sha256sum | awk '{print $1}') + buildkit_outputs=$(jq -cS '[.manifests[] | {digest,mediaType,size,platform,annotations}] | sort_by(.digest)' "$RUNNER_TEMP/candidate-index.json") + buildkit_outputs_sha256=$(printf '%s' "$buildkit_outputs" | sha256sum | awk '{print $1}') + actual_sbom_sha256=$(sha256sum "$RUNNER_TEMP/candidate-sbom-digests" | awk '{print $1}') + actual_provenance_sha256=$(sha256sum "$RUNNER_TEMP/candidate-provenance-digests" | awk '{print $1}') + jq -cnS \ + --arg commit '${{ steps.candidate_source.outputs.commit }}' \ + --arg tree '${{ steps.candidate_source.outputs.tree }}' \ + --arg sourceArchive '${{ steps.candidate_source.outputs.source_sha256 }}' \ + --arg bootstrapArchive '${{ steps.candidate_reservation.outputs.bootstrap_sha256 }}' \ + --arg package "$(sha256sum tools/agent-os/packages/firstmate/package.k | awk '{print $1}')" \ + --arg schema "$(sha256sum tools/agent-os/packages/firstmate/inputs.example.yaml | awk '{print $1}')" \ + --arg quickstart "$(sha256sum docs/kubernetes.md | awk '{print $1}')" \ + --arg repository "$IMAGE" --arg image "$actual_image_digest" \ + --argjson platforms "$platform_manifests" --arg platformsSha "$platform_manifests_sha256" \ + --arg sbom "$actual_sbom_sha256" --arg provenance "$actual_provenance_sha256" \ + --arg buildkitOutputs "$buildkit_outputs_sha256" \ + '{schema_version:1,source_mode:"candidate",commit:$commit,tree:$tree,source_archive_sha256:$sourceArchive,bootstrap_archive_sha256:$bootstrapArchive,package_sha256:$package,schema_sha256:$schema,quickstart_sha256:$quickstart,image_repository:$repository,image_digest:$image,platform_manifests:$platforms,platform_manifests_sha256:$platformsSha,sbom_sha256:$sbom,provenance_sha256:$provenance,buildkit_outputs_sha256:$buildkitOutputs}' \ + > "$RUNNER_TEMP/candidate-record.json" + jq -e \ + --arg commit '${{ steps.candidate_source.outputs.commit }}' --arg tree '${{ steps.candidate_source.outputs.tree }}' \ + --arg sourceArchive '${{ steps.candidate_source.outputs.source_sha256 }}' \ + --arg bootstrapArchive '${{ steps.candidate_reservation.outputs.bootstrap_sha256 }}' \ + --arg package "$(sha256sum tools/agent-os/packages/firstmate/package.k | awk '{print $1}')" \ + --arg schema "$(sha256sum tools/agent-os/packages/firstmate/inputs.example.yaml | awk '{print $1}')" \ + --arg quickstart "$(sha256sum docs/kubernetes.md | awk '{print $1}')" \ + --arg repository "$IMAGE" --arg image "$actual_image_digest" \ + --argjson platforms "$platform_manifests" --arg platformsSha "$platform_manifests_sha256" \ + --arg sbom "$actual_sbom_sha256" --arg provenance "$actual_provenance_sha256" \ + --arg buildkitOutputs "$buildkit_outputs_sha256" \ + '.schema_version == 1 and .source_mode == "candidate" and .commit == $commit and .tree == $tree and .source_archive_sha256 == $sourceArchive and .bootstrap_archive_sha256 == $bootstrapArchive and .package_sha256 == $package and .schema_sha256 == $schema and .quickstart_sha256 == $quickstart and .image_repository == $repository and .image_digest == $image and .platform_manifests == $platforms and .platform_manifests_sha256 == $platformsSha and .sbom_sha256 == $sbom and .provenance_sha256 == $provenance and .buildkit_outputs_sha256 == $buildkitOutputs' \ + "$RUNNER_TEMP/candidate-record.json" >/dev/null || { + echo "::error::candidate record coordinate already contains different artifacts" + exit 1 + } + printf '{}\n' > "$RUNNER_TEMP/candidate-config.json" + oras blob push --media-type application/vnd.oci.empty.v1+json --descriptor \ + "$IMAGE" "$RUNNER_TEMP/candidate-config.json" > "$RUNNER_TEMP/candidate-config-descriptor.json" + oras blob push --media-type application/vnd.akua.agent-os.release-candidate.v1+json --descriptor \ + "$IMAGE" "$RUNNER_TEMP/candidate-record.json" > "$RUNNER_TEMP/candidate-record-descriptor.json" + oras blob push --media-type application/vnd.akua.agent-os.bootstrap.v1+tar --descriptor \ + "$IMAGE" image/agent-os-bootstrap.tar > "$RUNNER_TEMP/candidate-bootstrap-descriptor.json" + [ "$(jq -r .digest "$RUNNER_TEMP/candidate-bootstrap-descriptor.json")" = \ + "sha256:${{ steps.candidate_reservation.outputs.bootstrap_sha256 }}" ] + config_descriptor=$(jq -cS '{mediaType,digest,size}' "$RUNNER_TEMP/candidate-config-descriptor.json") + record_descriptor=$(jq -cS '{mediaType,digest,size,annotations:{"org.opencontainers.image.title":"candidate-record.json"}}' "$RUNNER_TEMP/candidate-record-descriptor.json") + bootstrap_descriptor=$(jq -cS '{mediaType,digest,size,annotations:{"org.opencontainers.image.title":"agent-os-bootstrap.tar"}}' "$RUNNER_TEMP/candidate-bootstrap-descriptor.json") + reservation_subject=$(jq -cnS \ + --arg digest '${{ steps.candidate_reservation.outputs.reservation_digest }}' \ + --argjson size '${{ steps.candidate_reservation.outputs.reservation_size }}' \ + '{mediaType:"application/vnd.oci.image.manifest.v1+json",digest:$digest,size:$size}') + jq -cnS --argjson config "$config_descriptor" --argjson record "$record_descriptor" \ + --argjson bootstrap "$bootstrap_descriptor" --argjson subject "$reservation_subject" \ + '{schemaVersion:2,mediaType:"application/vnd.oci.image.manifest.v1+json",artifactType:"application/vnd.akua.agent-os.release-candidate.v1",subject:$subject,config:$config,layers:[$record,$bootstrap]}' \ + > "$RUNNER_TEMP/candidate-manifest.json" + candidate_record_digest="sha256:$(sha256sum "$RUNNER_TEMP/candidate-manifest.json" | awk '{print $1}')" + if [ -d "$RUNNER_TEMP/candidate-trusted-artifact" ]; then + cmp "$RUNNER_TEMP/candidate-record.json" "$RUNNER_TEMP/candidate-trusted-artifact/candidate-record.json" + cmp "$RUNNER_TEMP/candidate-manifest.json" "$RUNNER_TEMP/candidate-trusted-artifact/candidate-manifest.json" + cmp image/agent-os-bootstrap.tar "$RUNNER_TEMP/candidate-trusted-artifact/agent-os-bootstrap.tar" + fi + cp image/agent-os-bootstrap.tar "$RUNNER_TEMP/agent-os-bootstrap.tar" + printf 'candidate_record_digest=%s\nimage_digest=%s\n' "$candidate_record_digest" "$actual_image_digest" >> "$GITHUB_OUTPUT" + + - name: Stage trusted candidate record evidence + if: github.ref == 'refs/heads/main' && steps.candidate_reservation.outputs.record_artifact != 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: agent-os-candidate-record-${{ github.sha }} + path: | + ${{ runner.temp }}/candidate-record.json + ${{ runner.temp }}/candidate-manifest.json + ${{ runner.temp }}/agent-os-bootstrap.tar + if-no-files-found: error + retention-days: 90 + + - name: Publish immutable release candidate record + if: github.ref == 'refs/heads/main' + run: | + set -eu + candidate_record_digest='${{ steps.candidate_record.outputs.candidate_record_digest }}' + if oras manifest fetch "$IMAGE@$candidate_record_digest" > "$RUNNER_TEMP/candidate-existing-manifest.json" 2> "$RUNNER_TEMP/candidate-existing-manifest.err"; then + cmp "$RUNNER_TEMP/candidate-manifest.json" "$RUNNER_TEMP/candidate-existing-manifest.json" || { + echo "::error::candidate record coordinate already contains different artifacts" + exit 1 + } + elif grep -Eiq 'manifest unknown|not found|404' "$RUNNER_TEMP/candidate-existing-manifest.err"; then + oras manifest push --descriptor "$IMAGE@$candidate_record_digest" \ + "$RUNNER_TEMP/candidate-manifest.json" > "$RUNNER_TEMP/candidate-push.json" + [ "$(jq -r .digest "$RUNNER_TEMP/candidate-push.json")" = "$candidate_record_digest" ] + else + cat "$RUNNER_TEMP/candidate-existing-manifest.err" >&2 + exit 1 + fi + oras manifest fetch "$IMAGE@$candidate_record_digest" > "$RUNNER_TEMP/candidate-readback-manifest.json" + cmp "$RUNNER_TEMP/candidate-manifest.json" "$RUNNER_TEMP/candidate-readback-manifest.json" + record_blob_digest=$(jq -er '[.layers[] | select(.mediaType == "application/vnd.akua.agent-os.release-candidate.v1+json")] | select(length == 1) | .[0].digest' "$RUNNER_TEMP/candidate-readback-manifest.json") + oras blob fetch --output "$RUNNER_TEMP/candidate-record-readback.json" "$IMAGE@$record_blob_digest" + cmp "$RUNNER_TEMP/candidate-record.json" "$RUNNER_TEMP/candidate-record-readback.json" + oras discover --distribution-spec v1.1-referrers-api --artifact-type application/vnd.akua.agent-os.release-candidate.v1 \ + --format json --depth 1 "$IMAGE@${{ steps.candidate_reservation.outputs.reservation_digest }}" > "$RUNNER_TEMP/candidate-final-referrers.json" + jq -e --arg digest "$candidate_record_digest" '.referrers | length == 1 and .[0].digest == $digest' \ + "$RUNNER_TEMP/candidate-final-referrers.json" >/dev/null || { + echo "::error::candidate reservation does not resolve to exactly one verified record" + exit 1 + } + + - name: Prepare protected-main source + if: github.ref == 'refs/heads/main' + id: main_source + run: | + set -eu + bin/agent-os-source-bundle.sh > "$RUNNER_TEMP/main-source-metadata" + cat "$RUNNER_TEMP/main-source-metadata" >> "$GITHUB_OUTPUT" + + - name: Compute main image tags + if: github.ref == 'refs/heads/main' + id: metadata + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 + with: + images: ${{ env.IMAGE }} + tags: | + type=sha,format=long + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and publish + if: github.ref == 'refs/heads/main' + id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true + build-args: | + AGENT_OS_SOURCE_COMMIT=${{ steps.main_source.outputs.commit }} + AGENT_OS_SOURCE_TREE=${{ steps.main_source.outputs.tree }} + AGENT_OS_SOURCE_BRANCH=${{ steps.main_source.outputs.branch }} + AGENT_OS_SOURCE_ORIGIN=${{ steps.main_source.outputs.origin }} + AGENT_OS_SOURCE_MODE=${{ steps.main_source.outputs.mode }} + AGENT_OS_SOURCE_REF=${{ steps.main_source.outputs.ref }} + + - name: Promote exact verified release candidate + if: startsWith(github.ref, 'refs/tags/') + run: | + set -eu + release_tag=${GITHUB_REF#refs/tags/} + candidate_record_digest=$(jq -er '.candidate_record_digest' "$RUNNER_TEMP/release-record.json") + oras manifest fetch "$IMAGE@$candidate_record_digest" > "$RUNNER_TEMP/release-candidate-manifest.json" + [ "sha256:$(sha256sum "$RUNNER_TEMP/release-candidate-manifest.json" | awk '{print $1}')" = "$candidate_record_digest" ] + candidate_record_blob=$(jq -er ' + select(.schemaVersion == 2 and .artifactType == "application/vnd.akua.agent-os.release-candidate.v1") + | [.layers[] | select(.mediaType == "application/vnd.akua.agent-os.release-candidate.v1+json")] + | select(length == 1) | .[0].digest' \ + "$RUNNER_TEMP/release-candidate-manifest.json") + oras blob fetch --output "$RUNNER_TEMP/release-candidate-record.json" "$IMAGE@$candidate_record_blob" + jq -cS '{schema_version,source_mode,commit,tree,source_archive_sha256,bootstrap_archive_sha256,package_sha256,schema_sha256,quickstart_sha256,image_repository,image_digest,platform_manifests,platform_manifests_sha256,sbom_sha256,provenance_sha256,buildkit_outputs_sha256}' \ + "$RUNNER_TEMP/release-record.json" > "$RUNNER_TEMP/expected-candidate-record.json" + jq -cS . "$RUNNER_TEMP/release-candidate-record.json" > "$RUNNER_TEMP/actual-candidate-record.json" + cmp "$RUNNER_TEMP/expected-candidate-record.json" "$RUNNER_TEMP/actual-candidate-record.json" + RELEASE_DIGEST=$(jq -er '.image_digest' "$RUNNER_TEMP/release-record.json") + docker buildx imagetools inspect --raw "$IMAGE@$RELEASE_DIGEST" > "$RUNNER_TEMP/published-index.json" + [ "sha256:$(sha256sum "$RUNNER_TEMP/published-index.json" | awk '{print $1}')" = "$RELEASE_DIGEST" ] + actual_platforms=$(jq -cS '[.manifests[] | select(.platform.os == "linux" and (.platform.architecture == "amd64" or .platform.architecture == "arm64")) | {platform:(.platform.os + "/" + .platform.architecture),digest,mediaType,size}] | sort_by(.platform)' "$RUNNER_TEMP/published-index.json") + [ "$actual_platforms" = "$(jq -cS '.platform_manifests' "$RUNNER_TEMP/release-record.json")" ] + [ "$(printf '%s' "$actual_platforms" | sha256sum | awk '{print $1}')" = "$(jq -r .platform_manifests_sha256 "$RUNNER_TEMP/release-record.json")" ] + actual_buildkit_outputs=$(jq -cS '[.manifests[] | {digest,mediaType,size,platform,annotations}] | sort_by(.digest)' "$RUNNER_TEMP/published-index.json") + [ "$(printf '%s' "$actual_buildkit_outputs" | sha256sum | awk '{print $1}')" = "$(jq -r .buildkit_outputs_sha256 "$RUNNER_TEMP/release-record.json")" ] + verify_published_attestations() { + printf '%s' "$actual_platforms" | jq -r '.[].digest' | LC_ALL=C sort > "$RUNNER_TEMP/published-platform-digests" + : > "$RUNNER_TEMP/published-platform-subjects" + : > "$RUNNER_TEMP/published-sbom-digests" + : > "$RUNNER_TEMP/published-provenance-digests" + jq -r '.manifests[] | select(.annotations["vnd.docker.reference.type"] == "attestation-manifest") | .digest' \ + "$RUNNER_TEMP/published-index.json" > "$RUNNER_TEMP/published-attestation-manifests" + [ "$(wc -l < "$RUNNER_TEMP/published-attestation-manifests" | tr -d ' ')" -eq 2 ] + while IFS= read -r manifest_digest; do + manifest_file="$RUNNER_TEMP/published-attestation-${manifest_digest#sha256:}.json" + sbom_file="$RUNNER_TEMP/published-sbom-${manifest_digest#sha256:}.json" + provenance_file="$RUNNER_TEMP/published-provenance-${manifest_digest#sha256:}.json" + docker buildx imagetools inspect --raw "$IMAGE@$manifest_digest" > "$manifest_file" + subject_digest=$(jq -er --arg manifest "$manifest_digest" ' + .manifests[] | select(.digest == $manifest) + | .annotations["vnd.docker.reference.digest"] | select(test("^sha256:[0-9a-f]{64}$"))' \ + "$RUNNER_TEMP/published-index.json") + printf '%s\n' "$subject_digest" >> "$RUNNER_TEMP/published-platform-subjects" + sbom_digest=$(jq -er '[.layers[] | select(.annotations["in-toto.io/predicate-type"] == "https://spdx.dev/Document")] | select(length == 1) | .[0].digest' "$manifest_file") + provenance_digest=$(jq -er '[.layers[] | select(.annotations["in-toto.io/predicate-type"] == "https://slsa.dev/provenance/v0.2")] | select(length == 1) | .[0].digest' "$manifest_file") + oras blob fetch --output "$sbom_file" "$IMAGE@$sbom_digest" + oras blob fetch --output "$provenance_file" "$IMAGE@$provenance_digest" + jq -e --arg subject "${subject_digest#sha256:}" ' + .predicateType == "https://spdx.dev/Document" and + (.subject | length == 1) and .subject[0].digest.sha256 == $subject' "$sbom_file" >/dev/null + jq -e --arg subject "${subject_digest#sha256:}" ' + .predicateType == "https://slsa.dev/provenance/v0.2" and + (.subject | length == 1) and .subject[0].digest.sha256 == $subject' "$provenance_file" >/dev/null + printf '%s\n' "$sbom_digest" >> "$RUNNER_TEMP/published-sbom-digests" + printf '%s\n' "$provenance_digest" >> "$RUNNER_TEMP/published-provenance-digests" + done < "$RUNNER_TEMP/published-attestation-manifests" + LC_ALL=C sort -o "$RUNNER_TEMP/published-platform-subjects" "$RUNNER_TEMP/published-platform-subjects" + cmp "$RUNNER_TEMP/published-platform-digests" "$RUNNER_TEMP/published-platform-subjects" + LC_ALL=C sort -o "$RUNNER_TEMP/published-sbom-digests" "$RUNNER_TEMP/published-sbom-digests" + LC_ALL=C sort -o "$RUNNER_TEMP/published-provenance-digests" "$RUNNER_TEMP/published-provenance-digests" + } + verify_published_attestations + actual_sbom_sha256=$(sha256sum "$RUNNER_TEMP/published-sbom-digests" | awk '{print $1}') + actual_provenance_sha256=$(sha256sum "$RUNNER_TEMP/published-provenance-digests" | awk '{print $1}') + [ "$actual_sbom_sha256" = "$(jq -r .sbom_sha256 "$RUNNER_TEMP/release-record.json")" ] + [ "$actual_provenance_sha256" = "$(jq -r .provenance_sha256 "$RUNNER_TEMP/release-record.json")" ] + if oras manifest fetch --descriptor "$IMAGE:$release_tag" > "$RUNNER_TEMP/release-tag-descriptor.json" 2> "$RUNNER_TEMP/release-tag.err"; then + echo "::error::release registry tag coordinate already exists" + exit 1 + elif grep -Eiq 'manifest unknown|not found|404' "$RUNNER_TEMP/release-tag.err"; then + : + else + cat "$RUNNER_TEMP/release-tag.err" >&2 + exit 1 + fi + { + echo '### Agent OS release' + echo + echo "Canonical install digest: \`$IMAGE@$RELEASE_DIGEST\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Record protected-main publication + if: github.ref == 'refs/heads/main' + env: + BUILD_DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -eu + { + echo '### Agent OS image' + echo + echo "Digest: \`$BUILD_DIGEST\`" + echo "Release candidate: \`${{ steps.candidate_record.outputs.image_digest || steps.candidate_reservation.outputs.image_digest }}\`" + echo "Candidate record: \`${{ steps.candidate_record.outputs.candidate_record_digest || steps.candidate_reservation.outputs.candidate_record_digest }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a75d82d3b..bbdd17f6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_call: push: branches: [main] pull_request: @@ -14,7 +15,7 @@ jobs: name: Lint shell scripts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 - name: Install pinned ShellCheck run: | set -eu @@ -31,7 +32,7 @@ jobs: # hung watcher or tmux test instead of riding GitHub's 360-minute default. timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: fetch-depth: 0 - name: Install pinned ShellCheck @@ -52,6 +53,41 @@ jobs: set -eu npm install -g tasks-axi tasks-axi --version + - name: Install Akua renderer + run: | + set -eu + version=0.8.25 + case "$(uname -m)" in + x86_64) + triple=x86_64-unknown-linux-gnu + sha=bc57afbffe7e18aacd2146e2cd67151c56e7a3c279fe659312ff7ffb359cd03a + ;; + aarch64) + triple=aarch64-unknown-linux-gnu + sha=3a3c6bae72764cbd85a6e4e0877a05e5def8f7aeee8563b7918099214a1a313a + ;; + *) + echo "::error::unsupported Akua runner architecture: $(uname -m)" + exit 1 + ;; + esac + asset="akua-v${version}-${triple}.tar.gz" + mkdir -p "$RUNNER_TEMP/bin" + curl -fsSL "https://github.com/akua-dev/akua/releases/download/v${version}/${asset}" -o "$RUNNER_TEMP/$asset" + printf '%s %s\n' "$sha" "$RUNNER_TEMP/$asset" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/$asset" -C "$RUNNER_TEMP" + install -m 0755 "$RUNNER_TEMP/akua" "$RUNNER_TEMP/bin/akua" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/bin/akua" --version + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version-file: tools/agent-os/.bun-version + - name: Check Agent OS tool + working-directory: tools/agent-os + run: | + set -eu + bun install --frozen-lockfile --ignore-scripts + bun run check - run: | set -eu for test_script in tests/*.test.sh; do @@ -62,7 +98,7 @@ jobs: name: Repo invariants runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 - name: Symlinks must stay intact run: | set -eu diff --git a/.gitignore b/.gitignore index 5ed2da0c3..57d590bc9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ data/ .DS_Store __pycache__/ *.pyc +image/agent-os-source.tar +image/agent-os-bootstrap.tar +image/agent-os-source.attestation .env config/crew-harness config/crew-dispatch.json diff --git a/AGENTS.md b/AGENTS.md index 16aa0b034..de07cc933 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,7 @@ Never add an agent name as co-author. `FM_HOME` selects the operational home for a firstmate instance. When it is unset, most scripts use this repo root as the home, which is today's behavior. When it is set, scripts still use their own `bin/` from the repo they live in, but operational dirs come from `$FM_HOME`: `state/`, `data/`, `config/`, and `projects/`. +When `FM_HOME` is set, never create or read operational state through repo-relative `state/`, `data/`, `config/`, or `projects/` paths; use `$FM_HOME/...` or firstmate scripts that resolve it. Existing overrides remain compatible: `FM_STATE_OVERRIDE` can still point at a custom state dir, and `FM_ROOT_OVERRIDE` still behaves like the old whole-root override when `FM_HOME` is unset. `bin/fm-send.sh` is the fail-closed exception: it requires `FM_HOME` to be set so target resolution is always scoped to an explicit firstmate home. Each secondmate gets its own persistent `FM_HOME`, so its local state, backlog, projects, and session lock are isolated from the main firstmate. @@ -756,7 +757,7 @@ Adjust the other sections only when the task genuinely deviates from the standar firstmate is its own repo behind the no-mistakes gate, so improvements to `AGENTS.md`, `bin/`, `.agents/skills/`, and public `skills/` reach `main` and then wait for each running firstmate to pull them. Only `AGENTS.md`, `bin/`, and `.agents/skills/` are a running firstmate instruction surface; public `skills/` is tracked for installers and is not loaded by firstmate. When the captain invokes `/updatefirstmate` or asks to update firstmate, load the `/updatefirstmate` skill. -It performs only fast-forward self-updates of firstmate and registered secondmate homes, re-reads `AGENTS.md` when needed, nudges updated live secondmates, and never touches anything under `projects/`. +That skill owns the source-policy split: mutable checkouts update by guarded fast-forward, while immutable candidate/release images refuse in-place source updates and move only through an image-digest upgrade. ## 13. Agent-only reference skills @@ -770,6 +771,8 @@ These skills are not captain-invocable; they are conditional operating reference - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. +- `kubernetes-fleet` - load before creating, supervising, recovering, or deleting Kubernetes-backed crewmates. +- `akua-intelligence-bootstrap` - load before provisioning, recovering, or handing off a Firstmate in an Akua-managed intelligence cluster. ## 14. X mode diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..31b37ddcb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,248 @@ +FROM node:24-trixie-slim@sha256:366fdef91728b1b7fa18c84fba63b6e79ed77b7e10cc206878e9705da4d7b169 AS source-bootstrap + +ARG AGENT_OS_SOURCE_COMMIT +ARG AGENT_OS_SOURCE_TREE +ARG AGENT_OS_SOURCE_BRANCH=main +ARG AGENT_OS_SOURCE_ORIGIN=https://github.com/akua-dev/agent-os.git +ARG AGENT_OS_SOURCE_MODE=main +ARG AGENT_OS_SOURCE_REF=refs/heads/main + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +COPY image/agent-os-source.tar image/agent-os-bootstrap.tar image/agent-os-source.attestation /tmp/ + +RUN set -eu; \ + test -n "$AGENT_OS_SOURCE_COMMIT"; \ + test -n "$AGENT_OS_SOURCE_TREE"; \ + test "$AGENT_OS_SOURCE_BRANCH" = main; \ + test "$AGENT_OS_SOURCE_ORIGIN" = https://github.com/akua-dev/agent-os.git; \ + case "$AGENT_OS_SOURCE_MODE" in main|candidate|event|release) ;; *) exit 1 ;; esac; \ + test -n "$AGENT_OS_SOURCE_REF"; \ + grep -Fx "mode=$AGENT_OS_SOURCE_MODE" /tmp/agent-os-source.attestation; \ + grep -Fx "commit=$AGENT_OS_SOURCE_COMMIT" /tmp/agent-os-source.attestation; \ + grep -Fx "tree=$AGENT_OS_SOURCE_TREE" /tmp/agent-os-source.attestation; \ + grep -Fx "branch=$AGENT_OS_SOURCE_BRANCH" /tmp/agent-os-source.attestation; \ + grep -Fx "origin=$AGENT_OS_SOURCE_ORIGIN" /tmp/agent-os-source.attestation; \ + grep -Fx "ref=$AGENT_OS_SOURCE_REF" /tmp/agent-os-source.attestation; \ + source_sha=$(sha256sum /tmp/agent-os-source.tar | awk '{print $1}'); \ + bootstrap_sha=$(sha256sum /tmp/agent-os-bootstrap.tar | awk '{print $1}'); \ + grep -Fx "source_sha256=$source_sha" /tmp/agent-os-source.attestation; \ + grep -Fx "bootstrap_sha256=$bootstrap_sha" /tmp/agent-os-source.attestation; \ + tar -xf /tmp/agent-os-bootstrap.tar -C /opt; \ + mv /opt/bootstrap.git /opt/agent-os-bootstrap.git; \ + test "$(git --git-dir=/opt/agent-os-bootstrap.git rev-parse "$AGENT_OS_SOURCE_COMMIT")" = "$AGENT_OS_SOURCE_COMMIT"; \ + test "$(git --git-dir=/opt/agent-os-bootstrap.git rev-parse "$AGENT_OS_SOURCE_COMMIT^{tree}")" = "$AGENT_OS_SOURCE_TREE"; \ + test -s /opt/agent-os-bootstrap.git/shallow; \ + test "$(git --git-dir=/opt/agent-os-bootstrap.git remote get-url origin)" = "$AGENT_OS_SOURCE_ORIGIN"; \ + test -z "$(git --git-dir=/opt/agent-os-bootstrap.git ls-tree -r --name-only "$AGENT_OS_SOURCE_COMMIT" -- config data projects state .no-mistakes)"; \ + test ! -e /opt/agent-os-bootstrap.git/hooks; \ + git --git-dir=/opt/agent-os-bootstrap.git archive --format=tar --output=/tmp/verified-source.tar "$AGENT_OS_SOURCE_COMMIT"; \ + cmp /tmp/verified-source.tar /tmp/agent-os-source.tar; \ + mkdir -p /opt/agent-os; \ + tar -xf /tmp/verified-source.tar -C /opt/agent-os; \ + test "$(git --git-dir=/opt/agent-os-bootstrap.git rev-parse "$AGENT_OS_SOURCE_COMMIT^{tree}")" = "$AGENT_OS_SOURCE_TREE"; \ + rm -f /tmp/verified-source.tar /tmp/agent-os-source.tar /tmp/agent-os-bootstrap.tar /tmp/agent-os-source.attestation; \ + printf '%s\n' "$AGENT_OS_SOURCE_COMMIT" > /opt/agent-os-source.commit; \ + printf '%s\n' "$AGENT_OS_SOURCE_TREE" > /opt/agent-os-source.tree; \ + printf '%s\n' "$source_sha" > /opt/agent-os-source.sha256; \ + printf '%s\n' "$AGENT_OS_SOURCE_BRANCH" > /opt/agent-os-source.branch; \ + printf '%s\n' "$AGENT_OS_SOURCE_ORIGIN" > /opt/agent-os-source.origin; \ + printf '%s\n' "$AGENT_OS_SOURCE_MODE" > /opt/agent-os-source.mode; \ + printf '%s\n' "$AGENT_OS_SOURCE_REF" > /opt/agent-os-source.ref + +FROM node:24-trixie-slim@sha256:366fdef91728b1b7fa18c84fba63b6e79ed77b7e10cc206878e9705da4d7b169 + +ARG TARGETARCH +ARG HERDR_VERSION=0.7.3 +ARG KUBECTL_VERSION=1.34.8 +ARG GH_VERSION=2.96.0 +ARG TREEHOUSE_VERSION=2.0.0 +ARG NO_MISTAKES_VERSION=1.34.0 +ARG BUN_VERSION=1.3.14 +ARG AKUA_VERSION=0.8.25 +ARG K9S_VERSION=0.51.0 +ARG AGENT_OS_SOURCE_COMMIT +ARG AGENT_OS_SOURCE_TREE +ARG AGENT_OS_SOURCE_BRANCH=main +ARG AGENT_OS_SOURCE_ORIGIN=https://github.com/akua-dev/agent-os.git + +COPY image/debian.sources /etc/apt/sources.list.d/debian.sources + +RUN echo "9767ac71230276e282fdb39a087c889a277835b47751a0c0e5a9da0e8352e289 /etc/apt/sources.list.d/debian.sources" | sha256sum -c - + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + fd-find \ + git \ + jq \ + openssh-client \ + procps \ + ripgrep \ + rsync \ + tmux \ + unzip \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && rm -rf /var/lib/apt/lists/* + +RUN set -eu; \ + case "$TARGETARCH" in \ + amd64) asset=herdr-linux-x86_64; sha=043ef43ecbabda28465dcff1eec3184518150d567b8b8f20cda9c6c88770641d ;; \ + arm64) asset=herdr-linux-aarch64; sha=ea490094f2c7c39099870857d00c64c628ef7b5eba1967df4258033455ee2cb1 ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/ogulcancelik/herdr/releases/download/v${HERDR_VERSION}/${asset}" -o /usr/local/bin/herdr; \ + echo "$sha /usr/local/bin/herdr" | sha256sum -c -; \ + chmod 0755 /usr/local/bin/herdr; \ + mkdir -p /usr/share/licenses/herdr; \ + curl -fsSL "https://raw.githubusercontent.com/ogulcancelik/herdr/v${HERDR_VERSION}/LICENSE" -o /usr/share/licenses/herdr/LICENSE + +RUN set -eu; \ + case "$TARGETARCH" in \ + amd64) sha=f6249132865c13abe3c9dd5038f5da65849cb86eee1608c001831504e481aa8c ;; \ + arm64) sha=4c9fe1f717738950c638c38056130a8db5075e6413ae36d8687221a240cdf88b ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl" -o /usr/local/bin/kubectl; \ + echo "$sha /usr/local/bin/kubectl" | sha256sum -c -; \ + chmod 0755 /usr/local/bin/kubectl; \ + mkdir -p /usr/share/licenses/kubectl; \ + curl -fsSL "https://raw.githubusercontent.com/kubernetes/kubernetes/1f328c5e9dd683d0c5e69f3d7d58f8371278dec2/LICENSE" -o /usr/share/licenses/kubectl/LICENSE; \ + echo "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 /usr/share/licenses/kubectl/LICENSE" | sha256sum -c - + +RUN set -eu; \ + case "$TARGETARCH" in \ + amd64) sha=83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60 ;; \ + arm64) sha=06f86ec7103d41993b76cd78072f43595c34aaa56506d971d9860e67140bf909 ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + asset="gh_${GH_VERSION}_linux_${TARGETARCH}.tar.gz"; \ + curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${asset}" -o "/tmp/${asset}"; \ + echo "$sha /tmp/${asset}" | sha256sum -c -; \ + tar -xzf "/tmp/${asset}" -C /tmp; \ + install -m 0755 "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}/bin/gh" /usr/local/bin/gh; \ + mkdir -p /usr/share/licenses/gh; \ + curl -fsSL "https://raw.githubusercontent.com/cli/cli/b300f2ec7ec9dc9addc39b2ad88c54097ded7ca0/LICENSE" -o /usr/share/licenses/gh/LICENSE; \ + echo "6da4adc42392c8485e40b4251c7e332fc3352df1947c9ffade71dd60b14a7a4f /usr/share/licenses/gh/LICENSE" | sha256sum -c -; \ + rm -rf "/tmp/${asset}" "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}" + +RUN set -eu; \ + case "$TARGETARCH" in \ + amd64) treehouse_sha=b7926c19633ee94582b7f1b58369f22b304ae7228a47253c2148e3a8176f03b0; no_mistakes_sha=449d0276e1b35369ea332dae0eddb5be326c2d4fc9643270af98858cf3906536 ;; \ + arm64) treehouse_sha=91bca451bab84df685ee17975c8a9d8cf671b3e95c96b7fc6ff0121ea0aae991; no_mistakes_sha=f157df3e18350edea8abdaa065681bd115a9d321fca86f51e9a0184b3a9d8756 ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + treehouse_asset="treehouse-v${TREEHOUSE_VERSION}-linux-${TARGETARCH}.tar.gz"; \ + curl -fsSL "https://github.com/kunchenguid/treehouse/releases/download/v${TREEHOUSE_VERSION}/${treehouse_asset}" -o "/tmp/${treehouse_asset}"; \ + echo "$treehouse_sha /tmp/${treehouse_asset}" | sha256sum -c -; \ + tar -xzf "/tmp/${treehouse_asset}" -C /usr/local/bin; \ + no_mistakes_asset="no-mistakes-v${NO_MISTAKES_VERSION}-linux-${TARGETARCH}.tar.gz"; \ + curl -fsSL "https://github.com/kunchenguid/no-mistakes/releases/download/v${NO_MISTAKES_VERSION}/${no_mistakes_asset}" -o "/tmp/${no_mistakes_asset}"; \ + echo "$no_mistakes_sha /tmp/${no_mistakes_asset}" | sha256sum -c -; \ + tar -xzf "/tmp/${no_mistakes_asset}" -C /usr/local/bin; \ + chmod 0755 /usr/local/bin/treehouse /usr/local/bin/no-mistakes; \ + mkdir -p /usr/share/licenses/treehouse /usr/share/licenses/no-mistakes; \ + curl -fsSL "https://raw.githubusercontent.com/kunchenguid/treehouse/68fa3d2556542add76bf80255787b8625a5041a6/LICENSE" -o /usr/share/licenses/treehouse/LICENSE; \ + echo "1b962d20f826f6a758c737f8aa4e8e76dc719b8aa78fcfacdfb46681bb36c2f4 /usr/share/licenses/treehouse/LICENSE" | sha256sum -c -; \ + curl -fsSL "https://raw.githubusercontent.com/kunchenguid/no-mistakes/dc5a80059d3c0f1abbf28f20f43d994b8399bee6/LICENSE" -o /usr/share/licenses/no-mistakes/LICENSE; \ + echo "945016bd37e1ba7211622ef60ee1d23ab727896ba7710edd21e8fbe983863969 /usr/share/licenses/no-mistakes/LICENSE" | sha256sum -c -; \ + rm -f "/tmp/${treehouse_asset}" "/tmp/${no_mistakes_asset}" + +RUN set -eu; \ + case "$TARGETARCH" in \ + amd64) bun_arch=x64; sha=951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f ;; \ + arm64) bun_arch=aarch64; sha=a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + asset="bun-linux-${bun_arch}.zip"; \ + curl -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${asset}" -o "/tmp/${asset}"; \ + echo "$sha /tmp/${asset}" | sha256sum -c -; \ + unzip -q "/tmp/${asset}" -d /tmp/bun; \ + install -m 0755 "/tmp/bun/bun-linux-${bun_arch}/bun" /usr/local/bin/bun; \ + mkdir -p /usr/share/licenses/bun; \ + curl -fsSL "https://raw.githubusercontent.com/oven-sh/bun/0d9b296af33f2b851fcbf4df3e9ec89751734ba4/LICENSE.md" -o /usr/share/licenses/bun/LICENSE.md; \ + echo "2c6160ec8fb853f7e8f97d9b249e756c9b0ac44860a68b6bf4f1b0bcbc5c3741 /usr/share/licenses/bun/LICENSE.md" | sha256sum -c -; \ + rm -rf "/tmp/${asset}" /tmp/bun + +RUN set -eu; \ + case "$TARGETARCH" in \ + amd64) triple=x86_64-unknown-linux-gnu; sha=bc57afbffe7e18aacd2146e2cd67151c56e7a3c279fe659312ff7ffb359cd03a ;; \ + arm64) triple=aarch64-unknown-linux-gnu; sha=3a3c6bae72764cbd85a6e4e0877a05e5def8f7aeee8563b7918099214a1a313a ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + asset="akua-v${AKUA_VERSION}-${triple}.tar.gz"; \ + curl -fsSL "https://github.com/akua-dev/akua/releases/download/v${AKUA_VERSION}/${asset}" -o "/tmp/${asset}"; \ + echo "$sha /tmp/${asset}" | sha256sum -c -; \ + tar -xzf "/tmp/${asset}" -C /tmp; \ + install -m 0755 /tmp/akua /usr/local/bin/akua; \ + rm -f "/tmp/${asset}" /tmp/akua; \ + mkdir -p /usr/share/licenses/akua; \ + curl -fsSL "https://raw.githubusercontent.com/akua-dev/akua/v${AKUA_VERSION}/LICENSE" -o /usr/share/licenses/akua/LICENSE + +RUN set -eu; \ + case "$TARGETARCH" in \ + amd64) sha=c3752ad51a5a4015a113819c4eeb6e55a4d0e4b8e652494797532f6fc8161dd7 ;; \ + arm64) sha=3ee05c82e5f9198928a4e86133608ba6a2c10a2244d6a7789e820f78319d640c ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + asset="k9s_Linux_${TARGETARCH}.tar.gz"; \ + curl -fsSL "https://github.com/derailed/k9s/releases/download/v${K9S_VERSION}/${asset}" -o "/tmp/${asset}"; \ + echo "$sha /tmp/${asset}" | sha256sum -c -; \ + tar -xzf "/tmp/${asset}" -C /tmp k9s; \ + install -m 0755 /tmp/k9s /usr/local/bin/k9s; \ + rm -f "/tmp/${asset}" /tmp/k9s; \ + mkdir -p /usr/share/licenses/k9s; \ + curl -fsSL "https://raw.githubusercontent.com/derailed/k9s/v${K9S_VERSION}/LICENSE" -o /usr/share/licenses/k9s/LICENSE + +COPY image/npm/package.json image/npm/package-lock.json /opt/agent-os-npm/ + +RUN echo "3646e31389155fbce155c828d8db46bc60ff2976c2d8d29e6633f260f56fd06d /opt/agent-os-npm/package.json" | sha256sum -c - \ + && echo "f77f31c67455d6f72e6411d5fa82669b9cc95306d518ff655b7c7795cfd41ca2 /opt/agent-os-npm/package-lock.json" | sha256sum -c - \ + && npm ci --omit=dev --ignore-scripts --no-audit --no-fund --prefix /opt/agent-os-npm \ + && mkdir -p /usr/local/lib/node_modules \ + && cp -a /opt/agent-os-npm/node_modules/. /usr/local/lib/node_modules/ \ + && for command in pi gh-axi chrome-devtools-axi lavish-axi tasks-axi quota-axi; do \ + ln -s "/usr/local/lib/node_modules/.bin/$command" "/usr/local/bin/$command"; \ + done \ + && rm -rf /opt/agent-os-npm + +ENV FM_HOME=/home/agent \ + HOME=/home/agent \ + XDG_CONFIG_HOME=/home/agent/.config \ + XDG_DATA_HOME=/home/agent/.local/share \ + XDG_CACHE_HOME=/home/agent/.cache \ + NPM_CONFIG_PREFIX=/home/agent/.local \ + BUN_INSTALL=/home/agent/.bun \ + CARGO_HOME=/home/agent/.cargo \ + PATH=/home/agent/.local/bin:/home/agent/.bun/bin:/home/agent/.cargo/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin \ + HERDR_SESSION=default + +RUN mkdir -p /home/agent /opt/agent-os + +COPY --from=source-bootstrap /opt/agent-os /opt/agent-os +COPY --from=source-bootstrap /opt/agent-os-bootstrap.git /opt/agent-os-bootstrap.git +COPY --from=source-bootstrap /opt/agent-os-source.commit /opt/agent-os-source.commit +COPY --from=source-bootstrap /opt/agent-os-source.tree /opt/agent-os-source.tree +COPY --from=source-bootstrap /opt/agent-os-source.sha256 /opt/agent-os-source.sha256 +COPY --from=source-bootstrap /opt/agent-os-source.branch /opt/agent-os-source.branch +COPY --from=source-bootstrap /opt/agent-os-source.origin /opt/agent-os-source.origin +COPY --from=source-bootstrap /opt/agent-os-source.mode /opt/agent-os-source.mode +COPY --from=source-bootstrap /opt/agent-os-source.ref /opt/agent-os-source.ref + +RUN install -D -m 0644 /opt/agent-os/THIRD_PARTY_NOTICES.md /usr/share/doc/agent-os/THIRD_PARTY_NOTICES.md \ + && install -D -m 0644 /opt/agent-os/THIRD_PARTY_SOURCES.md /usr/share/doc/agent-os/THIRD_PARTY_SOURCES.md + +RUN cd /opt/agent-os/tools/agent-os \ + && bun install --frozen-lockfile --production --ignore-scripts \ + && ln -s /opt/agent-os/tools/agent-os/src/cli.ts /usr/local/bin/agent-os \ + && cd /usr/local \ + && find . \( -type f -o -type l \) -print | LC_ALL=C sort | while IFS= read -r path; do sha256sum "$path"; done > /opt/agent-os-image-usr-local.manifest \ + && cd /opt \ + && sha256sum agent-os-image-usr-local.manifest > agent-os-image-usr-local.manifest.sha256 + +WORKDIR /home/agent/firstmate +ENTRYPOINT ["/opt/agent-os/bin/agent-os-container-entrypoint.sh"] diff --git a/README.md b/README.md index 6ec155d69..c041b6de9 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,17 @@ An agent distro is a portable directory of instructions, skills, tooling, polici There is no app to install: the cloned repo is the distro - `AGENTS.md`, bundled firstmate skills, and helper scripts that any terminal coding agent can follow. Launching a supported harness inside it instantiates your first mate - and makes you the captain. +This Agent OS fork also packages the distro for Kubernetes: one persistent +first mate can allocate isolated, persistent crewmate containers using ordinary +`kubectl`, while Herdr keeps every agent terminal visible. Follow the +[Agent OS Kubernetes quickstart](docs/kubernetes.md) to render the one public +package for any conformant cluster. OrbStack is a local test profile of that +same package, not a separate installer. + +Agent OS works on Kubernetes and is better with Akua. +The portable core can be built and tested on local OrbStack without an Akua account, API key, managed control plane, or Akua-hosted worker, and is intended for any conformant Kubernetes cluster. +Akua adds the preferred credential-to-cluster bootstrap, managed capacity, identity and secret primitives, and guarded product delivery; it is an integration advantage, not a dependency of the core runtime. + ## Features - **One liaison** - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes. @@ -171,7 +182,7 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine wakes in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery wedges while you step away | | `/bearings` | Generate a "pick up where I left off" status report from the read-only fleet snapshot - backlog, per-task crew state, open PRs, scout reports, pending decisions, and date-gated queued work - written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | -| `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | +| `/updatefirstmate` | Update mutable-source firstmates by guarded fast-forward; immutable candidate/release installs report that they require an image-digest upgrade | | `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | Agent-only reference skills live under `.agents/skills/` and are loaded by firstmate at the trigger points named in [`AGENTS.md`](AGENTS.md). @@ -188,6 +199,8 @@ Firstmate's skills live in two separate places with different audiences: ## Documentation +- [docs/kubernetes.md](docs/kubernetes.md) - run the Agent OS controller and isolated crewmates on local OrbStack Kubernetes. +- [docs/acceptance.md](docs/acceptance.md) - requirement-to-evidence ledger for the full distributed Agent OS proof. - [docs/architecture.md](docs/architecture.md) - how the crew, supervision, worktrees, secondmates, and project modes work. - [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional X mode, the files you set, and harness support. - [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for a wedged away-mode escalation delivery. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..01b6ef8b5 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,66 @@ +# Third-party notices + +## Node.js base image + +The Agent OS image uses the official multi-architecture `node:24-trixie-slim` base image pinned to index digest `sha256:366fdef91728b1b7fa18c84fba63b6e79ed77b7e10cc206878e9705da4d7b169`. +The reviewed Docker Node source revision is . +System packages resolve only from the Debian and Debian security snapshots dated `20260624T235959Z` in `image/debian.sources`. +Runtime npm packages resolve from the committed `image/npm/package-lock.json`, including registry integrity checks for every artifact. + +## Herdr + +The Agent OS image includes an unmodified Herdr 0.7.3 executable as a separate program under AGPL-3.0-or-later. +The image includes Herdr's license at `/usr/share/licenses/herdr/LICENSE`. +The source offer is available in the image at `/usr/share/doc/agent-os/THIRD_PARTY_SOURCES.md` and in this repository at [THIRD_PARTY_SOURCES.md](THIRD_PARTY_SOURCES.md). +The exact upstream source is available at . +Agent OS invokes Herdr through its documented CLI and socket interfaces. + +## Akua + +The Agent OS demo image includes an unmodified Akua 0.8.25 executable as a separate program. +Akua is available under the Apache License 2.0. +The image includes Akua's license at `/usr/share/licenses/akua/LICENSE`. +The exact source is available at . +Agent OS invokes Akua through its documented CLI. + +## K9s + +The Agent OS demo image includes an unmodified K9s 0.51.0 executable as a separate program. +K9s is available under the Apache License 2.0. +The image includes K9s's license at `/usr/share/licenses/k9s/LICENSE`. +The exact source is available at . + +## kubectl + +The Agent OS image includes the unmodified kubectl 1.34.8 executable from Kubernetes. +Kubernetes is available under the Apache License 2.0. +The image includes the license at `/usr/share/licenses/kubectl/LICENSE`. +The exact source commit is . + +## GitHub CLI + +The Agent OS image includes the unmodified GitHub CLI 2.96.0 executable. +GitHub CLI is available under the MIT License. +The image includes the license at `/usr/share/licenses/gh/LICENSE`. +The exact source commit is . + +## Bun + +The Agent OS image includes the unmodified Bun 1.3.14 executable. +Bun and its bundled components use the licenses recorded in the upstream `LICENSE.md` file. +The image includes that complete license record at `/usr/share/licenses/bun/LICENSE.md`. +The exact source commit is . + +## Treehouse + +The Agent OS image includes the unmodified Treehouse 2.0.0 executable. +Treehouse is available under the MIT License. +The image includes the license at `/usr/share/licenses/treehouse/LICENSE`. +The exact source commit is . + +## no-mistakes + +The Agent OS image includes the unmodified no-mistakes 1.34.0 executable. +no-mistakes is available under the MIT License. +The image includes the license at `/usr/share/licenses/no-mistakes/LICENSE`. +The exact source commit is . diff --git a/THIRD_PARTY_SOURCES.md b/THIRD_PARTY_SOURCES.md new file mode 100644 index 000000000..898e17554 --- /dev/null +++ b/THIRD_PARTY_SOURCES.md @@ -0,0 +1,31 @@ +# Third-party source offers + +This file is copied into every Agent OS image at `/usr/share/doc/agent-os/THIRD_PARTY_SOURCES.md`. +It gives image recipients a no-account network path to the corresponding source for included copyleft software. + +## Herdr 0.7.3 + +Agent OS conveys the upstream `herdr-linux-x86_64` or `herdr-linux-aarch64` executable from Herdr v0.7.3 as an unmodified executable. +Agent OS does not patch, link, embed, or copy Herdr source into Agent OS. +The Agent OS entrypoint executes the executable as the separate process `herdr server`. + +The immutable upstream source commit is `299dd4163a96381ec2d8e5bde13d7ba6d6432373`. +The complete source archive is . +The SHA-256 of that archive is `4e4a536fff8cd74019a1f8b4f1eef7fce556042f2b3e389eb6f9a155c1a7c6d5`. +The archive contains Herdr's source, `Cargo.lock`, `Cargo.toml`, `build.rs`, and its vendored `portable-pty` patch source. + +To retrieve and verify the source, run: + +```bash +curl --fail --location --output herdr-v0.7.3.tar.gz \ + https://github.com/ogulcancelik/herdr/archive/299dd4163a96381ec2d8e5bde13d7ba6d6432373.tar.gz +printf '%s %s\n' \ + 4e4a536fff8cd74019a1f8b4f1eef7fce556042f2b3e389eb6f9a155c1a7c6d5 \ + herdr-v0.7.3.tar.gz | sha256sum --check +tar -xzf herdr-v0.7.3.tar.gz +cd herdr-299dd4163a96381ec2d8e5bde13d7ba6d6432373 +cargo build --release +``` + +The source is offered under the upstream GNU Affero General Public License v3.0 or later at . +The complete distribution record and the rule for any future Herdr modification are in `docs/herdr-compliance.md`. diff --git a/bin/agent-os-akua-auth.sh b/bin/agent-os-akua-auth.sh new file mode 100755 index 000000000..67f92d07e --- /dev/null +++ b/bin/agent-os-akua-auth.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash +set -eu + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +COMMAND=${1:-} +SECRET=${2:-} +CONTEXT=${AGENT_OS_CONTEXT:-} +NAMESPACE=${AGENT_OS_NAMESPACE:-} +KUBECTL=${AGENT_OS_KUBECTL:-kubectl} +INSTALLATION_ID="agent-os-firstmate:$NAMESPACE" +OPERATION_ID=${AGENT_OS_OPERATION_ID:-"$(date -u '+%Y%m%d%H%M%S')-$$-$RANDOM"} +LOCK_NONCE=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') +LOCK_HOLDER_ID="$OPERATION_ID.$LOCK_NONCE" +LOCK= +LOCK_NAMESPACE= +LOCK_INSTALLATION_ID= +EXPECTED_LOCK= +LOCK_UID= +LOCK_RV= +LOCK_RENEW_PID= +LOCK_PERSISTENT=0 +CONTROL_LOCK_UID= +CONTROL_LOCK_RV= +CONTROL_LOCK_RENEW_PID= +CONTROL_LOCK_VALID_UNTIL= +LOCK_DURATION_SECONDS=${AGENT_OS_LOCK_DURATION_SECONDS:-300} +LOCK_CLOCK_SKEW_SECONDS=${AGENT_OS_LOCK_CLOCK_SKEW_SECONDS:-5} +LOCK_ACQUIRE_SECONDS=${AGENT_OS_LOCK_ACQUIRE_SECONDS:-30} +LOCK_REQUEST_CEILING_SECONDS=${AGENT_OS_LOCK_REQUEST_CEILING_SECONDS:-5} +RESOURCE_REQUEST_CEILING_SECONDS=${AGENT_OS_RESOURCE_REQUEST_CEILING_SECONDS:-5} + +case "$COMMAND" in grant|revoke) ;; *) echo "usage: $0 grant|revoke " >&2; exit 2 ;; esac +[ -n "$CONTEXT" ] && [ -n "$NAMESPACE" ] || { echo "error: set AGENT_OS_CONTEXT and AGENT_OS_NAMESPACE" >&2; exit 2; } +case "$SECRET" in ''|*[!a-z0-9.-]*|[.-]*|*[-.]) echo "error: invalid Akua authorization Secret name" >&2; exit 2 ;; esac +[ "${#SECRET}" -le 253 ] || { echo "error: invalid Akua authorization Secret name" >&2; exit 2; } +case "$NAMESPACE" in ''|*[!a-z0-9-]*|-*|*-) echo "error: invalid Kubernetes namespace" >&2; exit 2 ;; esac +[ "${#NAMESPACE}" -le 63 ] && [ "${#LOCK_HOLDER_ID}" -le 255 ] || { echo "error: derived Kubernetes identity is too long" >&2; exit 2; } +command -v jq >/dev/null 2>&1 || { echo "error: jq is required" >&2; exit 2; } + +kube() { + "$KUBECTL" --context "$CONTEXT" -n "$LOCK_NAMESPACE" "$@" +} + +target_kube() { + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" "$@" +} + +lock_record() { + local deadline=${1:-} + [ -n "$deadline" ] || deadline=$(lock_default_deadline) + lock_kube "$deadline" get lease "$LOCK" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.labels.agent-os\.dev/lifecycle}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.spec.holderIdentity}{"\t"}{.spec.acquireTime}{"\t"}{.spec.renewTime}{"\t"}{.spec.leaseDurationSeconds}{"\t"}{.metadata.uid}{"\t"}{.metadata.resourceVersion}' +} + +render_lock() { + local acquired_at=$1 renewed_at=$2 uid=${3:-} rv=${4:-} uid_value='' rv_value='' + [ -z "$uid" ] || uid_value=$(yaml_string "$uid") + [ -z "$rv" ] || rv_value=$(yaml_string "$rv") + cat <&2; exit 3; } + [ -z "${checkpoint//$'\t'/}" ] || { + echo "error: active rollback checkpoint blocks authorization mutation" >&2 + exit 3 + } +} + +sha256_text() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + else + shasum -a 256 | awk '{print $1}' + fi +} + +verify_overlay() { + local expected=$1 state_uid=$2 verify_secret=${3:-1} state pod secret_record_after + state=$(statefulset_json) + printf '%s' "$state" | jq -e --arg installation "$INSTALLATION_ID" --arg secret "$SECRET" \ + --arg uid "$state_uid" --arg expected "$expected" ' + .metadata.name == "agent-os-firstmate" and .metadata.uid == $uid and + .metadata.labels["app.kubernetes.io/managed-by"] == "agent-os" and + .metadata.annotations["agent-os.dev/installation-id"] == $installation and + (if $expected == "present" then + .metadata.annotations["agent-os.dev/akua-auth-secret"] == $secret and + ([.spec.template.spec.containers[] | select(.name == "firstmate") | .env[] | select(.name == "AKUA_AUTH_HEADER_FILE" and .value == "/var/run/secrets/agent-os/akua/authorization")] | length) == 1 and + ([.spec.template.spec.containers[] | select(.name == "firstmate") | .volumeMounts[] | select(.name == "akua-auth" and .mountPath == "/var/run/secrets/agent-os/akua" and .readOnly == true)] | length) == 1 and + ([.spec.template.spec.volumes[] | select(.name == "akua-auth" and .secret.secretName == $secret and .secret.defaultMode == 256)] | length) == 1 + else + (.metadata.annotations["agent-os.dev/akua-auth-secret"] // "") == "" and + (.metadata.annotations["agent-os.dev/akua-auth-rejected-record"] // "") == "" and + ([.spec.template.spec.containers[] | select(.name == "firstmate") | .env[]? | select(.name == "AKUA_AUTH_HEADER_FILE")] | length) == 0 and + ([.spec.template.spec.containers[] | select(.name == "firstmate") | .volumeMounts[]? | select(.name == "akua-auth")] | length) == 0 and + ([.spec.template.spec.volumes[]? | select(.name == "akua-auth")] | length) == 0 + end)' >/dev/null || { echo "error: Akua authorization overlay verification failed" >&2; return 3; } + if [ "$verify_secret" -eq 1 ]; then + secret_record_after=$(secret_record) + [ "$secret_record_after" = "$SECRET_RECORD" ] || { + echo "incomplete: Akua authorization Secret identity changed after rollout" >&2 + return 4 + } + fi + pod=$(target_kube --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get pod agent-os-firstmate-0 -o json) + printf '%s' "$pod" | jq -e --arg uid "$state_uid" --arg secret "$SECRET" --arg expected "$expected" ' + any(.metadata.ownerReferences[]?; .apiVersion == "apps/v1" and .kind == "StatefulSet" and .name == "agent-os-firstmate" and .uid == $uid and .controller == true) and + (if $expected == "present" then + ([.spec.containers[] | select(.name == "firstmate") | .env[] | select(.name == "AKUA_AUTH_HEADER_FILE" and .value == "/var/run/secrets/agent-os/akua/authorization")] | length) == 1 and + ([.spec.containers[] | select(.name == "firstmate") | .volumeMounts[] | select(.name == "akua-auth" and .mountPath == "/var/run/secrets/agent-os/akua" and .readOnly == true)] | length) == 1 and + ([.spec.volumes[] | select(.name == "akua-auth" and .secret.secretName == $secret and .secret.defaultMode == 256)] | length) == 1 + else + ([.spec.containers[] | select(.name == "firstmate") | .env[]? | select(.name == "AKUA_AUTH_HEADER_FILE")] | length) == 0 and + ([.spec.containers[] | select(.name == "firstmate") | .volumeMounts[]? | select(.name == "akua-auth")] | length) == 0 and + ([.spec.volumes[]? | select(.name == "akua-auth")] | length) == 0 + end)' >/dev/null || { echo "error: Firstmate Pod authorization overlay verification failed" >&2; return 3; } +} + +reconcile_failed_grant() { + local state uid rv revoke_patch observed rejected patch + state=$(statefulset_json) || return 3 + uid=$(printf '%s' "$state" | jq -er --arg installation "$INSTALLATION_ID" --arg uid "$STATE_UID" ' + select(.metadata.name == "agent-os-firstmate" and .metadata.uid == $uid) + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation) + | .metadata.uid') || return 3 + rv=$(printf '%s' "$state" | jq -er '.metadata.resourceVersion') || return 3 + revoke_patch=$(mktemp) + sed "s/__AKUA_AUTH_SECRET__/$SECRET/g" "$ROOT/deploy/akua/firstmate-auth-revoke.yaml" | \ + awk -v uid="$(yaml_string "$uid")" -v rv="$(yaml_string "$rv")" ' + $1 == "metadata:" && !inserted { print; print " uid: " uid; print " resourceVersion: " rv; inserted=1; next } + { print } + ' > "$revoke_patch" + if ! target_kube patch statefulset agent-os-firstmate --type=strategic --patch-file "$revoke_patch" >/dev/null; then + rm -f "$revoke_patch" + return 3 + fi + rm -f "$revoke_patch" + target_kube rollout status statefulset/agent-os-firstmate --timeout=180s || return 3 + verify_overlay absent "$STATE_UID" 0 || return 3 + observed=$(secret_record 2>/dev/null || true) + rejected=$(printf '%s' "$SECRET_RECORD" | sha256_text) + state=$(statefulset_json) || return 3 + rv=$(printf '%s' "$state" | jq -er --arg uid "$STATE_UID" 'select(.metadata.uid == $uid) | .metadata.resourceVersion') || return 3 + patch=$(jq -cn --arg uid "$STATE_UID" --arg rv "$rv" --arg rejected "$rejected" \ + '{metadata:{uid:$uid,resourceVersion:$rv,annotations:{"agent-os.dev/akua-auth-rejected-record":$rejected}}}') + target_kube patch statefulset agent-os-firstmate --type=merge -p "$patch" >/dev/null || return 3 + printf 'incomplete: grant failed closed expected-secret-uid=%s expected-secret-rv=%s observed-secret-uid=%s observed-secret-rv=%s\n' \ + "$(printf '%s' "$SECRET_RECORD" | cut -f2)" "$(printf '%s' "$SECRET_RECORD" | cut -f3)" \ + "$(printf '%s' "$observed" | cut -f2)" "$(printf '%s' "$observed" | cut -f3)" >&2 +} + +fail_grant_closed() { + local reason=$1 + reconcile_failed_grant || { + echo "incomplete: $reason and fail-closed reconciliation is unverified" >&2 + exit 3 + } + echo "incomplete: $reason; authorization overlay removed and rejected identity retained" >&2 + echo "safe recovery: inspect the named Secret metadata, then run revoke before a new grant" >&2 + exit 3 +} + +configure_control_lock +LOCK=$CONTROL_LOCK +LOCK_NAMESPACE=$CONTROL_NAMESPACE +LOCK_INSTALLATION_ID=$CONTROL_LOCK_INSTALLATION_ID +LOCK_PERSISTENT=1 +EXPECTED_LOCK="$LOCK"$'\t'"agent-os"$'\t'"primary"$'\t'"$LOCK_INSTALLATION_ID" +acquire_lock +CONTROL_LOCK_UID=$LOCK_UID +CONTROL_LOCK_RV=$LOCK_RV +CONTROL_LOCK_RENEW_PID=$LOCK_RENEW_PID +CONTROL_LOCK_VALID_UNTIL=$LOCK_VALID_UNTIL +LOCK=agent-os-firstmate-lifecycle +LOCK_NAMESPACE=$NAMESPACE +LOCK_INSTALLATION_ID=$INSTALLATION_ID +EXPECTED_LOCK="$LOCK"$'\t'"agent-os"$'\t'"primary"$'\t'"$LOCK_INSTALLATION_ID" +LOCK_UID= +LOCK_RV= +LOCK_RENEW_PID= +LOCK_VALID_UNTIL= +LOCK_PERSISTENT=0 +acquire_lock + +SECRET_RECORD= +if [ "$COMMAND" = grant ]; then + SECRET_RECORD=$(secret_record) + [ "$(printf '%s' "$SECRET_RECORD" | cut -f1)" = "$SECRET" ] && \ + [ -n "$(printf '%s' "$SECRET_RECORD" | cut -f2)" ] && [ -n "$(printf '%s' "$SECRET_RECORD" | cut -f3)" ] && \ + [ "$(printf '%s' "$SECRET_RECORD" | cut -f4-)" = authorization ] || { + echo "error: Akua authorization Secret reference is missing or unverifiable" >&2 + exit 2 + } +fi + +STATE=$(statefulset_json) +STATE_UID=$(printf '%s' "$STATE" | jq -er --arg installation "$INSTALLATION_ID" ' + select(.metadata.name == "agent-os-firstmate") + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation) + | .metadata.uid') || { echo "error: StatefulSet ownership is unverifiable" >&2; exit 2; } +STATE_RV=$(printf '%s' "$STATE" | jq -er '.metadata.resourceVersion') || { echo "error: StatefulSet resourceVersion is unavailable" >&2; exit 2; } +require_no_active_rollback_checkpoint +if [ "$COMMAND" = grant ] && [ -n "$(printf '%s' "$STATE" | jq -r '.metadata.annotations["agent-os.dev/akua-auth-rejected-record"] // empty')" ]; then + echo "error: a rejected Secret identity is recorded; run revoke before approving a new grant" >&2 + exit 3 +fi + +PATCH_FILE=$(mktemp) +TEMPLATE="$ROOT/deploy/akua/firstmate-auth-$COMMAND.yaml" +uid_value=$(yaml_string "$STATE_UID") +rv_value=$(yaml_string "$STATE_RV") +sed "s/__AKUA_AUTH_SECRET__/$SECRET/g" "$TEMPLATE" | awk -v uid="$uid_value" -v rv="$rv_value" ' + $1 == "metadata:" && !inserted { print; print " uid: " uid; print " resourceVersion: " rv; inserted=1; next } + { print } +' > "$PATCH_FILE" +if [ "$COMMAND" = grant ]; then + [ "$(secret_record)" = "$SECRET_RECORD" ] || { + echo "error: Akua authorization Secret identity changed before StatefulSet CAS" >&2 + exit 3 + } +fi +if ! target_kube patch statefulset agent-os-firstmate --type=strategic --patch-file "$PATCH_FILE" >/dev/null; then + [ "$COMMAND" != grant ] || fail_grant_closed "grant CAS failed ambiguously" + echo "error: revoke CAS failed" >&2 + exit 3 +fi +if ! target_kube rollout status statefulset/agent-os-firstmate --timeout=180s; then + [ "$COMMAND" != grant ] || fail_grant_closed "grant rollout failed" + echo "incomplete: revoke rollout failed" >&2 + exit 3 +fi +if [ "$COMMAND" = grant ]; then + if ! verify_overlay present "$STATE_UID"; then + fail_grant_closed "grant verification failed" + fi +else + verify_overlay absent "$STATE_UID" 0 +fi diff --git a/bin/agent-os-candidate-state.sh b/bin/agent-os-candidate-state.sh new file mode 100755 index 000000000..243017dc1 --- /dev/null +++ b/bin/agent-os-candidate-state.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -eu + +state_file=${1:?candidate claim-chain state file is required} +[ -f "$state_file" ] && [ ! -L "$state_file" ] || { + echo "error: candidate claim-chain state is unreadable" >&2 + exit 2 +} + +attempted_count=0 +build_owner= +record_owner= +record_total=0 +build_total=0 +seen_owners='|' +seen_claims='|' +entries=0 +while IFS=$'\t' read -r owner claim attempt record_count artifact_count extra || [ -n "${owner:-}" ]; do + [ -z "${extra:-}" ] || { + echo "error: candidate claim-chain state has extra fields" >&2 + exit 2 + } + [[ "$owner" =~ ^[0-9]+$ ]] && [[ "$claim" =~ ^[0-9a-f]{40}$ ]] || { + echo "error: candidate claim-chain identity is invalid" >&2 + exit 2 + } + case "$seen_owners" in *"|$owner|"*) echo "error: candidate claim owner is duplicated" >&2; exit 2 ;; esac + case "$seen_claims" in *"|$claim|"*) echo "error: candidate claim commit is duplicated" >&2; exit 2 ;; esac + seen_owners="${seen_owners}${owner}|" + seen_claims="${seen_claims}${claim}|" + entries=$((entries + 1)) + case "$attempt" in + absent) ;; + attempted) + attempted_count=$((attempted_count + 1)) + build_owner=$owner + ;; + *) + echo "error: candidate attempt state is not exact: $attempt" >&2 + exit 2 + ;; + esac + [[ "$record_count" =~ ^[0-9]+$ ]] && [[ "$artifact_count" =~ ^[0-9]+$ ]] || { + echo "error: candidate evidence counts are invalid" >&2 + exit 2 + } + [ "$record_count" -le 1 ] && [ "$artifact_count" -le 1 ] || { + echo "error: candidate evidence is ambiguous" >&2 + exit 2 + } + if [ "$artifact_count" -eq 1 ]; then + [ "$attempt" = attempted ] || { + echo "error: candidate build evidence is not bound to its build attempt" >&2 + exit 2 + } + build_total=$((build_total + 1)) + fi + if [ "$record_count" -eq 1 ]; then + [ "$attempted_count" -eq 1 ] || { + echo "error: candidate record evidence precedes its build attempt" >&2 + exit 2 + } + record_total=$((record_total + 1)) + record_owner=$owner + fi +done < "$state_file" + +[ "$entries" -gt 0 ] || { + echo "error: candidate claim chain is empty" >&2 + exit 2 +} +[ "$attempted_count" -le 1 ] || { + echo "error: candidate claim chain contains multiple build attempts" >&2 + exit 2 +} +[ "$record_total" -le 1 ] && [ "$build_total" -le 1 ] || { + echo "error: candidate claim chain contains conflicting durable evidence" >&2 + exit 2 +} +if [ "$attempted_count" -eq 0 ]; then + printf 'build\n' + exit 0 +fi +case "$record_total:$build_total" in + 1:1) printf 'reuse-record-pair\t%s\t%s\n' "$record_owner" "$build_owner" ;; + 1:0) + [ "$record_owner" = "$build_owner" ] || { + echo "error: recovered candidate record lacks exact build evidence" >&2 + exit 2 + } + printf 'reuse-record\t%s\n' "$record_owner" + ;; + 0:1) printf 'reuse-build\t%s\n' "$build_owner" ;; + 0:0) + echo "error: candidate build attempt has no durable exact evidence; refusing rebuild" >&2 + exit 2 + ;; +esac diff --git a/bin/agent-os-container-entrypoint.sh b/bin/agent-os-container-entrypoint.sh new file mode 100755 index 000000000..5a29e4784 --- /dev/null +++ b/bin/agent-os-container-entrypoint.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# agent-os-container-entrypoint.sh - seed a persistent home and run Herdr. +set -eu + +FM_HOME=${FM_HOME:-/home/agent} +export FM_HOME HOME="$FM_HOME" +REQUESTED_FM_ROOT=${FM_ROOT_OVERRIDE:-$FM_HOME/firstmate} +IMAGE_SOURCE=${AGENT_OS_IMAGE_SOURCE:-/opt/agent-os-bootstrap.git} +SOURCE_COMMIT=$(cat /opt/agent-os-source.commit) +SOURCE_TREE=$(cat /opt/agent-os-source.tree) +SOURCE_SHA=$(cat /opt/agent-os-source.sha256) +SOURCE_BRANCH=$(cat /opt/agent-os-source.branch) +SOURCE_ORIGIN=$(cat /opt/agent-os-source.origin) +SOURCE_MODE=$(cat /opt/agent-os-source.mode) +SOURCE_REF=$(cat /opt/agent-os-source.ref) +case "$SOURCE_BRANCH" in ''|*[!A-Za-z0-9._/-]*|/*|*/|*..*) echo "error: image source branch provenance is invalid" >&2; exit 2 ;; esac +[ "$SOURCE_ORIGIN" = https://github.com/akua-dev/agent-os.git ] || { echo "error: image source origin is invalid" >&2; exit 2; } +case "$SOURCE_MODE" in + main) [ "$SOURCE_REF" = refs/heads/main ] || exit 2; TRUSTED_SOURCE_REF=$SOURCE_REF; TRUSTED_REF=refs/remotes/agent-os-verified/main; IMMUTABLE_SOURCE=false ;; + candidate) + [ "$SOURCE_REF" = "refs/agent-os/candidates/$SOURCE_COMMIT" ] || exit 2 + TRUSTED_SOURCE_REF=$SOURCE_COMMIT + TRUSTED_REF=refs/remotes/agent-os-verified/candidate + IMMUTABLE_SOURCE=true + ;; + release) [[ "$SOURCE_REF" =~ ^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || exit 2; TRUSTED_SOURCE_REF=$SOURCE_REF; TRUSTED_REF=refs/remotes/agent-os-verified/release; IMMUTABLE_SOURCE=true ;; + event) echo "error: pull-request validation images are not runnable" >&2; exit 2 ;; + *) echo "error: image source mode is invalid" >&2; exit 2 ;; +esac +IMAGE_REF="refs/remotes/agent-os-image/$SOURCE_BRANCH" +GIT_BIN=/usr/bin/git +[ -x "$GIT_BIN" ] || { echo "error: trusted Git executable is unavailable" >&2; exit 2; } + +trusted_git() { + env -i HOME=/nonexistent PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + "$GIT_BIN" -c credential.helper= -c core.hooksPath=/dev/null \ + -c http.proxy= -c https.proxy= "$@" +} + +validate_git_config() { + local config="$FM_ROOT/.git/config" key value + [ -f "$config" ] || { echo "error: canonical FM_ROOT Git config is unavailable" >&2; exit 2; } + while IFS= read -r key; do + [ -n "$key" ] || continue + value=$(trusted_git config --file "$config" --no-includes --get-all "$key" || true) + [ -n "$value" ] && [ "$value" = "$(printf '%s\n' "$value" | head -n 1)" ] || { + echo "error: canonical FM_ROOT Git config key is duplicated or empty" >&2 + exit 2 + } + case "$key" in + core.repositoryformatversion) [ "$value" = 0 ] ;; + core.filemode) [ "$value" = true ] || [ "$value" = false ] ;; + core.bare) [ "$value" = false ] ;; + core.logallrefupdates) [ "$value" = true ] ;; + remote.origin.url) [ "$value" = "$SOURCE_ORIGIN" ] && [ "$value" = https://github.com/akua-dev/agent-os.git ] ;; + remote.origin.fetch) + [ "$value" = '+refs/heads/*:refs/remotes/origin/*' ] || \ + [ "$value" = "+refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH" ] + ;; + branch."$SOURCE_BRANCH".remote) [ "$value" = origin ] ;; + branch."$SOURCE_BRANCH".merge) [ "$value" = "refs/heads/$SOURCE_BRANCH" ] ;; + *) echo "error: canonical FM_ROOT Git config key is not allowlisted: $key" >&2; exit 2 ;; + esac || { echo "error: canonical FM_ROOT Git config value is invalid: $key" >&2; exit 2; } + done < <(trusted_git config --file "$config" --no-includes --name-only --list) + [ "$(trusted_git config --file "$config" --no-includes --get remote.origin.url || true)" = "$SOURCE_ORIGIN" ] || { + echo "error: canonical FM_ROOT origin provenance changed" >&2 + exit 2 + } +} + +mkdir -p \ + "$FM_HOME/config" \ + "$FM_HOME/data" \ + "$FM_HOME/projects" \ + "$FM_HOME/state" \ + "$HOME/.config/agent-os" \ + "$HOME/.cache" \ + "$HOME/.local/bin" \ + "$HOME/.local/share" \ + "$HOME/.bun" \ + "$HOME/.cargo" + +if [ "$IMMUTABLE_SOURCE" = true ]; then + FM_ROOT=$(FM_HOME="$FM_HOME" AGENT_OS_IMAGE_SOURCE="$IMAGE_SOURCE" \ + AGENT_OS_SOURCE_COMMIT="$SOURCE_COMMIT" AGENT_OS_SOURCE_TREE="$SOURCE_TREE" \ + AGENT_OS_SOURCE_SHA256="$SOURCE_SHA" AGENT_OS_SOURCE_BRANCH="$SOURCE_BRANCH" \ + AGENT_OS_SOURCE_ORIGIN="$SOURCE_ORIGIN" AGENT_OS_SOURCE_MODE="$SOURCE_MODE" \ + "$(dirname "$0")/agent-os-runtime-source.sh") +elif [ ! -e "$REQUESTED_FM_ROOT/.git" ]; then + FM_ROOT=$REQUESTED_FM_ROOT + [ ! -e "$FM_ROOT" ] || { echo "error: canonical FM_ROOT exists without Git provenance" >&2; exit 2; } + trusted_git -c protocol.file.allow=always clone --no-local --branch "$SOURCE_BRANCH" "$IMAGE_SOURCE" "$FM_ROOT" + trusted_git -C "$FM_ROOT" remote set-url origin "$SOURCE_ORIGIN" + validate_git_config + [ "$(trusted_git -C "$FM_ROOT" rev-parse HEAD)" = "$SOURCE_COMMIT" ] || { + echo "error: canonical FM_ROOT bootstrap commit provenance failed" >&2 + exit 2 + } + rm -rf "$FM_ROOT/.git/hooks" +else + FM_ROOT=$REQUESTED_FM_ROOT + [ -d "$FM_ROOT/.git" ] || { echo "error: canonical FM_ROOT Git metadata is invalid" >&2; exit 2; } + validate_git_config + [ -z "$(trusted_git -C "$FM_ROOT" status --porcelain)" ] || { echo "error: canonical FM_ROOT is not clean" >&2; exit 2; } + trusted_git -c protocol.file.allow=always -C "$FM_ROOT" fetch --no-tags "$IMAGE_SOURCE" \ + "refs/heads/$SOURCE_BRANCH:$IMAGE_REF" + [ "$(trusted_git -C "$FM_ROOT" rev-parse "$IMAGE_REF")" = "$SOURCE_COMMIT" ] || { + echo "error: image source trusted ref provenance failed" >&2 + exit 2 + } + [ "$(trusted_git -C "$FM_ROOT" rev-parse "$IMAGE_REF^{tree}")" = "$SOURCE_TREE" ] || { + echo "error: image source trusted tree provenance failed" >&2 + exit 2 + } + current_branch=$(trusted_git -C "$FM_ROOT" symbolic-ref --quiet --short HEAD || true) + if [ -z "$current_branch" ]; then + trusted_git -C "$FM_ROOT" merge-base --is-ancestor HEAD "$IMAGE_REF" || { + echo "error: detached canonical FM_ROOT lacks trusted fast-forward provenance" >&2 + exit 2 + } + if trusted_git -C "$FM_ROOT" show-ref --verify --quiet "refs/heads/$SOURCE_BRANCH"; then + [ "$(trusted_git -C "$FM_ROOT" rev-parse "refs/heads/$SOURCE_BRANCH")" = "$(trusted_git -C "$FM_ROOT" rev-parse HEAD)" ] || { + echo "error: canonical default branch conflicts with detached provenance" >&2 + exit 2 + } + else + trusted_git -C "$FM_ROOT" branch "$SOURCE_BRANCH" HEAD + fi + trusted_git -C "$FM_ROOT" checkout "$SOURCE_BRANCH" + fi + [ "$(trusted_git -C "$FM_ROOT" symbolic-ref --short HEAD)" = "$SOURCE_BRANCH" ] || { + echo "error: canonical FM_ROOT is not on the declared default branch" >&2 + exit 2 + } + if trusted_git -C "$FM_ROOT" merge-base --is-ancestor HEAD "$IMAGE_REF"; then + trusted_git -C "$FM_ROOT" merge --ff-only "$IMAGE_REF" + elif ! trusted_git -C "$FM_ROOT" merge-base --is-ancestor "$SOURCE_COMMIT" HEAD; then + echo "error: canonical FM_ROOT source transition is not fast-forward compatible" >&2 + exit 2 + fi + trusted_git -C "$FM_ROOT" update-ref -d "$IMAGE_REF" +fi + +[ "$(trusted_git -C "$FM_ROOT" symbolic-ref --short HEAD)" = "$SOURCE_BRANCH" ] || { + echo "error: canonical FM_ROOT is not on the declared default branch" >&2 + exit 2 +} +[ "$(trusted_git -C "$FM_ROOT" rev-parse "$SOURCE_COMMIT^{tree}")" = "$SOURCE_TREE" ] || { + echo "error: canonical FM_ROOT image source tree provenance failed" >&2 + exit 2 +} +validate_git_config +trusted_git -C "$FM_ROOT" fetch --no-tags --prune "$SOURCE_ORIGIN" \ + "$TRUSTED_SOURCE_REF:$TRUSTED_REF" || { + echo "error: fresh trusted source provenance is unavailable" >&2 + exit 3 +} +trusted_git -C "$FM_ROOT" merge-base --is-ancestor "$SOURCE_COMMIT" "$TRUSTED_REF" || { + echo "error: image source commit is not reachable from the fresh trusted ref" >&2 + exit 2 +} +if [ "$SOURCE_MODE" = main ]; then + trusted_git -C "$FM_ROOT" merge-base --is-ancestor HEAD "$TRUSTED_REF" || { + echo "error: canonical FM_ROOT contains source not reachable from the fresh trusted ref" >&2 + exit 2 + } + trusted_git -C "$FM_ROOT" merge --ff-only "$TRUSTED_REF" +else + [ "$(trusted_git -C "$FM_ROOT" rev-parse HEAD)" = "$SOURCE_COMMIT" ] || { + echo "error: immutable FM_ROOT differs from its verified source commit" >&2 + exit 2 + } +fi +if [ "$SOURCE_MODE" = main ]; then + [ "$(trusted_git -C "$FM_ROOT" rev-parse HEAD)" = "$(trusted_git -C "$FM_ROOT" rev-parse "$TRUSTED_REF^{commit}")" ] || { + echo "error: canonical FM_ROOT HEAD is not the exact fresh trusted source ref" >&2 + exit 2 + } +else + trusted_git -C "$FM_ROOT" merge-base --is-ancestor "$SOURCE_COMMIT" "$TRUSTED_REF" || { + echo "error: immutable image source is not reachable from the fresh trusted ref" >&2 + exit 2 + } +fi +trusted_git -C "$FM_ROOT" update-ref "refs/remotes/origin/$SOURCE_BRANCH" "$TRUSTED_REF" +[ -z "$(trusted_git -C "$FM_ROOT" status --porcelain)" ] || { echo "error: canonical FM_ROOT is not clean" >&2; exit 2; } +[ -z "$(trusted_git -C "$FM_ROOT" ls-files -- config data projects state .no-mistakes)" ] || { + echo "error: canonical FM_ROOT contains operational state" >&2 + exit 2 +} +find "$FM_ROOT/.git/hooks" -mindepth 1 -print -quit 2>/dev/null | grep -q . && { + echo "error: canonical FM_ROOT contains Git hooks" >&2 + exit 2 +} +export FM_ROOT_OVERRIDE="$FM_ROOT" +if [ "$IMMUTABLE_SOURCE" = true ]; then + FM_HOME="$FM_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" \ + AGENT_OS_SOURCE_COMMIT="$SOURCE_COMMIT" AGENT_OS_SOURCE_TREE="$SOURCE_TREE" \ + AGENT_OS_SOURCE_SHA256="$SOURCE_SHA" AGENT_OS_SOURCE_BRANCH="$SOURCE_BRANCH" \ + AGENT_OS_SOURCE_ORIGIN="$SOURCE_ORIGIN" AGENT_OS_SOURCE_MODE="$SOURCE_MODE" \ + "$(dirname "$0")/agent-os-runtime-secondmates.sh" +fi +ln -sfn "$FM_ROOT/tools/agent-os/src/cli.ts" "$HOME/.local/bin/agent-os" + +if [ -n "${AGENT_OS_PI_AUTH_FILE:-}" ]; then + if [ ! -f "$AGENT_OS_PI_AUTH_FILE" ]; then + echo "error: projected Pi authorization is unavailable" >&2 + exit 2 + fi + mkdir -p "$HOME/.pi/agent" + ln -sfn -- "$AGENT_OS_PI_AUTH_FILE" "$HOME/.pi/agent/auth.json" +fi + +"$(dirname "$0")/agent-os-kubeconfig.sh" +if [ ! -e "$FM_HOME/config/backend" ]; then + printf 'herdr\n' > "$FM_HOME/config/backend" +fi + +# The local evaluation overlay may pin every spawned Pi agent while a model is +# under test. This is deliberately opt-in so the reusable image and Akua +# packages remain model-agnostic. Converge both dispatch files on every Pod +# start: crew-dispatch governs crewmates/scouts, while secondmate-harness is the +# separate profile used to launch Secondmates. +if [ -n "${AGENT_OS_TEST_PI_MODEL:-}" ]; then + case "$AGENT_OS_TEST_PI_MODEL" in + */*) ;; + *) + echo "error: AGENT_OS_TEST_PI_MODEL must include its provider" >&2 + exit 2 + ;; + esac + case "$AGENT_OS_TEST_PI_MODEL" in + *[!A-Za-z0-9._:/-]*) + echo "error: AGENT_OS_TEST_PI_MODEL must be one provider-qualified token" >&2 + exit 2 + ;; + esac + case "${AGENT_OS_TEST_PI_EFFORT:-}" in + low|medium|high|xhigh) ;; + *) + echo "error: AGENT_OS_TEST_PI_EFFORT must be low, medium, high, or xhigh" >&2 + exit 2 + ;; + esac + cat > "$FM_HOME/config/crew-dispatch.json" < "$FM_HOME/config/secondmate-harness" + + pi_provider=${AGENT_OS_TEST_PI_MODEL%%/*} + pi_model=${AGENT_OS_TEST_PI_MODEL#*/} + pi_settings_dir="$HOME/.pi/agent" + pi_settings="$pi_settings_dir/settings.json" + pi_settings_tmp="$pi_settings.tmp.$$" + mkdir -p "$pi_settings_dir" + umask 077 + if [ -s "$pi_settings" ] && jq -e 'type == "object"' "$pi_settings" >/dev/null 2>&1; then + jq \ + --arg provider "$pi_provider" \ + --arg model "$pi_model" \ + --arg thinking "$AGENT_OS_TEST_PI_EFFORT" \ + '. + {defaultProvider: $provider, defaultModel: $model, defaultThinkingLevel: $thinking}' \ + "$pi_settings" > "$pi_settings_tmp" + else + jq -n \ + --arg provider "$pi_provider" \ + --arg model "$pi_model" \ + --arg thinking "$AGENT_OS_TEST_PI_EFFORT" \ + '{defaultProvider: $provider, defaultModel: $model, defaultThinkingLevel: $thinking}' \ + > "$pi_settings_tmp" + fi + mv "$pi_settings_tmp" "$pi_settings" +fi + +for tool in gh-axi chrome-devtools-axi lavish-axi; do + marker="$HOME/.config/agent-os/setup-$tool" + if [ ! -e "$marker" ]; then + "$tool" setup hooks + : > "$marker" + fi +done + +cd "$FM_ROOT" +exec herdr server diff --git a/bin/agent-os-crewmate.sh b/bin/agent-os-crewmate.sh new file mode 100755 index 000000000..4b9a5d1b1 --- /dev/null +++ b/bin/agent-os-crewmate.sh @@ -0,0 +1,739 @@ +#!/usr/bin/env bash +# agent-os-crewmate.sh - operate one isolated crewmate Pod and persistent home. +# Usage: bin/agent-os-crewmate.sh create|status|stop|restart|purge|delete [--yes] +set -eu + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +COMMAND=${1:-} +ID=${2:-} +CONFIRM=${3:-} +NAMESPACE=${AGENT_OS_NAMESPACE:-agent-os} +IMAGE=${AGENT_OS_IMAGE:-} +IMAGE_PULL_POLICY=${AGENT_OS_IMAGE_PULL_POLICY:-IfNotPresent} +AI_SECRET=${AGENT_OS_AI_SECRET:-} +KUBECTL=${AGENT_OS_KUBECTL:-kubectl} +TEMPLATE=${AGENT_OS_CREWMATE_TEMPLATE:-"$ROOT/tools/agent-os/packages/firstmate/crewmate.yaml"} +INSTALLATION_ID="agent-os-firstmate:$NAMESPACE" +OPERATION_ID=${AGENT_OS_OPERATION_ID:-"$(date -u '+%Y%m%d%H%M%S')-$$-$RANDOM"} +LOCK_NONCE=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') +LOCK_HOLDER_ID="$OPERATION_ID.$LOCK_NONCE" +CONTROL_LOCK_UID= +CONTROL_LOCK_RV= +CONTROL_LOCK_RENEW_PID= +CONTROL_LOCK_VALID_UNTIL= + +[ -f "$TEMPLATE" ] || { echo "error: crewmate template is unavailable in canonical source" >&2; exit 2; } + +case "$ID" in + ''|*[!a-z0-9-]*|-*|*-) echo "error: invalid crewmate id '$ID'" >&2; exit 2 ;; +esac +case "$OPERATION_ID" in + ''|*[!a-z0-9.-]*|[.-]*|*[-.]) echo "error: invalid operation id" >&2; exit 2 ;; +esac +[ "${#OPERATION_ID}" -le 63 ] || { echo "error: invalid operation id" >&2; exit 2; } + +KUBECTL_ARGS=() +if [ -n "${AGENT_OS_CONTEXT:-}" ]; then + KUBECTL_ARGS=(--context "$AGENT_OS_CONTEXT") +elif [ "${AGENT_OS_IN_CLUSTER:-0}" != 1 ] && [ ! -f /var/run/secrets/kubernetes.io/serviceaccount/token ]; then + echo "error: set AGENT_OS_CONTEXT outside Kubernetes; ambient contexts are refused" >&2 + exit 2 +fi + +POD="agent-os-crewmate-$ID" +PVC="$POD-home" +LOCK="$POD-lifecycle" +[ "${#ID}" -le 63 ] || { echo "error: invalid crewmate id '$ID'" >&2; exit 2; } +for derived_name in "$POD" "$PVC" "$LOCK"; do + [ "${#derived_name}" -le 63 ] || { echo "error: crewmate id '$ID' makes Kubernetes resource names too long" >&2; exit 2; } +done +LOCK_NAMESPACE=$NAMESPACE +LOCK_SCOPE=crewmate +EXPECTED_POD="$POD"$'\t'"agent-os"$'\t'"$ID"$'\t'"$INSTALLATION_ID" +EXPECTED_PVC="$PVC"$'\t'"agent-os"$'\t'"$ID"$'\t'"$INSTALLATION_ID" +EXPECTED_LOCK="$LOCK"$'\t'"agent-os"$'\t'"$ID"$'\t'"$INSTALLATION_ID" +LOCK_UID= +LOCK_RV= +LOCK_RENEW_PID= +LOCK_PERSISTENT=0 +LOCK_DURATION_SECONDS=${AGENT_OS_LOCK_DURATION_SECONDS:-300} +LOCK_CLOCK_SKEW_SECONDS=${AGENT_OS_LOCK_CLOCK_SKEW_SECONDS:-5} +LOCK_ACQUIRE_SECONDS=${AGENT_OS_LOCK_ACQUIRE_SECONDS:-30} +LOCK_REQUEST_CEILING_SECONDS=${AGENT_OS_LOCK_REQUEST_CEILING_SECONDS:-5} +RESOURCE_REQUEST_CEILING_SECONDS=${AGENT_OS_RESOURCE_REQUEST_CEILING_SECONDS:-5} +DELETE_OUTCOME= +DELETE_CAPTURED_UID= +DELETE_OBSERVED_UID= + +for seconds in "$LOCK_DURATION_SECONDS" "$LOCK_CLOCK_SKEW_SECONDS" "$LOCK_ACQUIRE_SECONDS" "$LOCK_REQUEST_CEILING_SECONDS" "$RESOURCE_REQUEST_CEILING_SECONDS"; do + case "$seconds" in ''|*[!0-9]*) echo "error: lifecycle Lease timing must use whole seconds" >&2; exit 2 ;; esac +done +[ "$LOCK_DURATION_SECONDS" -ge 3 ] || { echo "error: lifecycle Lease duration must be at least 3 seconds" >&2; exit 2; } +[ "$LOCK_ACQUIRE_SECONDS" -ge 1 ] || { echo "error: lifecycle Lease acquisition must allow at least 1 second" >&2; exit 2; } +[ "$LOCK_REQUEST_CEILING_SECONDS" -ge 1 ] || { echo "error: lifecycle Lease request ceiling must be at least 1 second" >&2; exit 2; } +[ "$RESOURCE_REQUEST_CEILING_SECONDS" -ge 1 ] || { echo "error: resource request ceiling must be at least 1 second" >&2; exit 2; } + +kube() { + "$KUBECTL" "${KUBECTL_ARGS[@]}" -n "$LOCK_NAMESPACE" "$@" +} + +resource_identity() { + local kind=$1 name=$2 + kube get "$kind" "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.labels.agent-os\.dev/crewmate}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}' +} + +pod_record() { + local deadline=${1:-} request_args=() + if [ -n "$deadline" ]; then + request_seconds=$(resource_request_seconds "$deadline") || return 124 + request_args=(--request-timeout="${request_seconds}s") + fi + kube "${request_args[@]}" get pod "$POD" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.labels.agent-os\.dev/crewmate}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.metadata.labels.agent-os\.dev/operation-id}{"\t"}{.metadata.uid}{"\t"}{.metadata.resourceVersion}{"\t"}{range .spec.volumes[?(@.name=="home")]}{.persistentVolumeClaim.claimName}{end}' +} + +pvc_record() { + local deadline=${1:-} request_args=() + if [ -n "$deadline" ]; then + request_seconds=$(resource_request_seconds "$deadline") || return 124 + request_args=(--request-timeout="${request_seconds}s") + fi + kube "${request_args[@]}" get pvc "$PVC" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.labels.agent-os\.dev/crewmate}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.metadata.annotations.agent-os\.dev/checkpoint-state}{"\t"}{.metadata.annotations.agent-os\.dev/checkpoint-at}{"\t"}{.metadata.annotations.agent-os\.dev/quiesced-operation}{"\t"}{.metadata.annotations.agent-os\.dev/checkpoint-operation}{"\t"}{.metadata.uid}{"\t"}{.metadata.resourceVersion}' +} + +lock_record() { + local deadline=${1:-} + [ -n "$deadline" ] || deadline=$(lock_default_deadline) + if [ "$LOCK_SCOPE" != crewmate ]; then + lock_kube "$deadline" get lease "$LOCK" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.labels.agent-os\.dev/lifecycle}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.spec.holderIdentity}{"\t"}{.spec.acquireTime}{"\t"}{.spec.renewTime}{"\t"}{.spec.leaseDurationSeconds}{"\t"}{.metadata.uid}{"\t"}{.metadata.resourceVersion}' + return + fi + lock_kube "$deadline" get lease "$LOCK" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.labels.agent-os\.dev/crewmate}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.spec.holderIdentity}{"\t"}{.spec.acquireTime}{"\t"}{.spec.renewTime}{"\t"}{.spec.leaseDurationSeconds}{"\t"}{.metadata.uid}{"\t"}{.metadata.resourceVersion}' +} + +require_owned_or_absent() { + local kind=$1 name=$2 expected=$3 identity + identity=$(resource_identity "$kind" "$name") + if [ -n "$identity" ] && [ "$identity" != "$expected" ]; then + echo "error: $kind '$name' does not have the exact crewmate installation identity" >&2 + exit 2 + fi + printf '%s' "$identity" +} + +require_owned_pvc_or_absent() { + local record identity + record=$(pvc_record) + identity=$(printf '%s' "$record" | cut -f1-4) + if [ -n "$record" ] && [ "$identity" != "$EXPECTED_PVC" ]; then + echo "error: pvc '$PVC' does not have the exact crewmate installation identity" >&2 + exit 2 + fi + printf '%s' "$record" +} + +validate_ai_grant() { + if [ -z "$IMAGE" ]; then + echo "error: AGENT_OS_IMAGE must name the immutable image selected for this cluster" >&2 + exit 2 + fi + case "$AI_SECRET" in + ''|*[!a-z0-9.-]*|[.-]*|*[-.]) + echo "error: AGENT_OS_AI_SECRET must name an explicitly authorized namespace-local Secret" >&2 + exit 2 + ;; + esac + if [ "${#AI_SECRET}" -gt 253 ]; then + echo "error: AGENT_OS_AI_SECRET must be a valid Kubernetes Secret name" >&2 + exit 2 + fi +} + +render_resources() { + sed \ + -e "s|__AGENT_OS_CREWMATE_ID__|$ID|g" \ + -e "s|__AGENT_OS_NAMESPACE__|$NAMESPACE|g" \ + -e "s|__AGENT_OS_IMAGE__|$IMAGE|g" \ + -e "s|__AGENT_OS_IMAGE_PULL_POLICY__|$IMAGE_PULL_POLICY|g" \ + -e "s|__AGENT_OS_AI_SECRET__|$AI_SECRET|g" \ + -e "s|__AGENT_OS_OPERATION_ID__|$OPERATION_ID|g" \ + "$TEMPLATE" +} + +render_pvc() { + render_resources | awk '/^---$/ { exit } { print }' +} + +render_pod() { + render_resources | awk 'found { print } /^---$/ { found=1 }' +} + +render_lock() { + local acquired_at=$1 renewed_at=$2 uid=${3:-} rv=${4:-} uid_value='' rv_value='' + [ -z "$uid" ] || uid_value=$(yaml_string "$uid") + [ -z "$rv" ] || rv_value=$(yaml_string "$rv") + if [ "$LOCK_SCOPE" != crewmate ]; then + cat <&2; return 3; } ;; + pvc) record=$(pvc_record "$deadline") || { echo "error: pvc/$name observation unavailable before bounded deletion captured uid=${expected_uid:-unknown}" >&2; return 3; } ;; + *) return 2 ;; + esac + if [ -z "$record" ]; then + DELETE_OUTCOME=absent + DELETE_OBSERVED_UID=absent + echo "confirmed absent: $kind/$name captured uid=${expected_uid:-absent}" >&2 + return 0 + fi + identity=$(printf '%s' "$record" | cut -f1-4) + if { [ "$kind" = pod ] && [ "$identity" != "$EXPECTED_POD" ]; } || \ + { [ "$kind" = pvc ] && [ "$identity" != "$EXPECTED_PVC" ]; }; then + echo "error: $kind/$name ownership changed before deletion; retained" >&2 + return 2 + fi + if [ "$kind" = pod ]; then + [ -z "$expected_operation" ] || [ "$(printf '%s' "$record" | cut -f5)" = "$expected_operation" ] || { + echo "error: pod/$name replacement or ownership mismatch retained; operation identity changed before deletion" >&2 + return 3 + } + uid=$(printf '%s' "$record" | cut -f6) + rv=$(printf '%s' "$record" | cut -f7) + path="/api/v1/namespaces/$NAMESPACE/pods/$name" + else + uid=$(printf '%s' "$record" | cut -f9) + rv=$(printf '%s' "$record" | cut -f10) + path="/api/v1/namespaces/$NAMESPACE/persistentvolumeclaims/$name" + fi + DELETE_CAPTURED_UID=$uid + [ -n "$uid" ] && [ -n "$rv" ] || { echo "error: $kind/$name lacks deletion preconditions" >&2; return 2; } + if [ -n "$expected_uid" ] && [ "$uid" != "$expected_uid" ]; then + DELETE_OUTCOME=replacement-retained + DELETE_CAPTURED_UID=$expected_uid + DELETE_OBSERVED_UID=$uid + echo "error: $kind/$name replacement uid=$uid retained; captured uid=$expected_uid" >&2 + return 3 + fi + [ -z "$expected_rv" ] || [ "$rv" = "$expected_rv" ] || { + DELETE_OUTCOME=original-retained + DELETE_OBSERVED_UID=$uid + echo "error: $kind/$name resourceVersion changed before deletion; retained" >&2 + return 3 + } + request_seconds=$(resource_mutation_seconds "$deadline") || { + echo "error: $kind/$name deletion deadline left no reconciliation reserve; retained uid=$uid" >&2 + return 3 + } + if printf '{"apiVersion":"v1","kind":"DeleteOptions","preconditions":{"uid":"%s","resourceVersion":"%s"}}\n' "$uid" "$rv" | \ + kube --request-timeout="${request_seconds}s" delete --raw "$path" -f - >/dev/null; then + delete_ok=1 + fi + if [ "$delete_ok" -eq 1 ]; then + wait_seconds=$(resource_wait_seconds "$deadline") || wait_seconds=0 + if [ "$wait_seconds" -gt 0 ]; then + kube --request-timeout="${wait_seconds}s" wait --for=delete "$kind/$name" --timeout="${wait_seconds}s" >/dev/null 2>&1 || true + fi + fi + case "$kind" in + pod) current=$(pod_record "$deadline") || { echo "error: pod/$name deletion result unavailable; captured uid=$uid retained-state=unknown" >&2; return 3; } ;; + pvc) current=$(pvc_record "$deadline") || { echo "error: pvc/$name deletion result unavailable; captured uid=$uid retained-state=unknown" >&2; return 3; } ;; + esac + if [ -z "$current" ]; then + DELETE_OUTCOME=absent + DELETE_OBSERVED_UID=absent + echo "confirmed absent: $kind/$name captured uid=$uid" >&2 + return 0 + fi + if [ "$kind" = pod ]; then + current_uid=$(printf '%s' "$current" | cut -f6) + else + current_uid=$(printf '%s' "$current" | cut -f9) + fi + DELETE_OBSERVED_UID=$current_uid + if [ "$current_uid" != "$uid" ]; then + DELETE_OUTCOME=replacement-retained + echo "error: $kind/$name replacement uid=$current_uid retained; captured uid=$uid" >&2 + return 3 + fi + DELETE_OUTCOME=original-retained + echo "error: $kind/$name original uid=$uid remains after bounded deletion; retained" >&2 + return 3 +} + +cleanup_new_owned_pod() { + local expected_uid=${1:-} + echo "partial state: reconciling newly created owned Pod '$POD' uid=${expected_uid:-observed}; persistent home retained" >&2 + delete_owned_crewmate_resource pod "$POD" "$expected_uid" '' "$OPERATION_ID" || true +} + +create_and_wait() { + local pvc_before pvc_current pvc_identity pvc_uid pvc_current_uid pvc_rv pod pod_current pod_uid pod_rv pod_claim + pvc_before=$(require_owned_pvc_or_absent) + if [ -z "$pvc_before" ]; then + if ! render_pvc | kube create -f - >/dev/null; then + echo "error: create-only PVC operation conflicted; no resource was adopted" >&2 + exit 2 + fi + pvc_before=$(require_owned_pvc_or_absent) + fi + pvc_current=$(require_owned_pvc_or_absent) + if [ -z "$pvc_before" ] || [ -z "$pvc_current" ]; then + echo "error: PVC identity disappeared before Pod creation" >&2 + exit 2 + fi + pvc_uid=$(printf '%s' "$pvc_before" | cut -f9) + pvc_rv=$(printf '%s' "$pvc_before" | cut -f10) + if [ -z "$pvc_uid" ] || [ -z "$pvc_rv" ]; then + echo "error: Pod creation requires exact PVC UID and resourceVersion evidence" >&2 + exit 2 + fi + if [ "$(printf '%s' "$pvc_current" | cut -f9)" != "$pvc_uid" ]; then + echo "error: PVC UID changed before Pod creation" >&2 + exit 2 + fi + invalidate_checkpoint_evidence + pvc_current=$(require_owned_pvc_or_absent) + if [ -z "$pvc_current" ] || [ "$(printf '%s' "$pvc_current" | cut -f9)" != "$pvc_uid" ]; then + echo "error: PVC UID changed while activating the writer" >&2 + exit 2 + fi + if ! render_pod | kube create -f - >/dev/null; then + cleanup_new_owned_pod + echo "error: create-only Pod operation conflicted or was only partially acknowledged" >&2 + exit 1 + fi + pod=$(pod_record) + if [ "$(printf '%s' "$pod" | cut -f1-4)" != "$EXPECTED_POD" ] || \ + [ "$(printf '%s' "$pod" | cut -f5)" != "$OPERATION_ID" ]; then + echo "error: created Pod did not retain the exact operation identity; no cleanup attempted" >&2 + exit 1 + fi + pod_uid=$(printf '%s' "$pod" | cut -f6) + pod_rv=$(printf '%s' "$pod" | cut -f7) + pod_claim=$(printf '%s' "$pod" | cut -f8) + if [ -z "$pod_uid" ] || [ -z "$pod_rv" ] || [ "$pod_claim" != "$PVC" ]; then + echo "error: created Pod lacks exact UID, resourceVersion, or PVC relationship evidence" >&2 + exit 1 + fi + pvc_current=$(require_owned_pvc_or_absent) + if [ -z "$pvc_current" ] || [ "$(printf '%s' "$pvc_current" | cut -f9)" != "$pvc_uid" ]; then + cleanup_new_owned_pod "$pod_uid" + echo "error: PVC UID changed after Pod creation; replacement claim retained" >&2 + exit 3 + fi + if ! kube wait --for=condition=Ready "pod/$POD" --timeout=180s; then + cleanup_new_owned_pod "$pod_uid" + echo "error: crewmate Pod did not become ready with the authorized AI Secret" >&2 + exit 1 + fi + pod_current=$(pod_record) + if [ "$(printf '%s' "$pod_current" | cut -f1-5)" != "$EXPECTED_POD"$'\t'"$OPERATION_ID" ] || \ + [ "$(printf '%s' "$pod_current" | cut -f6)" != "$pod_uid" ] || \ + [ "$(printf '%s' "$pod_current" | cut -f8)" != "$PVC" ]; then + pvc_current=$(pvc_record) + pvc_identity=$(printf '%s' "$pvc_current" | cut -f1-4) + if [ "$pvc_identity" = "$EXPECTED_PVC" ]; then + if ! invalidate_checkpoint_evidence; then + echo "partial state: checkpoint invalidation failed after Pod continuity loss; persistent claims retained" >&2 + fi + fi + echo "error: Pod identity changed after readiness; captured uid=$pod_uid observed uid=$(printf '%s' "$pod_current" | cut -f6); replacement retained and persistent claims retained" >&2 + exit 3 + fi + pvc_current=$(pvc_record) + pvc_identity=$(printf '%s' "$pvc_current" | cut -f1-4) + pvc_current_uid=$(printf '%s' "$pvc_current" | cut -f9) + if [ "$pvc_identity" != "$EXPECTED_PVC" ] || [ "$pvc_current_uid" != "$pvc_uid" ]; then + if ! cleanup_new_owned_pod "$pod_uid"; then + echo "partial state: operation Pod cleanup did not complete; PVCs retained" >&2 + fi + if [ "$pvc_identity" = "$EXPECTED_PVC" ]; then + if ! invalidate_checkpoint_evidence; then + echo "partial state: replacement PVC checkpoint invalidation did not complete; PVCs retained" >&2 + fi + fi + echo "error: mounted PVC identity changed after readiness; captured uid=$pvc_uid observed uid=${pvc_current_uid:-absent}; persistent claims retained" >&2 + exit 3 + fi +} + +preflight_create() { + local identity + identity=$(resource_identity pod "$POD") + if [ -n "$identity" ] && [ "$identity" != "$EXPECTED_POD" ]; then + echo "error: pod '$POD' does not have the exact crewmate installation identity" >&2 + exit 2 + fi + if [ -n "$identity" ]; then + echo "error: crewmate Pod '$POD' already exists; use restart" >&2 + exit 2 + fi + require_owned_pvc_or_absent >/dev/null +} + +preflight_existing_home() { + local pvc + require_owned_or_absent pod "$POD" "$EXPECTED_POD" >/dev/null + pvc=$(require_owned_pvc_or_absent) + if [ -z "$pvc" ]; then + echo "error: crewmate persistent home '$PVC' does not exist" >&2 + exit 2 + fi + printf '%s' "$pvc" +} + +stop_owned_pod() { + delete_owned_crewmate_resource pod "$POD" +} + +invalidate_checkpoint_evidence() { + local pvc uid rv patch + pvc=$(require_owned_pvc_or_absent) + [ -n "$pvc" ] || return 0 + uid=$(printf '%s' "$pvc" | cut -f9) + rv=$(printf '%s' "$pvc" | cut -f10) + if [ -z "$uid" ] || [ -z "$rv" ]; then + echo "error: checkpoint invalidation requires exact PVC UID and resourceVersion evidence" >&2 + exit 2 + fi + patch=$(printf '{"metadata":{"uid":"%s","resourceVersion":"%s","annotations":{"agent-os.dev/checkpoint-state":"pending","agent-os.dev/writer-state":"active","agent-os.dev/checkpoint-at":null,"agent-os.dev/quiesced-operation":null,"agent-os.dev/checkpoint-operation":null}}}' "$uid" "$rv") + kube patch pvc "$PVC" --type=merge -p "$patch" >/dev/null +} + +record_quiesced_generation() { + local pvc uid rv patch + pvc=$(require_owned_pvc_or_absent) + [ -n "$pvc" ] || return 0 + if [ "$(printf '%s' "$pvc" | cut -f5)" != pending ]; then + echo "error: checkpoint evidence changed before quiesced generation could be recorded" >&2 + exit 2 + fi + uid=$(printf '%s' "$pvc" | cut -f9) + rv=$(printf '%s' "$pvc" | cut -f10) + if [ -z "$uid" ] || [ -z "$rv" ]; then + echo "error: quiesced generation requires exact PVC UID and resourceVersion evidence" >&2 + exit 2 + fi + patch=$(printf '{"metadata":{"uid":"%s","resourceVersion":"%s","annotations":{"agent-os.dev/writer-state":"quiesced","agent-os.dev/quiesced-operation":"%s"}}}' "$uid" "$rv" "$OPERATION_ID") + kube patch pvc "$PVC" --type=merge -p "$patch" >/dev/null +} + +quiesce_owned_home() { + local pod + invalidate_checkpoint_evidence + stop_owned_pod + if ! pod=$(pod_record); then + echo "error: could not prove Pod absence after deletion" >&2 + exit 3 + fi + if [ -n "$pod" ]; then + echo "error: Pod '$POD' reappeared before quiesced checkpoint generation" >&2 + exit 3 + fi + record_quiesced_generation +} + +record_purge() { + local phase=$1 checkpoint_at=$2 outcome=${3:-requested} captured_uid=${4:-unknown} observed_uid=${5:-unknown} timestamp + timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + printf '%s\t%s\tnamespace=%s\tcrewmate=%s\tpod=%s\tpvc=%s\tcheckpoint-at=%s\toutcome=%s\tcaptured-uid=%s\tobserved-uid=%s\n' \ + "$timestamp" "$phase" "$NAMESPACE" "$ID" "$POD" "$PVC" "$checkpoint_at" "$outcome" "$captured_uid" "$observed_uid" >&3 +} + +case "$COMMAND" in + create) + [ -z "$CONFIRM" ] || { echo "usage: $0 create " >&2; exit 2; } + validate_ai_grant + acquire_lifecycle_locks + preflight_create + create_and_wait + ;; + status) + [ -z "$CONFIRM" ] || { echo "usage: $0 status " >&2; exit 2; } + pod=$(pod_record) + if [ -z "$pod" ]; then + echo "error: crewmate Pod '$POD' does not exist" >&2 + exit 2 + fi + if [ "$(printf '%s' "$pod" | cut -f1-4)" != "$EXPECTED_POD" ]; then + echo "error: pod '$POD' does not have the exact crewmate installation identity" >&2 + exit 2 + fi + require_owned_pvc_or_absent >/dev/null + kube get pod "$POD" + ;; + stop) + [ -z "$CONFIRM" ] || { echo "usage: $0 stop " >&2; exit 2; } + acquire_lifecycle_locks + require_owned_pvc_or_absent >/dev/null + quiesce_owned_home + ;; + restart) + [ -z "$CONFIRM" ] || { echo "usage: $0 restart " >&2; exit 2; } + validate_ai_grant + acquire_lifecycle_locks + preflight_existing_home >/dev/null + quiesce_owned_home + create_and_wait + ;; + purge) + echo "purge target: namespace/$NAMESPACE pod/$POD pvc/$PVC" >&2 + if [ "$CONFIRM" != --yes ]; then + echo "error: purge requires the purge-specific --yes confirmation" >&2 + exit 2 + fi + acquire_lifecycle_locks + if ! pod=$(pod_record); then + echo "error: could not prove crewmate Pod absence before purge" >&2 + exit 3 + fi + if [ -n "$pod" ]; then + if [ "$(printf '%s' "$pod" | cut -f1-4)" != "$EXPECTED_POD" ]; then + echo "error: pod '$POD' does not have the exact crewmate installation identity" >&2 + exit 2 + fi + echo "error: stop the owned crewmate Pod and prove its absence before checkpointing for purge" >&2 + exit 2 + fi + pvc=$(preflight_existing_home) + checkpoint_state=$(printf '%s' "$pvc" | cut -f5) + checkpoint_at=$(printf '%s' "$pvc" | cut -f6) + quiesced_operation=$(printf '%s' "$pvc" | cut -f7) + checkpoint_operation=$(printf '%s' "$pvc" | cut -f8) + if [[ ! "$checkpoint_at" =~ ^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z$ ]]; then + echo "error: purge requires a valid non-secret checkpoint timestamp" >&2 + exit 2 + fi + if [ "$checkpoint_state" != clean ]; then + echo "error: purge requires agent-os.dev/checkpoint-state=clean on '$PVC'" >&2 + exit 2 + fi + if [ -z "$quiesced_operation" ] || [ "$checkpoint_operation" != "$quiesced_operation" ]; then + echo "error: purge requires checkpoint evidence created for the current quiesced PVC generation" >&2 + exit 2 + fi + if ! command -v jq >/dev/null 2>&1; then + echo "error: jq is required to prove that the persistent home is detached" >&2 + exit 2 + fi + if ! pods_json=$(kube get pods -o json); then + echo "error: purge could not inventory Pods that may reference PVC '$PVC'" >&2 + exit 3 + fi + if ! attached=$(printf '%s' "$pods_json" | jq -r --arg claim "$PVC" \ + '[.items[]? | any(.spec.volumes[]?; .persistentVolumeClaim.claimName == $claim)] | any'); then + echo "error: purge could not validate the Pod attachment inventory" >&2 + exit 3 + fi + if [ "$attached" = true ]; then + echo "error: purge refuses PVC '$PVC' while any Pod still references it" >&2 + exit 2 + fi + if ! pod=$(pod_record); then + echo "error: could not re-prove crewmate Pod absence during purge" >&2 + exit 3 + fi + if [ -n "$pod" ]; then + echo "error: crewmate Pod '$POD' reappeared during purge validation" >&2 + exit 3 + fi + current_pvc=$(require_owned_pvc_or_absent) + if [ "$current_pvc" != "$pvc" ]; then + echo "error: PVC identity, checkpoint, UID, or resourceVersion changed during purge validation" >&2 + exit 3 + fi + pvc_uid=$(printf '%s' "$pvc" | cut -f9) + pvc_rv=$(printf '%s' "$pvc" | cut -f10) + if [ -z "$pvc_uid" ] || [ -z "$pvc_rv" ]; then + echo "error: purge requires exact PVC UID and resourceVersion evidence" >&2 + exit 2 + fi + evidence_file=${AGENT_OS_PURGE_EVIDENCE_FILE:-} + if [ -z "$evidence_file" ] && [ -n "${FM_HOME:-}" ]; then + evidence_file="$FM_HOME/data/crewmate-purge-evidence.log" + fi + if [ -z "$evidence_file" ]; then + echo "error: set AGENT_OS_PURGE_EVIDENCE_FILE or FM_HOME before purge" >&2 + exit 2 + fi + mkdir -p "$(dirname "$evidence_file")" + exec 3>>"$evidence_file" + record_purge purge-requested "$checkpoint_at" requested "$pvc_uid" pending + if delete_owned_crewmate_resource pvc "$PVC" "$pvc_uid" "$pvc_rv"; then + record_purge purge-complete "$checkpoint_at" "$DELETE_OUTCOME" "$DELETE_CAPTURED_UID" "$DELETE_OBSERVED_UID" + else + purge_status=$? + record_purge "purge-incomplete-$DELETE_OUTCOME" "$checkpoint_at" "$DELETE_OUTCOME" "$DELETE_CAPTURED_UID" "$DELETE_OBSERVED_UID" + exit "$purge_status" + fi + ;; + delete) + echo "error: delete is ambiguous; use stop to preserve the home or purge --yes" >&2 + exit 2 + ;; + *) + echo "usage: $0 create|status|stop|restart|purge|delete [--yes]" >&2 + exit 2 + ;; +esac diff --git a/bin/agent-os-init.sh b/bin/agent-os-init.sh new file mode 100755 index 000000000..6717e4812 --- /dev/null +++ b/bin/agent-os-init.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Seed PVC-backed runtime tool paths before the agent container starts. +set -eu + +PERSISTENT_ROOT=${AGENT_OS_PERSISTENT_ROOT:-/persistent-agent} +IMAGE_MANIFEST=${AGENT_OS_IMAGE_MANIFEST:-/opt/agent-os-image-usr-local.manifest} +IMAGE_MANIFEST_SIGNATURE=${AGENT_OS_IMAGE_MANIFEST_SIGNATURE:-/opt/agent-os-image-usr-local.manifest.sha256} +MANIFEST_DIR="$PERSISTENT_ROOT/.config/agent-os" +LEGACY_ROOT="$PERSISTENT_ROOT/usr-local" + +mkdir -p \ + "$PERSISTENT_ROOT/.config" \ + "$PERSISTENT_ROOT/.cache" \ + "$PERSISTENT_ROOT/.local/bin" \ + "$PERSISTENT_ROOT/.local/share" \ + "$PERSISTENT_ROOT/.pi/agent" \ + "$PERSISTENT_ROOT/.bun" \ + "$PERSISTENT_ROOT/.cargo" \ + "$MANIFEST_DIR" + +(cd "$(dirname "$IMAGE_MANIFEST")" && sha256sum -c "$(basename "$IMAGE_MANIFEST_SIGNATURE")" >/dev/null) || { + echo "error: image-owned /usr/local manifest signature is invalid" >&2 + exit 2 +} + +if [ -d "$LEGACY_ROOT" ] && find "$LEGACY_ROOT" -mindepth 1 -print -quit | grep -q .; then + previous_manifest="$MANIFEST_DIR/previous-image-usr-local.manifest" + previous_signature="$MANIFEST_DIR/previous-image-usr-local.manifest.sha256" + if [ ! -f "$previous_manifest" ] || [ ! -f "$previous_signature" ] || \ + ! (cd "$MANIFEST_DIR" && sha256sum -c "$(basename "$previous_signature")" >/dev/null); then + echo "error: legacy persistent /usr/local ownership is ambiguous; migration refused" >&2 + exit 2 + fi + while IFS= read -r path; do + relative=".${path#"$LEGACY_ROOT"}" + expected=$(awk -v path="$relative" '$2 == path { print $1 }' "$previous_manifest") + [ -n "$expected" ] || { echo "error: legacy persistent /usr/local contains unowned path '$relative'" >&2; exit 2; } + actual=$(sha256sum "$path" | awk '{print $1}') + [ "$actual" = "$expected" ] || { echo "error: legacy persistent /usr/local path '$relative' changed; migration refused" >&2; exit 2; } + done < <(find "$LEGACY_ROOT" \( -type f -o -type l \) -print) + find "$LEGACY_ROOT" \( -type f -o -type l \) -delete + find "$LEGACY_ROOT" -depth -type d -empty -delete + if [ -e "$LEGACY_ROOT" ]; then + echo "error: legacy persistent /usr/local contains unsupported or unowned state" >&2 + exit 2 + fi +fi + +install -m 0600 "$IMAGE_MANIFEST" "$MANIFEST_DIR/previous-image-usr-local.manifest" +(cd "$MANIFEST_DIR" && sha256sum previous-image-usr-local.manifest > previous-image-usr-local.manifest.sha256) diff --git a/bin/agent-os-kubeconfig.sh b/bin/agent-os-kubeconfig.sh new file mode 100755 index 000000000..aeef3c2c6 --- /dev/null +++ b/bin/agent-os-kubeconfig.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Create a token-file-backed kubeconfig when this container has a ServiceAccount. +set -eu + +HOME=${HOME:-/home/agent} +service_account_dir=${AGENT_OS_SERVICE_ACCOUNT_DIR:-/var/run/secrets/kubernetes.io/serviceaccount} +kubeconfig=${AGENT_OS_KUBECONFIG_PATH:-$HOME/.kube/config} + +[ -r "$service_account_dir/token" ] || exit 0 +[ -r "$service_account_dir/ca.crt" ] || exit 0 +[ -e "$kubeconfig" ] && exit 0 + +: "${KUBERNETES_SERVICE_HOST:?KUBERNETES_SERVICE_HOST is required with a ServiceAccount token}" +mkdir -p "$(dirname "$kubeconfig")" +umask 077 +cat > "$kubeconfig" </dev/null 2>&1; then + sha256sum | awk '{print $1}' + else + shasum -a 256 | awk '{print $1}' + fi +} + +configure_control_lock() { + local digest + CONTROL_NAMESPACE=${AGENT_OS_CONTROL_NAMESPACE:-kube-system} + case "$CONTROL_NAMESPACE" in ''|*[!a-z0-9-]*|-*|*-) echo "error: invalid lifecycle control namespace" >&2; exit 2 ;; esac + [ "${#CONTROL_NAMESPACE}" -le 63 ] || { echo "error: lifecycle control namespace is too long" >&2; exit 2; } + digest=$(printf 'agent-os-installation:%s' "$NAMESPACE" | control_sha256) + CONTROL_INSTALLATION_UUID="${digest:0:8}-${digest:8:4}-5${digest:13:3}-8${digest:17:3}-${digest:20:12}" + # shellcheck disable=SC2034 # Read by scripts that source this helper. + CONTROL_LOCK="agent-os-lifecycle-${digest:0:16}" + # shellcheck disable=SC2034 # Read by scripts that source this helper. + CONTROL_LOCK_INSTALLATION_ID="agent-os-control:$CONTROL_INSTALLATION_UUID" +} diff --git a/bin/agent-os-kubernetes-lease.sh b/bin/agent-os-kubernetes-lease.sh new file mode 100644 index 000000000..005519578 --- /dev/null +++ b/bin/agent-os-kubernetes-lease.sh @@ -0,0 +1,315 @@ +#!/usr/bin/env bash + +yaml_string() { + local value=$1 + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\n'/\\n} + value=${value//$'\r'/\\r} + value=${value//$'\t'/\\t} + printf '"%s"' "$value" +} + +lock_default_deadline() { + printf '%s' "$(($(date -u '+%s') + LOCK_REQUEST_CEILING_SECONDS))" +} + +lock_request_seconds() { + local deadline=$1 now remaining validity seconds + now=$(date -u '+%s') + remaining=$((deadline - now)) + [ "$remaining" -gt 0 ] || return 1 + validity=$((LOCK_DURATION_SECONDS / 3)) + [ "$validity" -gt 0 ] || validity=1 + seconds=$LOCK_REQUEST_CEILING_SECONDS + [ "$seconds" -le "$validity" ] || seconds=$validity + [ "$seconds" -le "$remaining" ] || seconds=$remaining + [ "$seconds" -gt 0 ] || return 1 + printf '%s' "$seconds" +} + +lock_kube() { + local deadline=$1 seconds + shift + seconds=$(lock_request_seconds "$deadline") || return 124 + kube --request-timeout="${seconds}s" "$@" +} + +lock_mutation_seconds() { + local deadline=$1 now remaining validity seconds + now=$(date -u '+%s') + remaining=$((deadline - now - 1)) + [ "$remaining" -gt 0 ] || return 1 + validity=$((LOCK_DURATION_SECONDS / 3)) + [ "$validity" -gt 0 ] || validity=1 + seconds=$LOCK_REQUEST_CEILING_SECONDS + [ "$seconds" -le "$validity" ] || seconds=$validity + [ "$seconds" -le "$remaining" ] || seconds=$remaining + [ "$seconds" -gt 0 ] || return 1 + printf '%s' "$seconds" +} + +lock_kube_mutation() { + local deadline=$1 seconds + shift + seconds=$(lock_mutation_seconds "$deadline") || return 124 + kube --request-timeout="${seconds}s" "$@" +} + +rfc3339_epoch() { + local value=${1%%.*} + value=${value%Z}Z + date -j -u -f '%Y-%m-%dT%H:%M:%SZ' "$value" '+%s' 2>/dev/null || \ + date -u -d "$value" '+%s' 2>/dev/null +} + +lease_is_expired() { + local record=$1 renewed duration renewed_epoch now_epoch + renewed=$(printf '%s' "$record" | cut -f7) + duration=$(printf '%s' "$record" | cut -f8) + case "$duration" in ''|*[!0-9]*) return 1 ;; esac + renewed_epoch=$(rfc3339_epoch "$renewed") || return 1 + now_epoch=$(date -u '+%s') + [ "$now_epoch" -gt "$((renewed_epoch + duration + LOCK_CLOCK_SKEW_SECONDS))" ] +} + +verify_lock_record() { + local record=$1 identity holder uid + identity=$(printf '%s' "$record" | cut -f1-4) + holder=$(printf '%s' "$record" | cut -f5) + uid=$(printf '%s' "$record" | cut -f9) + [ "$identity" = "$EXPECTED_LOCK" ] && [ "$holder" = "$LOCK_HOLDER_ID" ] && [ -n "$uid" ] +} + +lock_record_valid_until() { + local record=$1 renewed duration renewed_epoch margin + renewed=$(printf '%s' "$record" | cut -f7) + duration=$(printf '%s' "$record" | cut -f8) + case "$duration" in ''|*[!0-9]*) return 1 ;; esac + renewed_epoch=$(rfc3339_epoch "$renewed") || return 1 + margin=$LOCK_CLOCK_SKEW_SECONDS + [ "$margin" -le "$((duration / 3))" ] || margin=$((duration / 3)) + [ "$margin" -gt 0 ] || margin=1 + printf '%s' "$((renewed_epoch + duration - margin))" +} + +renew_lock_once() { + local record acquired uid rv now current initial_deadline deadline + initial_deadline=$(lock_default_deadline) + if [ -n "${LOCK_VALID_UNTIL:-}" ] && [ "$LOCK_VALID_UNTIL" -lt "$initial_deadline" ]; then + initial_deadline=$LOCK_VALID_UNTIL + fi + record=$(lock_record "$initial_deadline") || return 1 + verify_lock_record "$record" || return 1 + acquired=$(printf '%s' "$record" | cut -f6) + uid=$(printf '%s' "$record" | cut -f9) + rv=$(printf '%s' "$record" | cut -f10) + [ -n "$acquired" ] && [ -n "$rv" ] || return 1 + deadline=$(lock_record_valid_until "$record") || return 1 + [ "$deadline" -gt "$(date -u '+%s')" ] || return 1 + now=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + render_lock "$acquired" "$now" "$uid" "$rv" | lock_kube_mutation "$deadline" replace -f - >/dev/null || true + current=$(lock_record "$deadline") || return 1 + verify_lock_record "$current" || return 1 + [ "$(printf '%s' "$current" | cut -f9)" = "$uid" ] || return 1 + [ "$(printf '%s' "$current" | cut -f10)" != "$rv" ] || return 1 + [ "$(printf '%s' "$current" | cut -f7)" = "$now" ] || return 1 + LOCK_RV=$(printf '%s' "$current" | cut -f10) + LOCK_VALID_UNTIL=$(lock_record_valid_until "$current") || return 1 +} + +start_lock_renewal() { + local parent=$$ interval=$((LOCK_DURATION_SECONDS / 3)) + ( + trap - EXIT + sleep_pid= + trap '[ -z "$sleep_pid" ] || kill "$sleep_pid" 2>/dev/null; exit 0' TERM INT + while :; do + sleep "$interval" & + sleep_pid=$! + wait "$sleep_pid" || exit 0 + sleep_pid= + renew_lock_once || { kill -TERM "$parent"; exit 1; } + done + ) & + LOCK_RENEW_PID=$! +} + +stop_lock_renewal() { + [ -n "$LOCK_RENEW_PID" ] || return 0 + kill "$LOCK_RENEW_PID" 2>/dev/null || true + wait "$LOCK_RENEW_PID" 2>/dev/null || true + LOCK_RENEW_PID= +} + +release_lock() { + local record identity holder uid rv after after_holder after_uid deadline released_holder + stop_lock_renewal + [ -n "$LOCK_UID" ] || return 0 + deadline=$(lock_default_deadline) + if ! record=$(lock_record "$deadline"); then + echo "error: lifecycle Lease '$LOCK' could not be verified for release" >&2 + return 1 + fi + [ -n "$record" ] || { LOCK_UID=; LOCK_RV=; return 0; } + identity=$(printf '%s' "$record" | cut -f1-4) + holder=$(printf '%s' "$record" | cut -f5) + uid=$(printf '%s' "$record" | cut -f9) + rv=$(printf '%s' "$record" | cut -f10) + if [ "$identity" != "$EXPECTED_LOCK" ] || [ "$holder" != "$LOCK_HOLDER_ID" ] || \ + [ "$uid" != "$LOCK_UID" ] || [ -z "$rv" ]; then + echo "error: lifecycle Lease '$LOCK' changed ownership before release; retained" >&2 + return 1 + fi + if [ "${LOCK_PERSISTENT:-0}" -eq 1 ]; then + released_holder=$LOCK_HOLDER_ID + LOCK_HOLDER_ID=released + if ! render_lock '1970-01-01T00:00:00Z' '1970-01-01T00:00:00Z' "$uid" "$rv" | \ + lock_kube_mutation "$deadline" replace -f - >/dev/null; then + LOCK_HOLDER_ID=$released_holder + echo "error: persistent lifecycle Lease '$LOCK' release CAS failed; retained" >&2 + return 1 + fi + LOCK_HOLDER_ID=$released_holder + after=$(lock_record "$deadline") || return 1 + if [ "$(printf '%s' "$after" | cut -f1-4)" != "$EXPECTED_LOCK" ] || \ + [ "$(printf '%s' "$after" | cut -f5)" != released ] || \ + [ "$(printf '%s' "$after" | cut -f9)" != "$uid" ] || \ + [ "$(printf '%s' "$after" | cut -f10)" = "$rv" ]; then + echo "error: persistent lifecycle Lease '$LOCK' release did not verify" >&2 + return 1 + fi + LOCK_UID= + LOCK_RV= + return 0 + fi + if ! printf '{"apiVersion":"v1","kind":"DeleteOptions","preconditions":{"uid":"%s","resourceVersion":"%s"}}\n' "$uid" "$rv" | \ + lock_kube_mutation "$deadline" delete --raw "/apis/coordination.k8s.io/v1/namespaces/$LOCK_NAMESPACE/leases/$LOCK" -f - >/dev/null; then + after=$(lock_record "$deadline") || { + echo "error: lifecycle Lease '$LOCK' release result is ambiguous; retained" >&2 + return 1 + } + if [ -n "$after" ] && [ "$(printf '%s' "$after" | cut -f9)" = "$uid" ] && \ + [ "$(printf '%s' "$after" | cut -f5)" = "$LOCK_HOLDER_ID" ]; then + echo "error: lifecycle Lease '$LOCK' release precondition failed; retained" >&2 + return 1 + fi + LOCK_UID= + LOCK_RV= + return 0 + fi + after=$(lock_record "$deadline") || return 1 + if [ -n "$after" ]; then + after_holder=$(printf '%s' "$after" | cut -f5) + after_uid=$(printf '%s' "$after" | cut -f9) + if [ "$after_uid" = "$uid" ] && [ "$after_holder" = "$LOCK_HOLDER_ID" ]; then + echo "error: lifecycle Lease '$LOCK' still exists after release" >&2 + return 1 + fi + fi + LOCK_UID= + LOCK_RV= +} + +acquire_lock() { + local record identity holder now deadline acquired uid rv current mutation_ok + now=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + deadline=$(($(date -u '+%s') + LOCK_ACQUIRE_SECONDS)) + holder=unknown + while :; do + if [ "$(date -u '+%s')" -ge "$deadline" ]; then + echo "error: lifecycle operation '$holder' still holds Lease '$LOCK' after ${LOCK_ACQUIRE_SECONDS}s" >&2 + exit 3 + fi + mutation_ok=0 + record= + if [ "${LOCK_PERSISTENT:-0}" -eq 1 ]; then + record=$(lock_record "$deadline") || { + echo "error: persistent lifecycle Lease '$LOCK' could not be read before acquisition" >&2 + exit 3 + } + fi + if [ -z "$record" ]; then + render_lock "$now" "$now" | lock_kube_mutation "$deadline" create -f - >/dev/null && mutation_ok=1 + fi + record=$(lock_record "$deadline") || { + echo "error: lifecycle Lease '$LOCK' create result could not be reconciled before the acquisition deadline" >&2 + exit 3 + } + identity=$(printf '%s' "$record" | cut -f1-4) + holder=$(printf '%s' "$record" | cut -f5) + if [ -z "$record" ] || [ "$identity" != "$EXPECTED_LOCK" ]; then + echo "error: lifecycle Lease '$LOCK' is absent or has foreign ownership after create conflict" >&2 + exit 2 + fi + if verify_lock_record "$record" && [ "$(printf '%s' "$record" | cut -f6)" = "$now" ] && \ + [ "$(printf '%s' "$record" | cut -f7)" = "$now" ]; then + break + fi + [ "$mutation_ok" -eq 0 ] || { + echo "error: lifecycle Lease '$LOCK' create acknowledgement did not verify exact timestamps" >&2 + exit 3 + } + if lease_is_expired "$record"; then + uid=$(printf '%s' "$record" | cut -f9) + rv=$(printf '%s' "$record" | cut -f10) + if [ -z "$uid" ] || [ -z "$rv" ]; then + echo "error: expired lifecycle Lease '$LOCK' lacks CAS identity" >&2 + exit 2 + fi + now=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + mutation_ok=0 + render_lock "$now" "$now" "$uid" "$rv" | lock_kube_mutation "$deadline" replace -f - >/dev/null && mutation_ok=1 + record=$(lock_record "$deadline") || { + echo "error: lifecycle Lease '$LOCK' takeover result could not be reconciled before the acquisition deadline" >&2 + exit 3 + } + if verify_lock_record "$record" && [ "$(printf '%s' "$record" | cut -f9)" = "$uid" ] && \ + [ "$(printf '%s' "$record" | cut -f10)" != "$rv" ] && \ + [ "$(printf '%s' "$record" | cut -f7)" = "$now" ]; then + break + fi + [ "$mutation_ok" -eq 0 ] || { + echo "error: lifecycle Lease '$LOCK' takeover acknowledgement did not verify exact renewal evidence" >&2 + exit 3 + } + fi + if [ "$(date -u '+%s')" -ge "$deadline" ]; then + echo "error: lifecycle operation '$holder' still holds Lease '$LOCK' after ${LOCK_ACQUIRE_SECONDS}s" >&2 + exit 3 + fi + sleep 1 + now=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + done + record=$(lock_record "$deadline") + if ! verify_lock_record "$record"; then + LOCK_UID= + echo "error: lifecycle Lease '$LOCK' did not verify after acquisition" >&2 + exit 2 + fi + acquired=$(printf '%s' "$record" | cut -f6) + LOCK_UID=$(printf '%s' "$record" | cut -f9) + LOCK_RV=$(printf '%s' "$record" | cut -f10) + LOCK_VALID_UNTIL=$(lock_record_valid_until "$record") || { + LOCK_UID= + echo "error: lifecycle Lease '$LOCK' lacks a valid renewal deadline" >&2 + exit 2 + } + if [ -z "$acquired" ] || [ -z "$LOCK_RV" ]; then + LOCK_UID= + echo "error: lifecycle Lease '$LOCK' lacks complete renewal evidence" >&2 + exit 2 + fi + current=$(lock_record "$deadline") + if [ "$current" != "$record" ]; then + LOCK_UID= + echo "error: lifecycle Lease '$LOCK' changed after acquisition" >&2 + exit 3 + fi + start_lock_renewal +} + +lock_renewal_failed() { + echo "error: lifecycle Lease '$LOCK' renewal failed; operation stopped" >&2 + exit 3 +} diff --git a/bin/agent-os-kubernetes.sh b/bin/agent-os-kubernetes.sh new file mode 100755 index 000000000..a8bd9909f --- /dev/null +++ b/bin/agent-os-kubernetes.sh @@ -0,0 +1,2062 @@ +#!/usr/bin/env bash +# agent-os-kubernetes.sh - render and operate the portable Agent OS package. +# Usage: bin/agent-os-kubernetes.sh install|upgrade|rollback|status|uninstall|cleanup-cluster-rbac [--yes] [--delete-namespace] +set -eu + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +COMMAND=${1:-} +shift || true +PACKAGE=${AGENT_OS_PACKAGE:-"$ROOT/tools/agent-os/packages/firstmate/package.k"} +INPUTS=${AGENT_OS_INPUTS:-"$ROOT/tools/agent-os/packages/firstmate/inputs.example.yaml"} +CONTEXT=${AGENT_OS_CONTEXT:-} +REQUESTED_NAMESPACE=${AGENT_OS_NAMESPACE:-} +NAMESPACE=${REQUESTED_NAMESPACE:-agent-os} +AKUA=${AGENT_OS_AKUA:-akua} +KUBECTL=${AGENT_OS_KUBECTL:-kubectl} +OUT=$(mktemp -d) +RENDER_INPUTS=$(mktemp) +CONFIRMED=0 +DELETE_NAMESPACE=0 +MANAGES_NAMESPACE=0 +DESIRED_RBAC=none +INSTALLATION_ID= +LEGACY_CLUSTER_BINDING_PRESENT=0 +PRESERVED_AKUA_SECRET_RECORD= +AKUA_OVERLAY_VERIFIED=0 +OPERATION_ID=${AGENT_OS_OPERATION_ID:-"$(date -u '+%Y%m%d%H%M%S')-$$-$RANDOM"} +LOCK_NONCE=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') +LOCK_HOLDER_ID="$OPERATION_ID.$LOCK_NONCE" +SERVICE_ACCOUNT_NAME="agent-os-firstmate-${LOCK_NONCE:0:12}" +LOCK= +LOCK_NAMESPACE= +EXPECTED_LOCK= +LOCK_UID= +LOCK_RV= +LOCK_RENEW_PID= +LOCK_PERSISTENT=0 +CONTROL_LOCK_UID= +CONTROL_LOCK_RV= +CONTROL_LOCK_RENEW_PID= +CONTROL_LOCK_VALID_UNTIL= +REMOVE_CONTROL_ACCESS=0 +STATEFULSET_CAS_ATTEMPTED=0 +STATEFULSET_CAS_UID= +LOCK_DURATION_SECONDS=${AGENT_OS_LOCK_DURATION_SECONDS:-300} +LOCK_CLOCK_SKEW_SECONDS=${AGENT_OS_LOCK_CLOCK_SKEW_SECONDS:-5} +LOCK_ACQUIRE_SECONDS=${AGENT_OS_LOCK_ACQUIRE_SECONDS:-30} +LOCK_REQUEST_CEILING_SECONDS=${AGENT_OS_LOCK_REQUEST_CEILING_SECONDS:-5} +RESOURCE_REQUEST_CEILING_SECONDS=${AGENT_OS_RESOURCE_REQUEST_CEILING_SECONDS:-5} + +for seconds in "$LOCK_DURATION_SECONDS" "$LOCK_CLOCK_SKEW_SECONDS" "$LOCK_ACQUIRE_SECONDS" "$LOCK_REQUEST_CEILING_SECONDS" "$RESOURCE_REQUEST_CEILING_SECONDS"; do + case "$seconds" in ''|*[!0-9]*) echo "error: lifecycle Lease timing must use whole seconds" >&2; exit 2 ;; esac +done +[ "$LOCK_DURATION_SECONDS" -ge 3 ] || { echo "error: lifecycle Lease duration must be at least 3 seconds" >&2; exit 2; } +[ "$LOCK_ACQUIRE_SECONDS" -ge 1 ] || { echo "error: lifecycle Lease acquisition must allow at least 1 second" >&2; exit 2; } +[ "$LOCK_REQUEST_CEILING_SECONDS" -ge 1 ] || { echo "error: lifecycle Lease request ceiling must be at least 1 second" >&2; exit 2; } +[ "$RESOURCE_REQUEST_CEILING_SECONDS" -ge 1 ] || { echo "error: resource request ceiling must be at least 1 second" >&2; exit 2; } + +. "$ROOT/bin/agent-os-kubernetes-control.sh" +. "$ROOT/bin/agent-os-kubernetes-lease.sh" + +cleanup() { + local status=$? + trap - EXIT + if [ "$REMOVE_CONTROL_ACCESS" -eq 1 ]; then + if delete_control_lock_access; then + REMOVE_CONTROL_ACCESS=0 + elif [ "$status" -eq 0 ]; then + status=3 + fi + fi + if ! release_primary_locks && [ "$status" -eq 0 ]; then + status=3 + fi + rm -rf "$OUT" "$RENDER_INPUTS" + exit "$status" +} +trap cleanup EXIT +trap lock_renewal_failed TERM + +usage() { + echo "usage: $0 install|upgrade|rollback|status|uninstall|cleanup-cluster-rbac [--yes] [--delete-namespace]" >&2 + exit 2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --yes) CONFIRMED=1 ;; + --delete-namespace) DELETE_NAMESPACE=1 ;; + *) usage ;; + esac + shift +done + +if [ -z "$CONTEXT" ]; then + echo "error: set AGENT_OS_CONTEXT to the Kubernetes context to operate" >&2 + exit 2 +fi +case "$OPERATION_ID" in + ''|*[!a-z0-9.-]*|[.-]*|*[-.]) echo "error: invalid operation id" >&2; exit 2 ;; +esac +[ "${#OPERATION_ID}" -le 63 ] || { echo "error: invalid operation id" >&2; exit 2; } + +render_has_kind() { + grep -R -Eq "^kind:[[:space:]]*$1$" "$OUT" +} + +rendered_statefulset() { + grep -Rl '^kind:[[:space:]]*StatefulSet$' "$OUT" || true +} + +render() { + local statefulset statefulset_count rendered_namespace + if ! command -v "$AKUA" >/dev/null 2>&1; then + echo "error: Akua renderer '$AKUA' is required for Kubernetes package operations" >&2 + exit 2 + fi + if grep -Eq '^[[:space:]]*(operationId|serviceAccountName):' "$INPUTS"; then + echo "error: operationId and serviceAccountName are reserved for the lifecycle helper" >&2 + exit 2 + fi + cp "$INPUTS" "$RENDER_INPUTS" + printf '\noperationId: %s\nserviceAccountName: %s\n' "$OPERATION_ID" "$SERVICE_ACCOUNT_NAME" >> "$RENDER_INPUTS" + "$AKUA" render --no-agent-mode --package "$PACKAGE" --inputs "$RENDER_INPUTS" --out "$OUT" + statefulset=$(rendered_statefulset) + statefulset_count=$(printf '%s\n' "$statefulset" | sed '/^$/d' | wc -l | tr -d ' ') + if [ "$statefulset_count" -ne 1 ]; then + echo "error: package must render exactly one StatefulSet" >&2 + exit 2 + fi + rendered_namespace=$(awk '$1 == "namespace:" { print $2; exit }' "$statefulset") + if [ -z "$rendered_namespace" ]; then + echo "error: rendered StatefulSet has no namespace" >&2 + exit 2 + fi + if [ -n "$REQUESTED_NAMESPACE" ] && [ "$REQUESTED_NAMESPACE" != "$rendered_namespace" ]; then + echo "error: AGENT_OS_NAMESPACE '$REQUESTED_NAMESPACE' does not match rendered namespace '$rendered_namespace'" >&2 + exit 2 + fi + NAMESPACE=$rendered_namespace + case "$NAMESPACE" in ''|*[!a-z0-9-]*|-*|*-) echo "error: rendered namespace is not a valid Kubernetes DNS label" >&2; exit 2 ;; esac + [ "${#NAMESPACE}" -le 63 ] || { echo "error: rendered namespace is too long" >&2; exit 2; } + [ "${#SERVICE_ACCOUNT_NAME}" -le 63 ] || { echo "error: generated ServiceAccount name is too long" >&2; exit 2; } + [ "${#LOCK_HOLDER_ID}" -le 255 ] || { echo "error: generated lifecycle Lease holder identity is too long" >&2; exit 2; } + [ "${#NAMESPACE}" -le 231 ] || { echo "error: namespace makes the ClusterRoleBinding name too long" >&2; exit 2; } + INSTALLATION_ID="agent-os-firstmate:$NAMESPACE" + if render_has_kind Namespace; then + MANAGES_NAMESPACE=1 + fi + if render_has_kind ClusterRoleBinding; then + DESIRED_RBAC=cluster-admin + elif render_has_kind Role; then + DESIRED_RBAC=namespace + fi +} + +namespace_name() { + "$KUBECTL" --context "$CONTEXT" get namespace "$NAMESPACE" --ignore-not-found -o name +} + +namespace_identity() { + "$KUBECTL" --context "$CONTEXT" get namespace "$NAMESPACE" \ + -o 'jsonpath={.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}' +} + +rendered_file_for_kind() { + local desired=$1 file + while IFS= read -r file; do + [ "$(rendered_resource_field "$file" kind)" = "$desired" ] && { printf '%s' "$file"; return; } + done < <(find "$OUT" -type f -name '*.yaml' -print) +} + +create_managed_namespace_if_absent() { + local file identity + [ "$MANAGES_NAMESPACE" -eq 1 ] || return 0 + [ -z "$(namespace_name)" ] || return 0 + file=$(rendered_file_for_kind Namespace) + [ -n "$file" ] || { echo "error: managed namespace render is missing" >&2; exit 2; } + if ! "$KUBECTL" --context "$CONTEXT" create -f "$file" >/dev/null; then + identity=$(namespace_identity 2>/dev/null || true) + if [ "$identity" != "agent-os"$'\t'"$INSTALLATION_ID" ]; then + echo "error: namespace '$NAMESPACE' create conflicted without exact installation ownership" >&2 + exit 2 + fi + fi + identity=$(namespace_identity) + if [ "$identity" != "agent-os"$'\t'"$INSTALLATION_ID" ]; then + echo "error: namespace '$NAMESPACE' did not retain exact installation ownership" >&2 + exit 2 + fi +} + +kube() { + "$KUBECTL" --context "$CONTEXT" -n "$LOCK_NAMESPACE" "$@" +} + +lock_record() { + local deadline=${1:-} + [ -n "$deadline" ] || deadline=$(lock_default_deadline) + lock_kube "$deadline" get lease "$LOCK" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.labels.agent-os\.dev/lifecycle}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.spec.holderIdentity}{"\t"}{.spec.acquireTime}{"\t"}{.spec.renewTime}{"\t"}{.spec.leaseDurationSeconds}{"\t"}{.metadata.uid}{"\t"}{.metadata.resourceVersion}' +} + +render_lock() { + local acquired_at=$1 renewed_at=$2 uid=${3:-} rv=${4:-} uid_value='' rv_value='' + [ -z "$uid" ] || uid_value=$(yaml_string "$uid") + [ -z "$rv" ] || rv_value=$(yaml_string "$rv") + cat <&2 + exit 2 + fi + return + fi + if [ -z "$name" ]; then + echo "error: createNamespace=false requires the pre-existing namespace '$NAMESPACE'" >&2 + exit 2 + fi + identity=$(namespace_identity) + if [ "$identity" != $'\t' ]; then + echo "error: createNamespace=false requires an unowned namespace; '$NAMESPACE' carries ownership metadata" >&2 + exit 2 + fi +} + +workload_state() { + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" get statefulset agent-os-firstmate \ + --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.annotations.agent-os\.dev/rbac-mode}{"\t"}{.metadata.annotations.agent-os\.dev/cluster-rbac-cleanup}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}' +} + +require_workload_owned() { + local state=$1 required=${2:-required} identity + if [ -z "$state" ]; then + if [ "$required" = required ]; then + echo "error: agent-os-firstmate does not exist" >&2 + exit 2 + fi + return + fi + identity=$(printf '%s' "$state" | cut -f1,4,5) + if [ "$identity" != "agent-os-firstmate"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" ]; then + echo "error: StatefulSet 'agent-os-firstmate' does not have the exact installation identity" >&2 + exit 2 + fi +} + +workload_service_account() { + local state account identity + state=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get statefulset agent-os-firstmate -o json) + identity=$(printf '%s' "$state" | jq -er --arg installation "$INSTALLATION_ID" ' + select(.metadata.name == "agent-os-firstmate") + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation) + | .spec.template.spec.serviceAccountName') || { + echo "error: StatefulSet ServiceAccount identity is unverifiable" >&2 + exit 3 + } + case "$identity" in agent-os-firstmate|agent-os-firstmate-[a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9]) ;; *) echo "error: StatefulSet ServiceAccount identity is invalid" >&2; exit 3 ;; esac + printf '%s' "$identity" +} + +preflight_legacy_cluster_binding() { + local allow_owned=${1:-0} binding identity expected + binding="agent-os-firstmate-$NAMESPACE" + if ! identity=$("$KUBECTL" --context "$CONTEXT" get clusterrolebinding "$binding" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}'); then + echo "error: exact legacy ClusterRoleBinding preflight requires separately authorized cluster-scoped read access" >&2 + exit 2 + fi + expected="$binding"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" + if [ -n "$identity" ] && [ "$identity" != "$expected" ]; then + echo "error: ClusterRoleBinding '$binding' does not have the exact Agent OS installation identity" >&2 + exit 2 + fi + [ -z "$identity" ] || LEGACY_CLUSTER_BINDING_PRESENT=1 + if [ -n "$identity" ] && [ "$allow_owned" -ne 1 ] && [ "$DESIRED_RBAC" != cluster-admin ]; then + echo "error: stale ClusterRoleBinding '$binding' must be removed through separately authorized cleanup before installation" >&2 + exit 3 + fi +} + +verify_revision_service_account() { + local revision=$1 account identity expected record + account=$(printf '%s' "$revision" | jq -er '.data.spec.template.spec.serviceAccountName') || { + echo "error: rollback target ServiceAccount dependency is unavailable" >&2 + exit 3 + } + case "$account" in agent-os-firstmate|agent-os-firstmate-[a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9]) ;; *) echo "error: rollback target ServiceAccount dependency is invalid" >&2; exit 3 ;; esac + identity=$(live_resource_identity ServiceAccount "$account") + expected="$account"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" + [ "$identity" = "$expected" ] || { + echo "error: rollback target ServiceAccount dependency is missing or changed" >&2 + exit 3 + } + record=$(live_resource_record namespaced ServiceAccount "$account") + [ "$(printf '%s' "$record" | cut -f1-3)" = "$expected" ] && \ + [ -n "$(printf '%s' "$record" | cut -f4)" ] && [ -n "$(printf '%s' "$record" | cut -f5)" ] || { + echo "error: rollback target ServiceAccount dependency metadata is unavailable" >&2 + exit 3 + } +} + +runtime_binding_json() { + local mode=$1 + case "$mode" in + namespace) "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" get rolebinding agent-os-firstmate-runtime -o json ;; + cluster-admin) "$KUBECTL" --context "$CONTEXT" get clusterrolebinding "agent-os-firstmate-$NAMESPACE" -o json ;; + none) printf '{}' ;; + *) echo "error: rollback runtime authority mode is invalid" >&2; return 3 ;; + esac +} + +verify_runtime_role_rules() { + local json + json=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" get role agent-os-firstmate-runtime -o json) || return 3 + printf '%s' "$json" | jq -e --arg installation "$INSTALLATION_ID" ' + .metadata.name == "agent-os-firstmate-runtime" and + .metadata.labels["app.kubernetes.io/managed-by"] == "agent-os" and + .metadata.annotations["agent-os.dev/installation-id"] == $installation and + .rules == [ + {"apiGroups":[""],"resources":["pods","persistentvolumeclaims"],"verbs":["get","list","watch","create","delete","patch"]}, + {"apiGroups":[""],"resources":["pods/log","pods/exec"],"verbs":["get","list","watch","create","delete"]}, + {"apiGroups":["apps"],"resources":["statefulsets"],"verbs":["get","list","watch"]}, + {"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","create","update","delete"]} + ]' >/dev/null +} + +verify_control_role_rules() { + local json + json=$("$KUBECTL" --context "$CONTEXT" -n "$CONTROL_NAMESPACE" get role "$CONTROL_LOCK" -o json) || return 3 + printf '%s' "$json" | jq -e --arg installation "$INSTALLATION_ID" --arg name "$CONTROL_LOCK" ' + .metadata.name == $name and + .metadata.labels["app.kubernetes.io/managed-by"] == "agent-os" and + .metadata.annotations["agent-os.dev/installation-id"] == $installation and + .rules == [{apiGroups:["coordination.k8s.io"],resources:["leases"],resourceNames:[$name],verbs:["get","update"]}]' >/dev/null +} + +verify_control_authority() { + local account=$1 json + verify_control_role_rules || return 3 + json=$(control_binding_json) || return 3 + printf '%s' "$json" | jq -e --arg installation "$INSTALLATION_ID" --arg namespace "$NAMESPACE" \ + --arg name "$CONTROL_LOCK" --arg account "$account" ' + .metadata.name == $name and .metadata.labels["app.kubernetes.io/managed-by"] == "agent-os" and + .metadata.annotations["agent-os.dev/installation-id"] == $installation and + .roleRef == {apiGroup:"rbac.authorization.k8s.io",kind:"Role",name:$name} and + .subjects == [{kind:"ServiceAccount",name:$account,namespace:$namespace}]' >/dev/null +} + +verify_no_runtime_authority() { + local resource + resource=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" get rolebinding agent-os-firstmate-runtime --ignore-not-found -o name) || return 3 + [ -z "$resource" ] || return 3 + resource=$("$KUBECTL" --context "$CONTEXT" get clusterrolebinding "agent-os-firstmate-$NAMESPACE" --ignore-not-found -o name) || return 3 + [ -z "$resource" ] || return 3 + resource=$("$KUBECTL" --context "$CONTEXT" -n "$CONTROL_NAMESPACE" get rolebinding "$CONTROL_LOCK" --ignore-not-found -o name) || return 3 + [ -z "$resource" ] || return 3 + resource=$("$KUBECTL" --context "$CONTEXT" -n "$CONTROL_NAMESPACE" get role "$CONTROL_LOCK" --ignore-not-found -o name) || return 3 + [ -z "$resource" ] +} + +transfer_runtime_authority() { + local mode=$1 from=$2 to=$3 binding kind scope_args json uid rv current patch verified + if [ "$mode" = none ]; then + verify_no_runtime_authority || { echo "error: rollback none-mode authority is not absent" >&2; return 3; } + return 0 + fi + if [ "$mode" = namespace ]; then + binding=agent-os-firstmate-runtime + kind=rolebinding + scope_args=(-n "$NAMESPACE") + verify_runtime_role_rules || { echo "error: rollback runtime Role rules are unverifiable" >&2; return 3; } + verify_control_role_rules || { echo "error: rollback control Role rules are unverifiable" >&2; return 3; } + else + binding="agent-os-firstmate-$NAMESPACE" + kind=clusterrolebinding + scope_args=() + fi + json=$(runtime_binding_json "$mode") || return 3 + if [ "$mode" = namespace ]; then + printf '%s' "$json" | jq -e '.roleRef == {apiGroup:"rbac.authorization.k8s.io",kind:"Role",name:"agent-os-firstmate-runtime"}' >/dev/null || return 3 + else + printf '%s' "$json" | jq -e '.roleRef == {apiGroup:"rbac.authorization.k8s.io",kind:"ClusterRole",name:"cluster-admin"}' >/dev/null || return 3 + fi + current=$(printf '%s' "$json" | jq -er --arg installation "$INSTALLATION_ID" --arg namespace "$NAMESPACE" --arg binding "$binding" ' + select(.metadata.name == $binding) + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation) + | select(.subjects | type == "array" and length == 1) + | select(.subjects[0].kind == "ServiceAccount" and .subjects[0].namespace == $namespace) + | .subjects[0].name') || { echo "error: rollback runtime authority ownership is unverifiable" >&2; return 3; } + if [ "$current" = "$to" ]; then + if [ "$mode" = namespace ]; then + transfer_control_authority "$from" "$to" || return 3 + verify_runtime_role_rules || return 3 + verify_control_authority "$to" || return 3 + fi + return 0 + fi + [ "$current" = "$from" ] || { echo "error: rollback runtime authority is assigned to an unexpected ServiceAccount" >&2; return 3; } + uid=$(printf '%s' "$json" | jq -er '.metadata.uid') || return 3 + rv=$(printf '%s' "$json" | jq -er '.metadata.resourceVersion') || return 3 + patch=$(jq -cn --arg uid "$uid" --arg rv "$rv" --arg namespace "$NAMESPACE" --arg account "$to" \ + '{metadata:{uid:$uid,resourceVersion:$rv},subjects:[{kind:"ServiceAccount",name:$account,namespace:$namespace}]}') + "$KUBECTL" --context "$CONTEXT" "${scope_args[@]}" patch "$kind" "$binding" --type=merge -p "$patch" >/dev/null || return 3 + verified=$(runtime_binding_json "$mode") || return 3 + printf '%s' "$verified" | jq -e --arg uid "$uid" --arg namespace "$NAMESPACE" --arg account "$to" ' + .metadata.uid == $uid and .subjects == [{kind:"ServiceAccount",name:$account,namespace:$namespace}]' >/dev/null || { + echo "error: rollback runtime authority transfer did not verify exactly" >&2 + return 3 + } + if [ "$mode" = namespace ]; then + transfer_control_authority "$from" "$to" || return 3 + verify_runtime_role_rules || return 3 + verify_control_authority "$to" + fi +} + +control_binding_json() { + "$KUBECTL" --context "$CONTEXT" -n "$CONTROL_NAMESPACE" get rolebinding "$CONTROL_LOCK" -o json +} + +transfer_control_authority() { + local from=$1 to=$2 json current uid rv patch verified + verify_control_role_rules || return 3 + json=$(control_binding_json) || return 3 + printf '%s' "$json" | jq -e --arg name "$CONTROL_LOCK" \ + '.roleRef == {apiGroup:"rbac.authorization.k8s.io",kind:"Role",name:$name}' >/dev/null || return 3 + current=$(printf '%s' "$json" | jq -er --arg installation "$INSTALLATION_ID" --arg namespace "$NAMESPACE" --arg name "$CONTROL_LOCK" ' + select(.metadata.name == $name) + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation) + | select(.subjects | type == "array" and length == 1) + | select(.subjects[0].kind == "ServiceAccount" and .subjects[0].namespace == $namespace) + | .subjects[0].name') || return 3 + [ "$current" != "$to" ] || { verify_control_authority "$to"; return $?; } + [ "$current" = "$from" ] || return 3 + uid=$(printf '%s' "$json" | jq -er '.metadata.uid') || return 3 + rv=$(printf '%s' "$json" | jq -er '.metadata.resourceVersion') || return 3 + patch=$(jq -cn --arg uid "$uid" --arg rv "$rv" --arg namespace "$NAMESPACE" --arg account "$to" \ + '{metadata:{uid:$uid,resourceVersion:$rv},subjects:[{kind:"ServiceAccount",name:$account,namespace:$namespace}]}') + "$KUBECTL" --context "$CONTEXT" -n "$CONTROL_NAMESPACE" patch rolebinding "$CONTROL_LOCK" --type=merge -p "$patch" >/dev/null || return 3 + verified=$(control_binding_json) || return 3 + printf '%s' "$verified" | jq -e --arg uid "$uid" --arg namespace "$NAMESPACE" --arg account "$to" ' + .metadata.uid == $uid and .subjects == [{kind:"ServiceAccount",name:$account,namespace:$namespace}]' >/dev/null +} + +ensure_control_lock_access() { + local role binding live uid rv desired kind + [ "$DESIRED_RBAC" = namespace ] || return 0 + configure_control_lock + role=$(jq -cn --arg name "$CONTROL_LOCK" --arg namespace "$CONTROL_NAMESPACE" --arg installation "$INSTALLATION_ID" ' + {apiVersion:"rbac.authorization.k8s.io/v1",kind:"Role",metadata:{name:$name,namespace:$namespace,labels:{"app.kubernetes.io/managed-by":"agent-os"},annotations:{"agent-os.dev/installation-id":$installation}},rules:[{apiGroups:["coordination.k8s.io"],resources:["leases"],resourceNames:[$name],verbs:["get","update"]}]}') + binding=$(jq -cn --arg name "$CONTROL_LOCK" --arg controlNamespace "$CONTROL_NAMESPACE" --arg installation "$INSTALLATION_ID" --arg namespace "$NAMESPACE" --arg account "$SERVICE_ACCOUNT_NAME" ' + {apiVersion:"rbac.authorization.k8s.io/v1",kind:"RoleBinding",metadata:{name:$name,namespace:$controlNamespace,labels:{"app.kubernetes.io/managed-by":"agent-os"},annotations:{"agent-os.dev/installation-id":$installation}},roleRef:{apiGroup:"rbac.authorization.k8s.io",kind:"Role",name:$name},subjects:[{kind:"ServiceAccount",name:$account,namespace:$namespace}]}') + for desired in "$role" "$binding"; do + kind=$(printf '%s' "$desired" | jq -r '.kind') + live=$("$KUBECTL" --context "$CONTEXT" -n "$CONTROL_NAMESPACE" get "$kind" "$CONTROL_LOCK" --ignore-not-found -o json) + if [ -z "$live" ]; then + printf '%s' "$desired" | "$KUBECTL" --context "$CONTEXT" create -f - >/dev/null || return 3 + else + printf '%s' "$live" | jq -e --arg installation "$INSTALLATION_ID" --arg name "$CONTROL_LOCK" ' + .metadata.name == $name and .metadata.labels["app.kubernetes.io/managed-by"] == "agent-os" and .metadata.annotations["agent-os.dev/installation-id"] == $installation' >/dev/null || return 3 + uid=$(printf '%s' "$live" | jq -er '.metadata.uid') || return 3 + rv=$(printf '%s' "$live" | jq -er '.metadata.resourceVersion') || return 3 + desired=$(printf '%s' "$desired" | jq -c --arg uid "$uid" --arg rv "$rv" '.metadata.uid=$uid | .metadata.resourceVersion=$rv') + printf '%s' "$desired" | "$KUBECTL" --context "$CONTEXT" replace -f - >/dev/null || return 3 + fi + done + live=$(control_binding_json) || return 3 + printf '%s' "$live" | jq -e --arg namespace "$NAMESPACE" --arg account "$SERVICE_ACCOUNT_NAME" --arg name "$CONTROL_LOCK" ' + .roleRef == {apiGroup:"rbac.authorization.k8s.io",kind:"Role",name:$name} and .subjects == [{kind:"ServiceAccount",name:$account,namespace:$namespace}]' >/dev/null +} + +delete_control_lock_access() { + local kind resource plural live uid rv after + [ -n "${CONTROL_LOCK:-}" ] || configure_control_lock + for kind in RoleBinding Role; do + resource=$(printf '%s' "$kind" | tr '[:upper:]' '[:lower:]') + plural="${resource}s" + live=$("$KUBECTL" --context "$CONTEXT" -n "$CONTROL_NAMESPACE" get "$kind" "$CONTROL_LOCK" --ignore-not-found -o json) || return 3 + [ -n "$live" ] || continue + printf '%s' "$live" | jq -e --arg name "$CONTROL_LOCK" --arg installation "$INSTALLATION_ID" ' + .metadata.name == $name and .metadata.labels["app.kubernetes.io/managed-by"] == "agent-os" and .metadata.annotations["agent-os.dev/installation-id"] == $installation' >/dev/null || return 3 + uid=$(printf '%s' "$live" | jq -er '.metadata.uid') || return 3 + rv=$(printf '%s' "$live" | jq -er '.metadata.resourceVersion') || return 3 + printf '{"apiVersion":"v1","kind":"DeleteOptions","preconditions":{"uid":"%s","resourceVersion":"%s"}}\n' "$uid" "$rv" | \ + "$KUBECTL" --context "$CONTEXT" delete --raw "/apis/rbac.authorization.k8s.io/v1/namespaces/$CONTROL_NAMESPACE/$plural/$CONTROL_LOCK" -f - >/dev/null || return 3 + after=$("$KUBECTL" --context "$CONTEXT" -n "$CONTROL_NAMESPACE" get "$kind" "$CONTROL_LOCK" --ignore-not-found -o json) || return 3 + [ -z "$after" ] || return 3 + done +} + +compensate_rollback() { + local source_template=$1 current_account=$2 target_account=$3 mode=$4 state uid rv patch refs revisions name revision digest clear + transfer_runtime_authority "$mode" "$target_account" "$current_account" || return 3 + state=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get statefulset agent-os-firstmate -o json) || return 3 + uid=$(printf '%s' "$state" | jq -er --arg expected "$rollback_uid" 'select(.metadata.uid == $expected) | .metadata.uid') || return 3 + rv=$(printf '%s' "$state" | jq -er '.metadata.resourceVersion') || return 3 + patch=$(jq -cn --arg uid "$uid" --arg rv "$rv" --argjson template "$source_template" \ + '{metadata:{uid:$uid,resourceVersion:$rv},spec:{template:$template}}') + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" patch statefulset agent-os-firstmate --type=strategic -p "$patch" >/dev/null || return 3 + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" rollout status statefulset/agent-os-firstmate --timeout=180s || return 3 + [ "$(workload_service_account)" = "$current_account" ] || return 3 + state=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" \ + get statefulset agent-os-firstmate -o json) || return 3 + refs=$(printf '%s' "$state" | jq -er --arg uid "$rollback_uid" \ + 'select(.metadata.uid == $uid) | [.status.currentRevision,.status.updateRevision,.metadata.resourceVersion] | @tsv') || return 3 + revisions=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" \ + get controllerrevisions.apps -o json) || return 3 + for name in "$(printf '%s' "$refs" | cut -f1)" "$(printf '%s' "$refs" | cut -f2)"; do + revision=$(printf '%s' "$revisions" | jq -ce --arg name "$name" --arg uid "$rollback_uid" ' + [.items[] | select(.metadata.name == $name) | select(any(.metadata.ownerReferences[]?; + .controller == true and .kind == "StatefulSet" and .name == "agent-os-firstmate" and .uid == $uid))] + | select(length == 1) | .[0]') || return 3 + digest=$(printf '%s' "$revision" | jq -cS '.data.spec.template' | template_digest) + [ "$digest" = "$rollback_source_digest" ] || return 3 + done + transfer_runtime_authority "$mode" "$target_account" "$current_account" || return 3 + clear=$(jq -cn --arg uid "$rollback_uid" --arg rv "$(printf '%s' "$refs" | cut -f3)" \ + '{metadata:{uid:$uid,resourceVersion:$rv,annotations:{"agent-os.dev/rollback-operation":null,"agent-os.dev/rollback-target-name":null,"agent-os.dev/rollback-target-uid":null,"agent-os.dev/rollback-target-digest":null,"agent-os.dev/rollback-source-name":null,"agent-os.dev/rollback-source-uid":null,"agent-os.dev/rollback-source-digest":null}}}') + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" patch statefulset agent-os-firstmate --type=merge -p "$clear" >/dev/null +} + +wait_for_revision_history_absence() { + local workload_uid=$1 deadline revisions count + deadline=$(($(date -u '+%s') + 180)) + while :; do + revisions=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get controllerrevisions.apps -o json) || return 3 + count=$(printf '%s' "$revisions" | jq -er --arg uid "$workload_uid" \ + '[.items[] | select(any(.metadata.ownerReferences[]?; .controller == true and .kind == "StatefulSet" and .name == "agent-os-firstmate" and ($uid == "" or .uid == $uid)))] | length') || return 3 + [ "$count" -eq 0 ] && return 0 + [ "$(date -u '+%s')" -lt "$deadline" ] || { + echo "incomplete: retained ControllerRevision history still references the removed StatefulSet" >&2 + return 3 + } + sleep 1 + done +} + +inventory_revision_service_accounts() { + local workload_uid=$1 revisions accounts account identity record + revisions=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get controllerrevisions.apps -o json) || return 3 + accounts=$(printf '%s' "$revisions" | jq -er --arg uid "$workload_uid" ' + [.items[] | select(any(.metadata.ownerReferences[]?; + .controller == true and .kind == "StatefulSet" and .name == "agent-os-firstmate" and .uid == $uid)) + | .data.spec.template.spec.serviceAccountName] + | select(all(.[]; type == "string" and test("^agent-os-firstmate(-[a-z0-9]{12})?$"))) + | unique | join("\n")') || return 3 + while IFS= read -r account; do + [ -n "$account" ] || continue + identity=$(live_resource_identity ServiceAccount "$account") || return 3 + [ "$identity" = "$account"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" ] || { + echo "error: historical ServiceAccount '$account' is missing or foreign" >&2 + return 3 + } + record=$(live_resource_record namespaced ServiceAccount "$account") || return 3 + [ -n "$(printf '%s' "$record" | cut -f4)" ] && [ -n "$(printf '%s' "$record" | cut -f5)" ] || return 3 + done <<< "$accounts" + printf '%s' "$accounts" +} + +inventory_owned_service_accounts() { + local resources accounts + resources=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get serviceaccounts -o json) || return 3 + accounts=$(printf '%s' "$resources" | jq -er --arg installation "$INSTALLATION_ID" ' + [.items[] + | select(.metadata.name | test("^agent-os-firstmate(-[a-z0-9]{12})?$")) + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation)] as $owned + | select(all($owned[]; (.metadata.uid | type == "string" and length > 0) and + (.metadata.resourceVersion | type == "string" and length > 0))) + | [$owned[].metadata.name] | unique | join("\n")') || return 3 + printf '%s' "$accounts" +} + +require_no_active_rollback_checkpoint() { + local state checkpoint + if ! command -v jq >/dev/null 2>&1; then + echo "error: jq is required to verify rollback checkpoint state before upgrade" >&2 + exit 2 + fi + state=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get statefulset agent-os-firstmate -o json) + checkpoint=$(printf '%s' "$state" | jq -er --arg installation "$INSTALLATION_ID" ' + select(.metadata.name == "agent-os-firstmate") + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation) + | [(.metadata.annotations["agent-os.dev/rollback-operation"] // ""), + (.metadata.annotations["agent-os.dev/rollback-target-name"] // ""), + (.metadata.annotations["agent-os.dev/rollback-target-uid"] // ""), + (.metadata.annotations["agent-os.dev/rollback-target-digest"] // ""), + (.metadata.annotations["agent-os.dev/rollback-source-name"] // ""), + (.metadata.annotations["agent-os.dev/rollback-source-uid"] // ""), + (.metadata.annotations["agent-os.dev/rollback-source-digest"] // "")] + | @tsv') || { echo "error: StatefulSet rollback checkpoint state is unverifiable" >&2; exit 3; } + if [ -n "${checkpoint//$'\t'/}" ]; then + echo "error: active rollback checkpoint blocks upgrade; resume rollback until exact recovery and checkpoint finalization" >&2 + exit 3 + fi +} + +verified_akua_overlay_secret() { + local expected_supplied=0 expected_record='' expected_uid=${2:-} state secret record current + if [ "$#" -gt 0 ]; then + expected_supplied=1 + expected_record=$1 + fi + if ! command -v jq >/dev/null 2>&1; then + echo "error: jq is required to verify the Akua authorization overlay" >&2 + return 2 + fi + state=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get statefulset agent-os-firstmate -o json) || return 3 + secret=$(printf '%s' "$state" | jq -er --arg installation "$INSTALLATION_ID" --arg uid "$expected_uid" ' + select(.metadata.name == "agent-os-firstmate") + | select($uid == "" or .metadata.uid == $uid) + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation) + | (.metadata.annotations["agent-os.dev/akua-auth-secret"] // "") as $declared + | ([.spec.template.spec.containers[]? | select(.name == "firstmate") | .env[]? | select(.name == "AKUA_AUTH_HEADER_FILE")] // []) as $env + | ([.spec.template.spec.containers[]? | select(.name == "firstmate") | .volumeMounts[]? | select(.name == "akua-auth")] // []) as $mount + | ([.spec.template.spec.volumes[]? | select(.name == "akua-auth")] // []) as $volume + | if ($declared == "" and ($env|length) == 0 and ($mount|length) == 0 and ($volume|length) == 0) then "" + elif ($declared | test("^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$")) and + ($env|length) == 1 and $env[0].value == "/var/run/secrets/agent-os/akua/authorization" and + ($mount|length) == 1 and $mount[0].mountPath == "/var/run/secrets/agent-os/akua" and $mount[0].readOnly == true and + ($volume|length) == 1 and $volume[0].secret.secretName == $declared and $volume[0].secret.defaultMode == 256 + then $declared else error("unverifiable Akua authorization overlay") end') || { + echo "error: Akua authorization overlay is missing, changed, or unverifiable; upgrade blocked" >&2 + return 3 + } + if [ -n "$secret" ]; then + # shellcheck disable=SC2016 # Kubernetes JSONPath expands $key and $value. + record=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get secret "$secret" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.uid}{"\t"}{.metadata.resourceVersion}{"\t"}{range $key,$value := .data}{$key}{"\n"}{end}') || return 3 + if [ "$(printf '%s' "$record" | cut -f1)" != "$secret" ] || \ + [ -z "$(printf '%s' "$record" | cut -f2)" ] || [ -z "$(printf '%s' "$record" | cut -f3)" ] || \ + [ "$(printf '%s' "$record" | cut -f4-)" != authorization ]; then + echo "error: Akua authorization Secret reference is missing or unverifiable; upgrade blocked" >&2 + return 3 + fi + current="present"$'\t'"$record" + else + current=absent + fi + if [ "$expected_supplied" -eq 1 ] && [ "$current" != "$expected_record" ]; then + echo "error: Akua authorization overlay presence or Secret identity changed during upgrade; upgrade blocked" >&2 + return 3 + fi + printf '%s' "$current" +} + +reconcile_failed_akua_upgrade() { + local expected=$1 expected_uid=${2:-$STATEFULSET_CAS_UID} state uid rv rejected patch + [ -n "$expected_uid" ] || return 3 + state=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get statefulset agent-os-firstmate -o json) || return 3 + uid=$(printf '%s' "$state" | jq -er --arg installation "$INSTALLATION_ID" --arg uid "$expected_uid" ' + select(.metadata.name == "agent-os-firstmate") + | select(.metadata.uid == $uid) + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation) + | .metadata.uid') || return 3 + rv=$(printf '%s' "$state" | jq -er '.metadata.resourceVersion') || return 3 + rejected=$(printf '%s' "$expected" | sha256_text) + patch=$(jq -cn --arg uid "$uid" --arg rv "$rv" --arg rejected "$rejected" ' + {metadata:{uid:$uid,resourceVersion:$rv,annotations:{"agent-os.dev/akua-auth-secret":null,"agent-os.dev/akua-auth-rejected-record":$rejected}}, + spec:{template:{spec:{containers:[{name:"firstmate",env:[{name:"AKUA_AUTH_HEADER_FILE","$patch":"delete"}],volumeMounts:[{mountPath:"/var/run/secrets/agent-os/akua","$patch":"delete"}]}],volumes:[{name:"akua-auth","$patch":"delete"}]}}}}') + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" patch statefulset agent-os-firstmate --type=strategic -p "$patch" >/dev/null || return 3 + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" rollout status statefulset/agent-os-firstmate --timeout=180s || return 3 + verified_akua_overlay_secret absent "$expected_uid" >/dev/null || return 3 + printf 'incomplete: upgrade authorization failed closed expected-secret-uid=%s expected-secret-rv=%s\n' \ + "$(printf '%s' "$expected" | cut -f3)" "$(printf '%s' "$expected" | cut -f4)" >&2 +} + +rendered_resource_field() { + local file=$1 field=$2 + awk -v field="$field" '$1 == field ":" { print $2; exit }' "$file" +} + +live_resource_identity() { + local kind=$1 name=$2 + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" get "$kind" "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}' +} + +live_resource_record() { + local scope=$1 kind=$2 name=$3 request_timeout=${4:-} + local request_args=() + [ -z "$request_timeout" ] || request_args=(--request-timeout="$request_timeout") + if [ "$scope" = cluster ]; then + "$KUBECTL" --context "$CONTEXT" "${request_args[@]}" get "$kind" "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.metadata.uid}{"\t"}{.metadata.resourceVersion}{"\t"}{.metadata.labels.agent-os\.dev/operation-id}' + else + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" "${request_args[@]}" get "$kind" "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.metadata.uid}{"\t"}{.metadata.resourceVersion}{"\t"}{.metadata.labels.agent-os\.dev/operation-id}' + fi +} + +operation_remaining_seconds() { + local deadline=$1 remaining + remaining=$((deadline - $(date -u '+%s'))) + [ "$remaining" -gt 0 ] || return 1 + printf '%s' "$remaining" +} + +operation_request_seconds() { + local deadline=$1 remaining seconds + remaining=$(operation_remaining_seconds "$deadline") || return 1 + seconds=$RESOURCE_REQUEST_CEILING_SECONDS + [ "$seconds" -le "$remaining" ] || seconds=$remaining + [ "$seconds" -gt 0 ] || return 1 + printf '%s' "$seconds" +} + +reconcile_deleted_resource() { + local scope=$1 kind=$2 name=$3 uid=$4 rv=$5 timeout=$6 started=$7 phase=$8 class=$9 + local deadline=${10} request_seconds current current_uid current_rv elapsed + request_seconds=$(operation_request_seconds "$deadline") || { + elapsed=$(($(date -u '+%s') - started)) + bounded_delete_failure "$kind/$name" "$phase" "$class" "$timeout" "$elapsed" "$uid" "$deadline" + return $? + } + if ! current=$(live_resource_record "$scope" "$kind" "$name" "${request_seconds}s"); then + elapsed=$(($(date -u '+%s') - started)) + bounded_delete_failure "$kind/$name" reconcile transport "$timeout" "$elapsed" "$uid" "$deadline" + return $? + fi + if [ -z "$current" ]; then + echo "confirmed absent: $kind/$name captured uid=$uid after delete-$phase failure=$class" >&2 + return 0 + fi + current_uid=$(printf '%s' "$current" | cut -f4) + current_rv=$(printf '%s' "$current" | cut -f5) + elapsed=$(($(date -u '+%s') - started)) + if [ "$current_uid" != "$uid" ]; then + echo "error: $kind/$name replacement uid=${current_uid:-unknown} retained after ambiguous delete of captured uid=$uid resourceVersion=$rv" >&2 + bounded_delete_failure "$kind/$name" reconcile replacement "$timeout" "$elapsed" "$uid" "$deadline" + return $? + fi + echo "error: $kind/$name captured uid=$uid remains at resourceVersion=${current_rv:-unknown} after delete-$phase failure=$class" >&2 + bounded_delete_failure "$kind/$name" "$phase" "$class" "$timeout" "$elapsed" "$uid" "$deadline" +} + +resource_api_path() { + local scope=$1 kind=$2 name=$3 + case "$kind" in + Namespace) printf '/api/v1/namespaces/%s' "$name" ;; + Pod) printf '/api/v1/namespaces/%s/pods/%s' "$NAMESPACE" "$name" ;; + PersistentVolumeClaim) printf '/api/v1/namespaces/%s/persistentvolumeclaims/%s' "$NAMESPACE" "$name" ;; + Service) printf '/api/v1/namespaces/%s/services/%s' "$NAMESPACE" "$name" ;; + ServiceAccount) printf '/api/v1/namespaces/%s/serviceaccounts/%s' "$NAMESPACE" "$name" ;; + StatefulSet) printf '/apis/apps/v1/namespaces/%s/statefulsets/%s' "$NAMESPACE" "$name" ;; + Role) printf '/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s' "$NAMESPACE" "$name" ;; + RoleBinding) printf '/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s' "$NAMESPACE" "$name" ;; + ClusterRoleBinding) printf '/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/%s' "$name" ;; + *) echo "error: unsupported atomic deletion target $scope $kind/$name" >&2; return 2 ;; + esac +} + +delete_owned_resource() { + local scope=$1 kind=$2 name=$3 timeout=$4 record expected uid rv path output started deadline class request_seconds remaining reserve wait_budget + started=$(date -u '+%s') + deadline=$((started + timeout)) + request_seconds=$(operation_request_seconds "$deadline") || { + bounded_delete_failure "$kind/$name" observe timeout "$timeout" 0 unknown "$deadline" + return $? + } + if ! record=$(live_resource_record "$scope" "$kind" "$name" "${request_seconds}s"); then + bounded_delete_failure "$kind/$name" observe transport "$timeout" 0 unknown "$deadline" + return $? + fi + [ -n "$record" ] || return 0 + expected="$name"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" + if [ "$(printf '%s' "$record" | cut -f1-3)" != "$expected" ]; then + echo "error: $kind '$name' changed ownership before deletion" >&2 + exit 2 + fi + uid=$(printf '%s' "$record" | cut -f4) + rv=$(printf '%s' "$record" | cut -f5) + [ -n "$uid" ] && [ -n "$rv" ] || { echo "error: $kind '$name' lacks deletion preconditions" >&2; exit 2; } + path=$(resource_api_path "$scope" "$kind" "$name") + request_seconds=$(operation_request_seconds "$deadline") || { + bounded_delete_failure "$kind/$name" request timeout "$timeout" 0 "$uid" "$deadline" + return $? + } + if ! output=$(printf '{"apiVersion":"v1","kind":"DeleteOptions","preconditions":{"uid":"%s","resourceVersion":"%s"}}\n' "$uid" "$rv" | \ + "$KUBECTL" --context "$CONTEXT" --request-timeout="${request_seconds}s" delete --raw "$path" -f - 2>&1 >/dev/null); then + class=$(delete_failure_class "$output") + reconcile_deleted_resource "$scope" "$kind" "$name" "$uid" "$rv" "$timeout" "$started" request "$class" "$deadline" + return $? + fi + remaining=$(operation_remaining_seconds "$deadline") || remaining=0 + if [ "$remaining" -lt 2 ]; then + reconcile_deleted_resource "$scope" "$kind" "$name" "$uid" "$rv" "$timeout" "$started" wait timeout "$deadline" + return $? + fi + reserve=$RESOURCE_REQUEST_CEILING_SECONDS + [ "$reserve" -lt "$remaining" ] || reserve=$((remaining - 1)) + wait_budget=$((remaining - reserve)) + if [ "$scope" = cluster ]; then + if ! output=$("$KUBECTL" --context "$CONTEXT" --request-timeout="${wait_budget}s" wait --for=delete "$kind/$name" --timeout="${wait_budget}s" 2>&1 >/dev/null); then + class=$(delete_failure_class "$output") + reconcile_deleted_resource "$scope" "$kind" "$name" "$uid" "$rv" "$timeout" "$started" wait "$class" "$deadline" + fi + else + if ! output=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" --request-timeout="${wait_budget}s" wait --for=delete "$kind/$name" --timeout="${wait_budget}s" 2>&1 >/dev/null); then + class=$(delete_failure_class "$output") + reconcile_deleted_resource "$scope" "$kind" "$name" "$uid" "$rv" "$timeout" "$started" wait "$class" "$deadline" + fi + fi +} + +patch_workload_annotations() { + local annotations=$1 record expected uid rv patch current + record=$(live_resource_record namespaced StatefulSet agent-os-firstmate) + expected="agent-os-firstmate"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" + if [ "$(printf '%s' "$record" | cut -f1-3)" != "$expected" ]; then + echo "error: StatefulSet ownership changed before annotation mutation" >&2 + exit 2 + fi + uid=$(printf '%s' "$record" | cut -f4) + rv=$(printf '%s' "$record" | cut -f5) + [ -n "$uid" ] && [ -n "$rv" ] || { echo "error: StatefulSet lacks annotation CAS identity" >&2; exit 2; } + patch=$(printf '{"metadata":{"uid":"%s","resourceVersion":"%s","annotations":%s}}' "$uid" "$rv" "$annotations") + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" patch StatefulSet agent-os-firstmate --type=merge -p "$patch" >/dev/null + current=$(live_resource_record namespaced StatefulSet agent-os-firstmate) + if [ "$(printf '%s' "$current" | cut -f1-4)" != "$(printf '%s' "$record" | cut -f1-4)" ]; then + echo "error: StatefulSet UID or ownership changed during annotation mutation" >&2 + exit 3 + fi +} + +require_namespaced_resource_owned_or_absent() { + local kind=$1 name=$2 identity expected + identity=$(live_resource_identity "$kind" "$name") + expected="$name"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" + if [ -n "$identity" ] && [ "$identity" != "$expected" ]; then + echo "error: $kind '$name' does not have the exact Agent OS installation identity" >&2 + exit 2 + fi +} + +preflight_rendered_resources() { + local include_cluster=${1:-0} file kind name identity binding + while IFS= read -r file; do + [ -n "$file" ] || continue + kind=$(rendered_resource_field "$file" kind) + name=$(rendered_resource_field "$file" name) + case "$kind" in + Namespace) ;; + ClusterRoleBinding) + [ "$include_cluster" -eq 1 ] || continue + identity=$("$KUBECTL" --context "$CONTEXT" get clusterrolebinding "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}') + binding="$name"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" + if [ -n "$identity" ] && [ "$identity" != "$binding" ]; then + echo "error: ClusterRoleBinding '$name' does not have the exact Agent OS installation identity" >&2 + exit 2 + fi + ;; + *) require_namespaced_resource_owned_or_absent "$kind" "$name" ;; + esac + done < <(find "$OUT" -type f -name '*.yaml' -print) + require_namespaced_resource_owned_or_absent Role agent-os-firstmate-runtime + require_namespaced_resource_owned_or_absent RoleBinding agent-os-firstmate-runtime +} + +delete_namespace_rbac() { + delete_owned_resource namespaced RoleBinding agent-os-firstmate-runtime 180 + delete_owned_resource namespaced Role agent-os-firstmate-runtime 180 +} + +resource_observation() { + local scope=$1 kind=$2 name=$3 request_timeout=${4:-} request_args=() + [ -z "$request_timeout" ] || request_args=(--request-timeout="$request_timeout") + if [ "$kind" = StatefulSet ]; then + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" "${request_args[@]}" get "$kind" "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.metadata.uid}{"\t"}{.metadata.labels.agent-os\.dev/operation-id}{"\t"}{"\t"}{.metadata.finalizers}{"\t"}{.spec.replicas}{"\t"}{.status.currentReplicas}{"\t"}{.status.readyReplicas}{"\t"}{.status.updatedReplicas}{"\t"}{.status.availableReplicas}{"\t"}{.status.currentRevision}{"\t"}{.status.updateRevision}{"\t"}{.metadata.generation}{"\t"}{.status.observedGeneration}' + return + fi + if [ "$kind" = Pod ]; then + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" "${request_args[@]}" get "$kind" "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.metadata.uid}{"\t"}{.metadata.labels.agent-os\.dev/operation-id}{"\t"}{range .status.conditions[?(@.type=="Ready")]}{.status}{end}{"\t"}{.metadata.finalizers}{"\t"}{range .status.containerStatuses[*]}{.name}{"="}{.state.waiting.reason}{.state.terminated.reason}{","}{end}' + return + fi + if [ "$scope" = cluster ]; then + "$KUBECTL" --context "$CONTEXT" "${request_args[@]}" get "$kind" "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.metadata.uid}{"\t"}{.metadata.labels.agent-os\.dev/operation-id}{"\t"}{range .status.conditions[?(@.type=="Ready")]}{.status}{end}{"\t"}{.metadata.finalizers}' + else + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" "${request_args[@]}" get "$kind" "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}{"\t"}{.metadata.uid}{"\t"}{.metadata.labels.agent-os\.dev/operation-id}{"\t"}{range .status.conditions[?(@.type=="Ready")]}{.status}{end}{"\t"}{.metadata.finalizers}' + fi +} + +lifecycle_command() { + local action=${1:-$COMMAND} + printf 'AGENT_OS_CONTEXT=%q AGENT_OS_NAMESPACE=%q AGENT_OS_CONTROL_NAMESPACE=%q AGENT_OS_PACKAGE=%q AGENT_OS_INPUTS=%q AGENT_OS_AKUA=%q AGENT_OS_KUBECTL=%q %q %q' \ + "$CONTEXT" "$NAMESPACE" "${CONTROL_NAMESPACE:-${AGENT_OS_CONTROL_NAMESPACE:-kube-system}}" "$PACKAGE" "$INPUTS" "$AKUA" "$KUBECTL" "$0" "$action" +} + +report_partial_observation() { + local kind=$1 name=$2 scope=$3 record managed installation uid operation ready finalizers prefix details + if ! record=$(resource_observation "$scope" "$kind" "$name"); then + echo "partial apply: $kind/$name observation=unavailable expected-operation=$OPERATION_ID" >&2 + return + fi + if [ -z "$record" ]; then + echo "partial apply: $kind/$name observed=absent expected-operation=$OPERATION_ID" >&2 + return + fi + managed=$(printf '%s' "$record" | cut -f2) + installation=$(printf '%s' "$record" | cut -f3) + uid=$(printf '%s' "$record" | cut -f4) + operation=$(printf '%s' "$record" | cut -f5) + ready=$(printf '%s' "$record" | cut -f6) + finalizers=$(printf '%s' "$record" | cut -f7) + [ -n "$ready" ] || ready=unknown + [ -n "$finalizers" ] || finalizers='[]' + prefix='partial apply' + [ "$kind" != ClusterRoleBinding ] || prefix='residual-authority' + details="ready=$ready" + if [ "$kind" = StatefulSet ]; then + details="desired=$(printf '%s' "$record" | cut -f8) current=$(printf '%s' "$record" | cut -f9) ready=$(printf '%s' "$record" | cut -f10) updated=$(printf '%s' "$record" | cut -f11) available=$(printf '%s' "$record" | cut -f12) current-revision=$(printf '%s' "$record" | cut -f13) update-revision=$(printf '%s' "$record" | cut -f14) generation=$(printf '%s' "$record" | cut -f15) observed-generation=$(printf '%s' "$record" | cut -f16)" + elif [ "$kind" = Pod ]; then + details="ready=$ready reasons=$(printf '%s' "$record" | cut -f8-)" + fi + echo "$prefix: $kind/$name uid=$uid operation=$operation $details ownership=$managed installation=$installation finalizers=$finalizers" >&2 +} + +report_rollback_failure() { + local target_name=$1 target_revision=$2 target_uid=$3 target_digest=$4 checkpoint_operation=$5 checkpoint_name=$6 checkpoint_uid=$7 + local state references lease deadline record + echo "incomplete: rollback target=$target_name revision=$target_revision target-uid=$target_uid target-digest=$target_digest checkpoint-operation=$checkpoint_operation checkpoint-target=$checkpoint_name checkpoint-uid=$checkpoint_uid did not complete" >&2 + deadline=$(lock_default_deadline) + if state=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get statefulset agent-os-firstmate -o json 2>/dev/null) && \ + references=$(printf '%s' "$state" | jq -er '[.status.currentRevision, .status.updateRevision] | select(all(.[]; type == "string" and length > 0)) | @tsv' 2>/dev/null); then + echo "rollback observed: current-revision=$(printf '%s' "$references" | cut -f1) update-revision=$(printf '%s' "$references" | cut -f2)" >&2 + else + echo "rollback observed: StatefulSet revision evidence unavailable" >&2 + fi + if ! record=$(resource_observation namespaced Pod agent-os-firstmate-0 "${RESOURCE_REQUEST_CEILING_SECONDS}s" 2>/dev/null); then + echo "partial apply: Pod/agent-os-firstmate-0 observation=unavailable expected-operation=$OPERATION_ID" >&2 + elif [ -n "$record" ]; then + echo "partial apply: Pod/agent-os-firstmate-0 uid=$(printf '%s' "$record" | cut -f4) operation=$(printf '%s' "$record" | cut -f5) ready=$(printf '%s' "$record" | cut -f6) ownership=$(printf '%s' "$record" | cut -f2) installation=$(printf '%s' "$record" | cut -f3) finalizers=$(printf '%s' "$record" | cut -f7)" >&2 + else + echo "partial apply: Pod/agent-os-firstmate-0 observed=absent expected-operation=$OPERATION_ID" >&2 + fi + if lease=$(lock_record "$deadline" 2>/dev/null) && [ -n "$lease" ]; then + echo "rollback retained: lifecycle-lease=$LOCK uid=$(printf '%s' "$lease" | cut -f9) holder=$(printf '%s' "$lease" | cut -f5)" >&2 + else + echo "rollback retained: lifecycle-lease=$LOCK evidence=unavailable" >&2 + fi + printf 'safe recovery: %q --context %q -n %q rollout status statefulset/agent-os-firstmate --timeout=180s\n' \ + "$KUBECTL" "$CONTEXT" "$NAMESPACE" >&2 + echo "safe recovery condition: rerun rollback only while the persisted target digest remains '$target_digest'" >&2 + return 3 +} + +sha256_text() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + else + shasum -a 256 | awk '{print $1}' + fi +} + +template_digest() { + jq -cS . | sha256_text +} + +owned_revision_by_digest() { + local revisions=$1 workload_uid=$2 digest=$3 preferred_uid=${4:-} item item_digest candidate='' candidate_revision=-1 item_revision item_uid + while IFS= read -r item; do + [ -n "$item" ] || continue + item_digest=$(printf '%s' "$item" | jq -cS '.data.spec.template' | template_digest) + [ "$item_digest" = "$digest" ] || continue + item_uid=$(printf '%s' "$item" | jq -r '.metadata.uid // empty') + if [ -n "$preferred_uid" ] && [ "$item_uid" = "$preferred_uid" ]; then + printf '%s' "$item" + return 0 + fi + item_revision=$(printf '%s' "$item" | jq -r '.revision // -1') + if [ "$item_revision" -gt "$candidate_revision" ] 2>/dev/null; then + candidate=$item + candidate_revision=$item_revision + fi + done < <(printf '%s' "$revisions" | jq -c --arg uid "$workload_uid" ' + .items[] + | select(any(.metadata.ownerReferences[]?; + .apiVersion == "apps/v1" and .kind == "StatefulSet" and + .name == "agent-os-firstmate" and .uid == $uid and .controller == true)) + ') + [ -n "$candidate" ] || return 1 + printf '%s' "$candidate" +} + +partial_install_is_applicable() { + local file kind name identity expected namespace + if ! namespace=$(namespace_name 2>/dev/null); then + return 1 + fi + if [ "$MANAGES_NAMESPACE" -eq 1 ]; then + if [ -n "$namespace" ]; then + if ! identity=$(namespace_identity 2>/dev/null); then + return 1 + fi + [ "$identity" = "agent-os"$'\t'"$INSTALLATION_ID" ] || return 1 + fi + else + [ -n "$namespace" ] || return 1 + if ! identity=$(namespace_identity 2>/dev/null); then + return 1 + fi + [ "$identity" = $'\t' ] || return 1 + fi + while IFS= read -r file; do + [ -n "$file" ] || continue + kind=$(rendered_resource_field "$file" kind) + name=$(rendered_resource_field "$file" name) + case "$kind" in + Namespace) continue ;; + ClusterRoleBinding) + if ! identity=$("$KUBECTL" --context "$CONTEXT" get clusterrolebinding "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}' 2>/dev/null); then + return 1 + fi + ;; + *) + if ! identity=$(live_resource_identity "$kind" "$name" 2>/dev/null); then + return 1 + fi + ;; + esac + expected="$name"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" + [ -z "$identity" ] || [ "$identity" = "$expected" ] || return 1 + done < <(find "$OUT" -type f -name '*.yaml' -print) + for kind in Role RoleBinding; do + if ! identity=$(live_resource_identity "$kind" agent-os-firstmate-runtime 2>/dev/null); then + return 1 + fi + expected="agent-os-firstmate-runtime"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" + [ -z "$identity" ] || [ "$identity" = "$expected" ] || return 1 + done + return 0 +} + +partial_recovery_action() { + local state identity namespace + if ! namespace=$(namespace_name 2>/dev/null); then + return 1 + fi + state= + if [ -n "$namespace" ] && ! state=$(workload_state 2>/dev/null); then + return 1 + fi + if [ -n "$state" ]; then + identity=$(printf '%s' "$state" | cut -f1,4,5) + if [ "$identity" = "agent-os-firstmate"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" ]; then + if partial_install_is_applicable; then + printf upgrade + return + fi + fi + return 1 + fi + if partial_install_is_applicable; then + printf install + return + fi + return 1 +} + +partial_cleanup_is_applicable() { + local binding workload mode marker identity namespace + if ! binding=$(resource_observation cluster ClusterRoleBinding "agent-os-firstmate-$NAMESPACE" 2>/dev/null); then + return 1 + fi + [ -n "$binding" ] || return 1 + identity=$(printf '%s' "$binding" | cut -f2,3) + [ "$identity" = "agent-os"$'\t'"$INSTALLATION_ID" ] || return 1 + if ! namespace=$(namespace_name 2>/dev/null); then + return 1 + fi + [ -n "$namespace" ] || return 0 + if ! workload=$(workload_state 2>/dev/null); then + return 1 + fi + [ -n "$workload" ] || return 0 + identity=$(printf '%s' "$workload" | cut -f1,4,5) + [ "$identity" = "agent-os-firstmate"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" ] || return 1 + mode=$(printf '%s' "$workload" | cut -f2) + marker=$(printf '%s' "$workload" | cut -f3) + [ "$mode" != cluster-admin ] && [ "$marker" = required ] +} + +report_partial_apply() { + local phase=$1 file kind name scope recovery + echo "incomplete: primary $phase failed; automatic cleanup withheld pending exact recovery" >&2 + while IFS= read -r file; do + [ -n "$file" ] || continue + kind=$(rendered_resource_field "$file" kind) + name=$(rendered_resource_field "$file" name) + scope=namespaced + case "$kind" in + ClusterRoleBinding) continue ;; + Namespace) scope=cluster ;; + esac + report_partial_observation "$kind" "$name" "$scope" + done < <(find "$OUT" -type f -name '*.yaml' -print) + report_partial_observation Pod agent-os-firstmate-0 namespaced + report_partial_observation ClusterRoleBinding "agent-os-firstmate-$NAMESPACE" cluster + if recovery=$(partial_recovery_action); then + echo "safe recovery: $(lifecycle_command "$recovery")" >&2 + else + echo "safe recovery unavailable: workload ownership changed; inspect retained resources" >&2 + fi + if partial_cleanup_is_applicable; then + echo "privileged cleanup with proven predicates: $(cleanup_command)" >&2 + else + echo "privileged cleanup unavailable: stale-grant predicates are not proven" >&2 + fi + return 3 +} + +cas_patch_file() { + local file=$1 uid=$2 rv=$3 patch_file uid_value rv_value + patch_file=$(mktemp "$OUT/cas.XXXXXX") + uid_value=$(yaml_string "$uid") + rv_value=$(yaml_string "$rv") + awk -v uid="$uid_value" -v rv="$rv_value" ' + !inserted && $1 == "metadata:" { + print + print " uid: " uid + print " resourceVersion: " rv + inserted=1 + next + } + { print } + ' "$file" > "$patch_file" + printf '%s' "$patch_file" +} + +mutate_rendered_resource() { + local file=$1 kind name scope record expected uid rv operation current patch_file patch_type + kind=$(rendered_resource_field "$file" kind) + name=$(rendered_resource_field "$file" name) + [ "$kind" != Namespace ] || return 0 + scope=namespaced + [ "$kind" != ClusterRoleBinding ] || scope=cluster + record=$(live_resource_record "$scope" "$kind" "$name") + expected="$name"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" + if [ -z "$record" ]; then + [ "$kind" != StatefulSet ] || STATEFULSET_CAS_ATTEMPTED=1 + if ! "$KUBECTL" --context "$CONTEXT" create -f "$file" >/dev/null; then + current=$(live_resource_record "$scope" "$kind" "$name" 2>/dev/null || true) + if [ "$(printf '%s' "$current" | cut -f1-3)" != "$expected" ] || \ + [ "$(printf '%s' "$current" | cut -f6)" != "$OPERATION_ID" ]; then + report_partial_apply create + return $? + fi + fi + else + if [ "$(printf '%s' "$record" | cut -f1-3)" != "$expected" ]; then + echo "error: $kind '$name' changed ownership before mutation" >&2 + exit 2 + fi + uid=$(printf '%s' "$record" | cut -f4) + rv=$(printf '%s' "$record" | cut -f5) + [ -n "$uid" ] && [ -n "$rv" ] || { echo "error: $kind '$name' lacks CAS identity" >&2; exit 2; } + if [ "$kind" = StatefulSet ]; then + STATEFULSET_CAS_UID=$uid + fi + if [ "$kind" = StatefulSet ] && [ "$AKUA_OVERLAY_VERIFIED" -eq 1 ]; then + verified_akua_overlay_secret "$PRESERVED_AKUA_SECRET_RECORD" "$STATEFULSET_CAS_UID" >/dev/null || { + echo "incomplete: Akua authorization changed before StatefulSet CAS; no template mutation attempted" >&2 + return 3 + } + fi + patch_file=$(cas_patch_file "$file" "$uid" "$rv") + [ "$kind" != StatefulSet ] || STATEFULSET_CAS_ATTEMPTED=1 + if [ "$scope" = cluster ]; then + if ! "$KUBECTL" --context "$CONTEXT" patch "$kind" "$name" --type=merge --patch-file "$patch_file" >/dev/null; then + report_partial_apply patch + return $? + fi + else + patch_type=merge + [ "$kind" != StatefulSet ] || patch_type=strategic + if ! "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" patch "$kind" "$name" --type="$patch_type" --patch-file "$patch_file" >/dev/null; then + report_partial_apply patch + return $? + fi + fi + fi + current=$(live_resource_record "$scope" "$kind" "$name") + operation=$(printf '%s' "$current" | cut -f6) + if [ "$(printf '%s' "$current" | cut -f1-3)" != "$expected" ] || [ -z "$(printf '%s' "$current" | cut -f4)" ] || \ + [ "$operation" != "$OPERATION_ID" ]; then + report_partial_apply verify + return $? + fi + if [ -n "$record" ] && [ "$(printf '%s' "$current" | cut -f4)" != "$(printf '%s' "$record" | cut -f4)" ]; then + echo "error: $kind '$name' UID changed during mutation" >&2 + exit 3 + fi + if [ "$kind" = StatefulSet ] && [ -z "$STATEFULSET_CAS_UID" ]; then + STATEFULSET_CAS_UID=$(printf '%s' "$current" | cut -f4) + fi +} + +apply_rendered() { + local file + while IFS= read -r file; do + [ -n "$file" ] || continue + mutate_rendered_resource "$file" || return $? + done < <(find "$OUT" -type f -name '*.yaml' -print) + if ! "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" rollout status \ + statefulset/agent-os-firstmate --timeout=180s; then + report_partial_apply rollout + fi +} + +verify_desired_rbac() { + local binding_json + if [ "$DESIRED_RBAC" = namespace ]; then + if ! command -v jq >/dev/null 2>&1; then + echo "error: jq is required to verify namespace RBAC exactly" >&2 + exit 2 + fi + binding_json=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" get rolebinding agent-os-firstmate-runtime -o json) + if ! verify_runtime_role_rules || + ! printf '%s' "$binding_json" | jq -e --arg namespace "$NAMESPACE" --arg serviceAccount "$SERVICE_ACCOUNT_NAME" ' + .roleRef == {"apiGroup":"rbac.authorization.k8s.io","kind":"Role","name":"agent-os-firstmate-runtime"} and + .subjects == [{"kind":"ServiceAccount","name":$serviceAccount,"namespace":$namespace}]' >/dev/null; then + echo "error: desired namespace RBAC did not verify after apply" >&2 + exit 2 + fi + fi +} + +cleanup_command() { + printf 'AGENT_OS_CONTEXT=%q AGENT_OS_NAMESPACE=%q AGENT_OS_CONTROL_NAMESPACE=%q AGENT_OS_PACKAGE=%q AGENT_OS_INPUTS=%q AGENT_OS_AKUA=%q AGENT_OS_KUBECTL=%q %q cleanup-cluster-rbac --yes' \ + "$CONTEXT" "$NAMESPACE" "${CONTROL_NAMESPACE:-${AGENT_OS_CONTROL_NAMESPACE:-kube-system}}" "$PACKAGE" "$INPUTS" "$AKUA" "$KUBECTL" "$0" +} + +report_cluster_cleanup() { + echo "incomplete: privileged cleanup is required: $(cleanup_command)" >&2 + echo "required evidence: clusterrolebinding/agent-os-firstmate-$NAMESPACE absent" >&2 +} + +uninstall_command() { + printf 'AGENT_OS_CONTEXT=%q AGENT_OS_NAMESPACE=%q AGENT_OS_PACKAGE=%q AGENT_OS_INPUTS=%q AGENT_OS_AKUA=%q AGENT_OS_KUBECTL=%q %q uninstall --yes' \ + "$CONTEXT" "$NAMESPACE" "$PACKAGE" "$INPUTS" "$AKUA" "$KUBECTL" "$0" + [ "$DELETE_NAMESPACE" -eq 0 ] || printf ' --delete-namespace' +} + +report_retained_observation() { + local kind=$1 name=$2 scope=$3 deadline=${4:-} record managed installation uid operation ready finalizers identity expected details request_seconds + if [ -n "$deadline" ]; then + request_seconds=$(operation_request_seconds "$deadline") || { + echo "retained-unverified: $kind/$name evidence deadline exhausted; no further deletion attempted" >&2 + return 0 + } + else + request_seconds=$RESOURCE_REQUEST_CEILING_SECONDS + fi + if ! record=$(resource_observation "$scope" "$kind" "$name" "${request_seconds}s"); then + echo "retained-unverified: $kind/$name could not be inspected; no further deletion attempted" >&2 + return 0 + fi + [ -n "$record" ] || return 0 + managed=$(printf '%s' "$record" | cut -f2) + installation=$(printf '%s' "$record" | cut -f3) + uid=$(printf '%s' "$record" | cut -f4) + operation=$(printf '%s' "$record" | cut -f5) + ready=$(printf '%s' "$record" | cut -f6) + finalizers=$(printf '%s' "$record" | cut -f7) + identity="$managed"$'\t'"$installation" + expected="agent-os"$'\t'"$INSTALLATION_ID" + [ -n "$ready" ] || ready=unknown + [ -n "$finalizers" ] || finalizers='[]' + details="ready=$ready" + if [ "$kind" = StatefulSet ]; then + details="desired=$(printf '%s' "$record" | cut -f8) current=$(printf '%s' "$record" | cut -f9) ready=$(printf '%s' "$record" | cut -f10) updated=$(printf '%s' "$record" | cut -f11) available=$(printf '%s' "$record" | cut -f12) current-revision=$(printf '%s' "$record" | cut -f13) update-revision=$(printf '%s' "$record" | cut -f14) generation=$(printf '%s' "$record" | cut -f15) observed-generation=$(printf '%s' "$record" | cut -f16)" + elif [ "$kind" = Pod ]; then + details="ready=$ready reasons=$(printf '%s' "$record" | cut -f8-)" + fi + if [ "$identity" = "$expected" ]; then + echo "retained: $kind/$name uid=$uid operation=$operation $details ownership=$managed installation=$installation finalizers=$finalizers" >&2 + else + echo "retained-unverified: $kind/$name uid=$uid operation=$operation ownership=$managed installation=$installation; no further deletion attempted" >&2 + fi +} + +report_retained_resources() { + local failed_target=$1 phase=$2 class=$3 timeout=$4 elapsed=$5 uid=$6 deadline=${7:-} file kind name scope + echo "failed-target: $failed_target uid=$uid delete-${phase}-failure=$class timeout=${timeout}s elapsed=${elapsed}s" >&2 + kind=${failed_target%%/*} + name=${failed_target#*/} + scope=namespaced + [ "$kind" != Namespace ] || scope=cluster + report_retained_observation "$kind" "$name" "$scope" "$deadline" + report_retained_observation Pod agent-os-firstmate-0 namespaced "$deadline" + report_retained_observation Role agent-os-firstmate-runtime namespaced "$deadline" + report_retained_observation RoleBinding agent-os-firstmate-runtime namespaced "$deadline" + while IFS= read -r file; do + [ -n "$file" ] || continue + kind=$(rendered_resource_field "$file" kind) + name=$(rendered_resource_field "$file" name) + case "$kind" in + ClusterRoleBinding) continue ;; + Namespace) + [ "$DELETE_NAMESPACE" -eq 1 ] || continue + scope=cluster + ;; + *) scope=namespaced ;; + esac + report_retained_observation "$kind" "$name" "$scope" "$deadline" + done < <(find "$OUT" -type f -name '*.yaml' -print) + echo "safe retry: $(uninstall_command)" >&2 + echo "cluster cleanup if required: $(cleanup_command)" >&2 +} + +delete_failure_class() { + local output + output=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') + case "$output" in + *forbidden*) printf 'Forbidden' ;; + *conflict*) printf 'Conflict' ;; + *notfound*|*'not found'*) printf 'NotFound' ;; + *'timed out'*|*timeout*) printf 'timeout' ;; + *) printf 'transport' ;; + esac +} + +bounded_delete_failure() { + local target=$1 phase=${2:-wait} class=${3:-timeout} timeout=${4:-180} + local elapsed=${5:-$timeout} uid=${6:-unknown} deadline=${7:-} prefix kind name scope + [ "$COMMAND" = uninstall ] && prefix=incomplete || prefix=error + echo "$prefix: delete-${phase}-failure=$class target=$target uid=$uid timeout=${timeout}s elapsed=${elapsed}s" >&2 + if [ "$COMMAND" = uninstall ]; then + report_retained_resources "$target" "$phase" "$class" "$timeout" "$elapsed" "$uid" "$deadline" + return 3 + fi + kind=${target%%/*} + name=${target#*/} + scope=namespaced + case "$kind" in + ClusterRoleBinding|Namespace) scope=cluster ;; + esac + report_retained_observation "$kind" "$name" "$scope" "$deadline" + if [ "$COMMAND" = cleanup-cluster-rbac ]; then + echo "safe retry: $(cleanup_command)" >&2 + fi + return 1 +} + +delete_rendered_kind() { + local desired_kind=$1 file kind name + while IFS= read -r file; do + [ -n "$file" ] || continue + kind=$(rendered_resource_field "$file" kind) + if [ "$kind" = "$desired_kind" ]; then + name=$(rendered_resource_field "$file" name) + delete_owned_resource namespaced "$kind" "$name" 180 + fi + done < <(find "$OUT" -type f -name '*.yaml' -print) +} + +delete_rendered_namespaced_resources() { + local kind output started elapsed class + delete_rendered_kind StatefulSet + started=$(date -u '+%s') + if ! output=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" wait \ + --for=delete pod/agent-os-firstmate-0 --timeout=180s 2>&1 >/dev/null); then + elapsed=$(($(date -u '+%s') - started)) + class=$(delete_failure_class "$output") + bounded_delete_failure Pod/agent-os-firstmate-0 wait "$class" 180 "$elapsed" unknown + fi + for kind in Service RoleBinding Role ServiceAccount; do + delete_rendered_kind "$kind" + done + delete_rendered_kind PersistentVolumeClaim +} + +namespace_is_empty() { + local resources resource objects object lock + if ! resources=$("$KUBECTL" --context "$CONTEXT" api-resources --verbs=list --namespaced -o name); then + echo "error: could not inventory namespaced Kubernetes resource types" >&2 + return 1 + fi + while IFS= read -r resource; do + [ -n "$resource" ] || continue + case "$resource" in + events|events.events.k8s.io) continue ;; + esac + if ! objects=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" get "$resource" -o name); then + echo "error: could not inventory '$resource' in namespace '$NAMESPACE'" >&2 + return 1 + fi + while IFS= read -r object; do + [ -n "$object" ] || continue + case "$object" in + serviceaccount/default|configmap/kube-root-ca.crt) ;; + lease/agent-os-firstmate-lifecycle|lease.coordination.k8s.io/agent-os-firstmate-lifecycle) + lock=$(lock_record 2>/dev/null || true) + if ! verify_lock_record "$lock" || [ "$(printf '%s' "$lock" | cut -f9)" != "$LOCK_UID" ]; then + echo "error: namespace '$NAMESPACE' contains an unverified lifecycle Lease" >&2 + return 1 + fi + ;; + *) + echo "error: namespace '$NAMESPACE' still contains foreign resource '$object'" >&2 + return 1 + ;; + esac + done <<< "$objects" + done <<< "$resources" +} + +delete_owned_empty_namespace() { + local identity + if [ "$MANAGES_NAMESPACE" -ne 1 ]; then + echo "error: --delete-namespace requires createNamespace=true in the current inputs" >&2 + exit 2 + fi + identity=$(namespace_identity) + if [ "$identity" != "agent-os"$'\t'"$INSTALLATION_ID" ]; then + echo "error: namespace '$NAMESPACE' no longer has the exact Agent OS installation identity" >&2 + exit 2 + fi + if ! namespace_is_empty; then + exit 2 + fi + identity=$(namespace_identity) + if [ "$identity" != "agent-os"$'\t'"$INSTALLATION_ID" ]; then + echo "error: namespace '$NAMESPACE' ownership changed during deletion checks" >&2 + exit 2 + fi + stop_lock_renewal + delete_owned_resource cluster Namespace "$NAMESPACE" 180 + LOCK_UID= + LOCK_RV= +} + +cleanup_cluster_rbac() { + local binding identity workload mode cleanup_marker + binding="agent-os-firstmate-$NAMESPACE" + identity=$("$KUBECTL" --context "$CONTEXT" get clusterrolebinding "$binding" --ignore-not-found \ + -o 'jsonpath={.metadata.name}{"\t"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"\t"}{.metadata.annotations.agent-os\.dev/installation-id}') + if [ -n "$identity" ] && [ "$identity" != "$binding"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" ]; then + echo "error: clusterrolebinding '$binding' does not have the exact Agent OS installation identity" >&2 + exit 2 + fi + if [ -n "$identity" ]; then + workload= + if [ -n "$(namespace_name)" ]; then + workload=$(workload_state) + require_workload_owned "$workload" optional + fi + if [ -n "$workload" ]; then + mode=$(printf '%s' "$workload" | cut -f2) + cleanup_marker=$(printf '%s' "$workload" | cut -f3) + if [ "$mode" = cluster-admin ] || [ "$cleanup_marker" != required ]; then + echo "error: ClusterRoleBinding '$binding' is not proven stale by the workload cleanup marker" >&2 + exit 2 + fi + fi + delete_owned_resource cluster ClusterRoleBinding "$binding" 60 + fi + if [ -n "$(namespace_name)" ]; then + workload=$(workload_state) + require_workload_owned "$workload" optional + if [ -n "$workload" ] && [ "$(printf '%s' "$workload" | cut -f3)" = required ]; then + patch_workload_annotations '{"agent-os.dev/cluster-rbac-cleanup":null}' + fi + fi + echo "evidence: clusterrolebinding/$binding absent" +} + +case "$COMMAND" in + install) + [ "$CONFIRMED" -eq 0 ] && [ "$DELETE_NAMESPACE" -eq 0 ] || usage + render + require_namespace_contract + acquire_primary_lock + require_namespace_contract + create_managed_namespace_if_absent + acquire_namespace_lock + require_namespace_contract + preflight_rendered_resources 1 + previous=$(workload_state) + require_workload_owned "$previous" optional + if [ -n "$previous" ]; then + echo "error: agent-os-firstmate already exists; use upgrade" >&2 + exit 2 + fi + preflight_legacy_cluster_binding 0 + ensure_control_lock_access + apply_rendered + verify_desired_rbac + if [ "$DESIRED_RBAC" != namespace ]; then + delete_namespace_rbac + fi + if [ "$DESIRED_RBAC" = none ]; then + delete_control_lock_access + verify_no_runtime_authority || { echo "error: authority-free install retained RBAC" >&2; exit 3; } + fi + ;; + upgrade) + [ "$CONFIRMED" -eq 0 ] && [ "$DELETE_NAMESPACE" -eq 0 ] || usage + render + require_namespace_contract + acquire_primary_lock + require_namespace_contract + preflight_rendered_resources 1 + previous=$(workload_state) + if [ -z "$previous" ]; then + echo "error: agent-os-firstmate does not exist; use install" >&2 + exit 2 + fi + require_workload_owned "$previous" + previous_service_account=$(workload_service_account) + case "$previous_service_account" in + agent-os-firstmate-[a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9]) + SERVICE_ACCOUNT_NAME=$previous_service_account + render + require_namespaced_resource_owned_or_absent ServiceAccount "$SERVICE_ACCOUNT_NAME" + ;; + agent-os-firstmate) + [ "$(live_resource_identity ServiceAccount agent-os-firstmate)" = \ + "agent-os-firstmate"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" ] || { + echo "error: retained legacy ServiceAccount dependency is missing or foreign" >&2 + exit 3 + } + legacy_service_account_record=$(live_resource_record namespaced ServiceAccount agent-os-firstmate) + [ -n "$(printf '%s' "$legacy_service_account_record" | cut -f4)" ] && \ + [ -n "$(printf '%s' "$legacy_service_account_record" | cut -f5)" ] || { + echo "error: retained legacy ServiceAccount dependency lacks exact metadata" >&2 + exit 3 + } + ;; + esac + preflight_legacy_cluster_binding 1 + require_no_active_rollback_checkpoint + PRESERVED_AKUA_SECRET_RECORD=$(verified_akua_overlay_secret) + AKUA_OVERLAY_VERIFIED=1 + previous_mode=$(printf '%s' "$previous" | cut -f2) + previous_cleanup=$(printf '%s' "$previous" | cut -f3) + if [ "$DESIRED_RBAC" != cluster-admin ] && [ "$LEGACY_CLUSTER_BINDING_PRESENT" -eq 1 ]; then + patch_workload_annotations '{"agent-os.dev/cluster-rbac-cleanup":"required"}' + report_cluster_cleanup + exit 3 + fi + cleanup_required=0 + if [ "$DESIRED_RBAC" != cluster-admin ] && \ + { [ "$previous_mode" = cluster-admin ] || [ -z "$previous_mode" ] || [ "$previous_cleanup" = required ]; }; then + cleanup_required=1 + patch_workload_annotations '{"agent-os.dev/cluster-rbac-cleanup":"required"}' + fi + ensure_control_lock_access + if ! apply_rendered; then + if [ "$STATEFULSET_CAS_ATTEMPTED" -eq 1 ]; then + reconcile_failed_akua_upgrade "$PRESERVED_AKUA_SECRET_RECORD" "$STATEFULSET_CAS_UID" || { + echo "incomplete: post-CAS upgrade failure and fail-closed authorization reconciliation is unverified" >&2 + report_partial_observation StatefulSet agent-os-firstmate namespaced + report_partial_observation Pod agent-os-firstmate-0 namespaced + exit 3 + } + echo "safe recovery: run the serialized authorization revoke before retrying upgrade" >&2 + fi + exit 3 + fi + if ! verified_akua_overlay_secret "$PRESERVED_AKUA_SECRET_RECORD" "$STATEFULSET_CAS_UID" >/dev/null; then + reconcile_failed_akua_upgrade "$PRESERVED_AKUA_SECRET_RECORD" "$STATEFULSET_CAS_UID" || { + echo "incomplete: upgrade authorization changed and fail-closed reconciliation is unverified" >&2 + report_partial_observation StatefulSet agent-os-firstmate namespaced + report_partial_observation Pod agent-os-firstmate-0 namespaced + exit 3 + } + echo "safe recovery: run the serialized authorization revoke before retrying upgrade" >&2 + exit 3 + fi + verify_desired_rbac + if [ "$DESIRED_RBAC" != namespace ]; then + delete_namespace_rbac + fi + if [ "$DESIRED_RBAC" = none ]; then + delete_control_lock_access + verify_no_runtime_authority || { echo "error: authority-free upgrade retained RBAC" >&2; exit 3; } + fi + if [ "$DESIRED_RBAC" = cluster-admin ] && [ "$previous_cleanup" = required ]; then + patch_workload_annotations '{"agent-os.dev/cluster-rbac-cleanup":null}' + fi + if [ "$cleanup_required" -eq 1 ]; then + report_cluster_cleanup + exit 3 + fi + ;; + rollback) + [ "$CONFIRMED" -eq 0 ] && [ "$DELETE_NAMESPACE" -eq 0 ] || usage + render + require_namespace_contract + acquire_primary_lock + require_namespace_contract + preflight_rendered_resources 1 + previous=$(workload_state) + require_workload_owned "$previous" + if ! command -v jq >/dev/null 2>&1; then + echo "error: jq is required to resolve StatefulSet revision history safely" >&2 + exit 2 + fi + rollback_record=$(live_resource_record namespaced StatefulSet agent-os-firstmate) + rollback_uid=$(printf '%s' "$rollback_record" | cut -f4) + rollback_rv=$(printf '%s' "$rollback_record" | cut -f5) + if [ "$(printf '%s' "$rollback_record" | cut -f1-3)" != \ + "agent-os-firstmate"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" ] || \ + [ -z "$rollback_uid" ] || [ -z "$rollback_rv" ]; then + echo "error: rollback requires exact StatefulSet UID and resourceVersion evidence" >&2 + exit 2 + fi + rollback_state=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get statefulset agent-os-firstmate -o json) + rollback_revisions=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get controllerrevisions.apps -o json) + rollback_references=$(printf '%s' "$rollback_state" | jq -er \ + --arg uid "$rollback_uid" --arg rv "$rollback_rv" --arg installation "$INSTALLATION_ID" ' + select(.metadata.name == "agent-os-firstmate") + | select(.metadata.uid == $uid and .metadata.resourceVersion == $rv) + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation) + | [.status.currentRevision, .status.updateRevision, + (.metadata.annotations["agent-os.dev/rollback-operation"] // ""), + (.metadata.annotations["agent-os.dev/rollback-target-name"] // ""), + (.metadata.annotations["agent-os.dev/rollback-target-uid"] // ""), + (.metadata.annotations["agent-os.dev/rollback-target-digest"] // ""), + (.metadata.annotations["agent-os.dev/rollback-source-name"] // ""), + (.metadata.annotations["agent-os.dev/rollback-source-uid"] // ""), + (.metadata.annotations["agent-os.dev/rollback-source-digest"] // "")] + | select((.[0:2]) | all(.[]; type == "string" and length > 0)) + | @tsv + ') || { echo "error: StatefulSet changed before rollback revision resolution" >&2; exit 3; } + rollback_current_revision=$(printf '%s' "$rollback_references" | cut -f1) + rollback_update_revision=$(printf '%s' "$rollback_references" | cut -f2) + rollback_checkpoint_operation=$(printf '%s' "$rollback_references" | cut -f3) + rollback_checkpoint_name=$(printf '%s' "$rollback_references" | cut -f4) + rollback_checkpoint_uid=$(printf '%s' "$rollback_references" | cut -f5) + rollback_checkpoint_digest=$(printf '%s' "$rollback_references" | cut -f6) + rollback_source_name=$(printf '%s' "$rollback_references" | cut -f7) + rollback_source_uid=$(printf '%s' "$rollback_references" | cut -f8) + rollback_source_digest=$(printf '%s' "$rollback_references" | cut -f9) + if [ -n "$rollback_checkpoint_operation$rollback_checkpoint_name$rollback_checkpoint_uid$rollback_checkpoint_digest$rollback_source_name$rollback_source_uid$rollback_source_digest" ]; then + if [ -z "$rollback_checkpoint_operation" ] || [ -z "$rollback_checkpoint_name" ] || \ + [ -z "$rollback_checkpoint_uid" ] || ! [[ "$rollback_checkpoint_digest" =~ ^[0-9a-f]{64}$ ]] || \ + [ -z "$rollback_source_name" ] || [ -z "$rollback_source_uid" ] || ! [[ "$rollback_source_digest" =~ ^[0-9a-f]{64}$ ]]; then + echo "error: rollback checkpoint is incomplete or malformed; retained" >&2 + exit 3 + fi + rollback_source=$(owned_revision_by_digest "$rollback_revisions" "$rollback_uid" \ + "$rollback_source_digest" "$rollback_source_uid") || { + echo "error: rollback checkpoint source content is unavailable; retained" >&2 + exit 3 + } + rollback_target=$(printf '%s' "$rollback_revisions" | jq -ce --arg name "$rollback_update_revision" --arg uid "$rollback_uid" ' + [.items[] | select(.metadata.name == $name) | select(any(.metadata.ownerReferences[]?; + .apiVersion == "apps/v1" and .kind == "StatefulSet" and .name == "agent-os-firstmate" and .uid == $uid and .controller == true))] + | select(length == 1) | .[0]' 2>/dev/null) || rollback_target='' + if [ -n "$rollback_target" ]; then + rollback_update_digest=$(printf '%s' "$rollback_target" | jq -cS '.data.spec.template' | template_digest) + [ "$rollback_update_digest" = "$rollback_checkpoint_digest" ] || rollback_target='' + fi + if [ -z "$rollback_target" ]; then + rollback_target=$(owned_revision_by_digest "$rollback_revisions" "$rollback_uid" \ + "$rollback_checkpoint_digest" "$rollback_checkpoint_uid") || { + echo "error: rollback checkpoint target content is unavailable; retained" >&2 + exit 3 + } + fi + rollback_target_name=$(printf '%s' "$rollback_target" | jq -r '.metadata.name') + rollback_target_uid=$(printf '%s' "$rollback_target" | jq -r '.metadata.uid') + rollback_target_revision=$(printf '%s' "$rollback_target" | jq -r '.revision') + rollback_target_digest=$rollback_checkpoint_digest + verify_revision_service_account "$rollback_target" + rollback_mode='patch' + if [ "$rollback_update_revision" = "$rollback_target_name" ]; then + rollback_mode=resume + fi + else + rollback_source=$(printf '%s' "$rollback_revisions" | jq -ce --arg name "$rollback_update_revision" --arg uid "$rollback_uid" ' + [.items[] | select(.metadata.name == $name) | select(any(.metadata.ownerReferences[]?; + .apiVersion == "apps/v1" and .kind == "StatefulSet" and .name == "agent-os-firstmate" and .uid == $uid and .controller == true))] + | select(length == 1) | .[0]') || { echo "error: current rollback source revision is unavailable" >&2; exit 3; } + rollback_source_name=$(printf '%s' "$rollback_source" | jq -r '.metadata.name') + rollback_source_uid=$(printf '%s' "$rollback_source" | jq -r '.metadata.uid') + rollback_source_digest=$(printf '%s' "$rollback_source" | jq -cS '.data.spec.template' | template_digest) + rollback_selection=$(printf '%s' "$rollback_revisions" | jq -ce \ + --arg current "$rollback_current_revision" --arg update "$rollback_update_revision" \ + --arg uid "$rollback_uid" ' + def owned: + any(.metadata.ownerReferences[]?; + .apiVersion == "apps/v1" and .kind == "StatefulSet" and + .name == "agent-os-firstmate" and .uid == $uid and .controller == true); + [.items[] | select(owned)] as $owned + | ($owned | map(select(.metadata.name == $current))) as $current_items + | ($owned | map(select(.metadata.name == $update))) as $update_items + | select($current_items | length == 1) + | select($update_items | length == 1) + | $current_items[0] as $current_revision + | $update_items[0] as $update_revision + | select($current_revision.revision | type == "number") + | select($update_revision.revision | type == "number") + | (if $current != $update then + {target:$current_revision} + else + {target:($owned | map(select((.revision | type) == "number" and .revision < $update_revision.revision)) | sort_by(.revision) | last)} + end) as $selection + | select($selection.target != null and ($selection.target.data.spec.template | type) == "object") + | $selection.target + ') || { echo "error: no exact-owned previous ControllerRevision is available for rollback" >&2; exit 2; } + rollback_target_name=$(printf '%s' "$rollback_selection" | jq -r '.metadata.name') + rollback_target_uid=$(printf '%s' "$rollback_selection" | jq -r '.metadata.uid') + rollback_target_revision=$(printf '%s' "$rollback_selection" | jq -r '.revision') + rollback_target_digest=$(printf '%s' "$rollback_selection" | jq -cS '.data.spec.template' | template_digest) + rollback_target=$rollback_selection + verify_revision_service_account "$rollback_target" + rollback_checkpoint_operation=$OPERATION_ID + rollback_checkpoint_name=$rollback_target_name + rollback_checkpoint_uid=$rollback_target_uid + rollback_checkpoint_patch=$(jq -cn \ + --arg uid "$rollback_uid" --arg rv "$rollback_rv" --arg operation "$rollback_checkpoint_operation" \ + --arg name "$rollback_target_name" --arg targetUid "$rollback_target_uid" --arg digest "$rollback_target_digest" \ + --arg sourceName "$rollback_source_name" --arg sourceUid "$rollback_source_uid" --arg sourceDigest "$rollback_source_digest" \ + '{metadata:{uid:$uid,resourceVersion:$rv,annotations:{"agent-os.dev/rollback-operation":$operation,"agent-os.dev/rollback-target-name":$name,"agent-os.dev/rollback-target-uid":$targetUid,"agent-os.dev/rollback-target-digest":$digest,"agent-os.dev/rollback-source-name":$sourceName,"agent-os.dev/rollback-source-uid":$sourceUid,"agent-os.dev/rollback-source-digest":$sourceDigest}}}') + if ! "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" patch StatefulSet agent-os-firstmate \ + --type=merge -p "$rollback_checkpoint_patch" >/dev/null; then + echo "error: rollback checkpoint CAS conflicted; no template mutation attempted" >&2 + exit 3 + fi + rollback_mode='patch' + rollback_state=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get statefulset agent-os-firstmate -o json) + rollback_rv=$(printf '%s' "$rollback_state" | jq -er \ + --arg uid "$rollback_uid" --arg operation "$rollback_checkpoint_operation" \ + --arg name "$rollback_checkpoint_name" --arg targetUid "$rollback_checkpoint_uid" \ + --arg digest "$rollback_target_digest" --arg sourceName "$rollback_source_name" \ + --arg sourceUid "$rollback_source_uid" --arg sourceDigest "$rollback_source_digest" ' + select(.metadata.uid == $uid) + | select(.metadata.annotations["agent-os.dev/rollback-operation"] == $operation) + | select(.metadata.annotations["agent-os.dev/rollback-target-name"] == $name) + | select(.metadata.annotations["agent-os.dev/rollback-target-uid"] == $targetUid) + | select(.metadata.annotations["agent-os.dev/rollback-target-digest"] == $digest) + | select(.metadata.annotations["agent-os.dev/rollback-source-name"] == $sourceName) + | select(.metadata.annotations["agent-os.dev/rollback-source-uid"] == $sourceUid) + | select(.metadata.annotations["agent-os.dev/rollback-source-digest"] == $sourceDigest) + | .metadata.resourceVersion') || { echo "error: rollback checkpoint did not persist exactly" >&2; exit 3; } + fi + verify_revision_service_account "$rollback_source" + rollback_source_template=$(printf '%s' "$rollback_source" | jq -c '.data.spec.template') + rollback_source_account=$(printf '%s' "$rollback_source" | jq -er '.data.spec.template.spec.serviceAccountName') || { + echo "error: rollback source ServiceAccount dependency is unavailable" >&2 + exit 3 + } + rollback_target_account=$(printf '%s' "$rollback_target" | jq -er '.data.spec.template.spec.serviceAccountName') || { + echo "error: rollback target ServiceAccount dependency is unavailable" >&2 + exit 3 + } + rollback_authority_mode=$(printf '%s' "$previous" | cut -f2) + case "$rollback_authority_mode" in namespace|cluster-admin|none) ;; *) echo "error: rollback RBAC mode is unavailable" >&2; exit 3 ;; esac + if ! transfer_runtime_authority "$rollback_authority_mode" "$rollback_source_account" "$rollback_target_account"; then + transfer_runtime_authority "$rollback_authority_mode" "$rollback_target_account" "$rollback_source_account" || { + echo "incomplete: rollback authority transfer failed and compensation is unverified" >&2 + exit 3 + } + echo "error: rollback authority transfer failed before revision activation; current authority restored" >&2 + exit 3 + fi + if [ "$rollback_mode" = patch ]; then + rollback_patch=$(printf '%s' "$rollback_target" | jq -c --arg uid "$rollback_uid" --arg rv "$rollback_rv" \ + '.data * {metadata:{uid:$uid,resourceVersion:$rv}}') + if ! "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" patch StatefulSet agent-os-firstmate \ + --type=strategic -p "$rollback_patch" >/dev/null; then + compensate_rollback "$rollback_source_template" "$rollback_source_account" "$rollback_target_account" "$rollback_authority_mode" || { + echo "incomplete: StatefulSet rollback CAS conflicted and compensation is unverified" >&2 + exit 3 + } + echo "error: StatefulSet rollback CAS conflicted; current revision and authority restored" >&2 + exit 3 + fi + rollback_current=$(live_resource_record namespaced StatefulSet agent-os-firstmate) + if [ "$(printf '%s' "$rollback_current" | cut -f1-4)" != \ + "$(printf '%s' "$rollback_record" | cut -f1-4)" ]; then + compensate_rollback "$rollback_source_template" "$rollback_source_account" "$rollback_target_account" "$rollback_authority_mode" || { + echo "incomplete: StatefulSet identity changed and rollback compensation is unverified" >&2 + exit 3 + } + echo "error: StatefulSet UID or ownership changed during rollback; source revision and authority restored" >&2 + exit 3 + fi + fi + if ! "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" rollout status statefulset/agent-os-firstmate --timeout=180s; then + if compensate_rollback "$rollback_source_template" "$rollback_source_account" "$rollback_target_account" "$rollback_authority_mode"; then + echo "incomplete: rollback readiness failed; current revision and authority restored" >&2 + exit 3 + fi + report_rollback_failure "$rollback_target_name" "$rollback_target_revision" "$rollback_target_uid" "$rollback_target_digest" \ + "$rollback_checkpoint_operation" "$rollback_checkpoint_name" "$rollback_checkpoint_uid" + exit 3 + fi + rollback_verify_state=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get statefulset agent-os-firstmate -o json) || { + compensate_rollback "$rollback_source_template" "$rollback_source_account" "$rollback_target_account" "$rollback_authority_mode" || { + echo "error: rollback verification evidence and compensation are unavailable; checkpoint retained target-digest=$rollback_target_digest" >&2 + exit 3 + } + echo "incomplete: rollback verification evidence was unavailable; current revision and authority restored" >&2 + exit 3 + } + rollback_verify_refs=$(printf '%s' "$rollback_verify_state" | jq -er \ + --arg uid "$rollback_uid" --arg installation "$INSTALLATION_ID" \ + --arg operation "$rollback_checkpoint_operation" --arg name "$rollback_checkpoint_name" \ + --arg targetUid "$rollback_checkpoint_uid" --arg digest "$rollback_target_digest" \ + --arg sourceName "$rollback_source_name" --arg sourceUid "$rollback_source_uid" --arg sourceDigest "$rollback_source_digest" ' + select(.metadata.uid == $uid) + | select(.metadata.labels["app.kubernetes.io/managed-by"] == "agent-os") + | select(.metadata.annotations["agent-os.dev/installation-id"] == $installation) + | select(.metadata.annotations["agent-os.dev/rollback-operation"] == $operation) + | select(.metadata.annotations["agent-os.dev/rollback-target-name"] == $name) + | select(.metadata.annotations["agent-os.dev/rollback-target-uid"] == $targetUid) + | select(.metadata.annotations["agent-os.dev/rollback-target-digest"] == $digest) + | select(.metadata.annotations["agent-os.dev/rollback-source-name"] == $sourceName) + | select(.metadata.annotations["agent-os.dev/rollback-source-uid"] == $sourceUid) + | select(.metadata.annotations["agent-os.dev/rollback-source-digest"] == $sourceDigest) + | [.status.currentRevision,.status.updateRevision,.metadata.resourceVersion] | @tsv') || { + compensate_rollback "$rollback_source_template" "$rollback_source_account" "$rollback_target_account" "$rollback_authority_mode" || { + echo "error: rollback verification mismatch and compensation failed; retained target-digest=$rollback_target_digest" >&2 + exit 3 + } + echo "incomplete: rollback verification mismatch; current revision and authority restored" >&2 + exit 3 + } + rollback_revisions=$("$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" \ + --request-timeout="${RESOURCE_REQUEST_CEILING_SECONDS}s" get controllerrevisions.apps -o json) || { + compensate_rollback "$rollback_source_template" "$rollback_source_account" "$rollback_target_account" "$rollback_authority_mode" || { + echo "error: rollback revision evidence and compensation are unavailable; checkpoint retained target-digest=$rollback_target_digest" >&2 + exit 3 + } + echo "incomplete: rollback revision evidence was unavailable; current revision and authority restored" >&2 + exit 3 + } + for rollback_verify_name in "$(printf '%s' "$rollback_verify_refs" | cut -f1)" "$(printf '%s' "$rollback_verify_refs" | cut -f2)"; do + rollback_verify_revision=$(printf '%s' "$rollback_revisions" | jq -ce --arg name "$rollback_verify_name" --arg uid "$rollback_uid" ' + [.items[] | select(.metadata.name == $name) | select(any(.metadata.ownerReferences[]?; + .apiVersion == "apps/v1" and .kind == "StatefulSet" and .name == "agent-os-firstmate" and .uid == $uid and .controller == true))] + | select(length == 1) | .[0]') || { + compensate_rollback "$rollback_source_template" "$rollback_source_account" "$rollback_target_account" "$rollback_authority_mode" || { + echo "error: rollback revision '$rollback_verify_name' is unavailable and compensation failed" >&2 + exit 3 + } + echo "incomplete: rollback revision '$rollback_verify_name' is unavailable; current revision and authority restored" >&2 + exit 3 + } + rollback_verify_digest=$(printf '%s' "$rollback_verify_revision" | jq -cS '.data.spec.template' | template_digest) + if [ "$rollback_verify_digest" != "$rollback_target_digest" ]; then + compensate_rollback "$rollback_source_template" "$rollback_source_account" "$rollback_target_account" "$rollback_authority_mode" || { + echo "error: rollback revision '$rollback_verify_name' content differs and compensation failed" >&2 + exit 3 + } + echo "incomplete: rollback revision '$rollback_verify_name' content differs; current revision and authority restored" >&2 + exit 3 + fi + done + transfer_runtime_authority "$rollback_authority_mode" "$rollback_source_account" "$rollback_target_account" || { + echo "error: rollback completed but runtime authority postcondition changed" >&2 + exit 3 + } + rollback_clear_patch=$(jq -cn --arg uid "$rollback_uid" --arg rv "$(printf '%s' "$rollback_verify_refs" | cut -f3)" \ + '{metadata:{uid:$uid,resourceVersion:$rv,annotations:{"agent-os.dev/rollback-operation":null,"agent-os.dev/rollback-target-name":null,"agent-os.dev/rollback-target-uid":null,"agent-os.dev/rollback-target-digest":null,"agent-os.dev/rollback-source-name":null,"agent-os.dev/rollback-source-uid":null,"agent-os.dev/rollback-source-digest":null}}}') + if ! "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" patch StatefulSet agent-os-firstmate \ + --type=merge -p "$rollback_clear_patch" >/dev/null; then + echo "error: rollback completed but checkpoint clear CAS conflicted; retained target-digest=$rollback_target_digest" >&2 + exit 3 + fi + ;; + status) + [ "$CONFIRMED" -eq 0 ] && [ "$DELETE_NAMESPACE" -eq 0 ] || usage + "$KUBECTL" --context "$CONTEXT" -n "$NAMESPACE" get statefulset agent-os-firstmate + ;; + uninstall) + if [ "$CONFIRMED" -ne 1 ]; then + echo "error: uninstall requires --yes" >&2 + exit 2 + fi + render + require_namespace_contract + acquire_primary_lock + require_namespace_contract + preflight_rendered_resources + previous=$(workload_state) + require_workload_owned "$previous" optional + previous_service_account= + previous_workload_uid= + historical_service_accounts=$(inventory_owned_service_accounts) || { + echo "error: uninstall could not recover exact-owned ServiceAccount dependencies" >&2 + exit 3 + } + retained_legacy_service_account= + if [ -n "$previous" ]; then + previous_service_account=$(workload_service_account) + previous_workload_record=$(live_resource_record namespaced StatefulSet agent-os-firstmate) + previous_workload_uid=$(printf '%s' "$previous_workload_record" | cut -f4) + [ -n "$previous_workload_uid" ] || { echo "error: uninstall requires exact StatefulSet UID evidence" >&2; exit 3; } + revision_service_accounts=$(inventory_revision_service_accounts "$previous_workload_uid") || { + echo "error: uninstall could not inventory exact historical ServiceAccount dependencies" >&2 + exit 3 + } + historical_service_accounts=$(printf '%s\n%s\n' "$historical_service_accounts" "$revision_service_accounts" | awk 'NF && !seen[$0]++') + fi + legacy_identity=$(live_resource_identity ServiceAccount agent-os-firstmate) + if [ -n "$legacy_identity" ]; then + [ "$legacy_identity" = "agent-os-firstmate"$'\t'"agent-os"$'\t'"$INSTALLATION_ID" ] || { + echo "error: retained legacy ServiceAccount is foreign" >&2 + exit 3 + } + retained_legacy_service_account=agent-os-firstmate + fi + previous_mode=$(printf '%s' "$previous" | cut -f2) + previous_cleanup=$(printf '%s' "$previous" | cut -f3) + REMOVE_CONTROL_ACCESS=1 + delete_rendered_namespaced_resources + if [ -n "$previous_workload_uid" ] || [ -n "$retained_legacy_service_account" ]; then + wait_for_revision_history_absence "$previous_workload_uid" + fi + if [ -n "$previous_service_account" ] && [ "$previous_service_account" != "$SERVICE_ACCOUNT_NAME" ]; then + delete_owned_resource namespaced ServiceAccount "$previous_service_account" 180 + fi + if [ -n "$retained_legacy_service_account" ] && \ + [ "$retained_legacy_service_account" != "$previous_service_account" ] && \ + [ "$retained_legacy_service_account" != "$SERVICE_ACCOUNT_NAME" ]; then + delete_owned_resource namespaced ServiceAccount "$retained_legacy_service_account" 180 + fi + while IFS= read -r historical_service_account; do + [ -n "$historical_service_account" ] || continue + delete_owned_resource namespaced ServiceAccount "$historical_service_account" 180 + done <<< "$historical_service_accounts" + delete_namespace_rbac + delete_control_lock_access || { + echo "incomplete: exact-owned control RBAC removal is unverified" >&2 + exit 3 + } + REMOVE_CONTROL_ACCESS=0 + if [ "$DELETE_NAMESPACE" -eq 1 ]; then + delete_owned_empty_namespace + fi + if [ "$DESIRED_RBAC" = cluster-admin ] || [ "$previous_mode" = cluster-admin ] || \ + [ "$previous_cleanup" = required ] || [ -z "$previous" ] || \ + { [ -n "$previous" ] && [ -z "$previous_mode" ]; }; then + report_cluster_cleanup + exit 3 + fi + ;; + cleanup-cluster-rbac) + if [ "$CONFIRMED" -ne 1 ] || [ "$DELETE_NAMESPACE" -eq 1 ]; then + echo "error: cleanup-cluster-rbac requires --yes" >&2 + exit 2 + fi + render + acquire_primary_lock + preflight_rendered_resources 1 + cleanup_cluster_rbac + ;; + *) usage ;; +esac diff --git a/bin/agent-os-local.sh b/bin/agent-os-local.sh new file mode 100755 index 000000000..8fb5d4fef --- /dev/null +++ b/bin/agent-os-local.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# agent-os-local.sh - build and operate the local OrbStack Agent OS demo. +# Usage: bin/agent-os-local.sh build|deploy|status|shell|attach|destroy [--yes] +set -eu + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +CONTEXT=${AGENT_OS_CONTEXT:-orbstack} +NAMESPACE=${AGENT_OS_NAMESPACE:-agent-os-demo} +IMAGE=${AGENT_OS_IMAGE:-agent-os:dev} +IMAGE_IS_OVERRIDE= +[ -z "${AGENT_OS_IMAGE:-}" ] || IMAGE_IS_OVERRIDE=1 +COMMAND=${1:-} +PROFILE="$ROOT/deploy/orbstack/inputs.yaml" + +case "$NAMESPACE" in + ''|*[!a-z0-9.-]*|[.-]*|*[-.]) + echo "error: AGENT_OS_NAMESPACE must be a valid Kubernetes namespace" >&2 + exit 2 + ;; +esac + +if [ "$CONTEXT" != orbstack ] && [ "${AGENT_OS_ALLOW_NON_ORBSTACK:-0}" != 1 ]; then + echo "error: refusing Kubernetes context '$CONTEXT'; set AGENT_OS_ALLOW_NON_ORBSTACK=1 to opt in" >&2 + exit 2 +fi + +local_image_tag() { + local image_id + image_id=$(docker image inspect --format '{{.Id}}' "$IMAGE") + printf 'agent-os:local-%s\n' "${image_id#sha256:}" +} + +render_profile_inputs() { + local image=$1 inputs=$2 rendered_namespace=$NAMESPACE + awk -v image="$image" -v rendered_namespace="$rendered_namespace" ' + $1 == "image:" { print "image: " image; next } + $1 == "namespace:" { print "namespace: " rendered_namespace; next } + { print } + ' "$PROFILE" > "$inputs" +} + +render_profile() { + local image=$1 inputs lifecycle + inputs=$(mktemp) + trap 'rm -f "$inputs"' RETURN + render_profile_inputs "$image" "$inputs" + lifecycle=install + if [ -n "$(kubectl --context "$CONTEXT" -n "$NAMESPACE" get statefulset agent-os-firstmate --ignore-not-found -o name)" ]; then + lifecycle=upgrade + fi + AGENT_OS_CONTEXT="$CONTEXT" AGENT_OS_NAMESPACE="$NAMESPACE" AGENT_OS_INPUTS="$inputs" \ + "$ROOT/bin/agent-os-kubernetes.sh" "$lifecycle" +} + +cd "$ROOT" + +case "$COMMAND" in + build) + source_metadata=$("$ROOT/bin/agent-os-source-bundle.sh") + source_commit=$(printf '%s\n' "$source_metadata" | awk -F= '$1 == "commit" { print $2 }') + source_tree=$(printf '%s\n' "$source_metadata" | awk -F= '$1 == "tree" { print $2 }') + source_branch=$(printf '%s\n' "$source_metadata" | awk -F= '$1 == "branch" { print $2 }') + source_origin=$(printf '%s\n' "$source_metadata" | awk -F= '$1 == "origin" { sub(/^[^=]*=/, ""); print }') + source_mode=$(printf '%s\n' "$source_metadata" | awk -F= '$1 == "mode" { print $2 }') + source_ref=$(printf '%s\n' "$source_metadata" | awk -F= '$1 == "ref" { sub(/^[^=]*=/, ""); print }') + [ -n "$source_commit" ] && [ -n "$source_tree" ] && [ -n "$source_branch" ] && [ -n "$source_origin" ] && \ + [ -n "$source_mode" ] && [ -n "$source_ref" ] || { + echo "error: exact-source bootstrap metadata is incomplete" >&2 + exit 2 + } + docker build \ + --build-arg "AGENT_OS_SOURCE_COMMIT=$source_commit" \ + --build-arg "AGENT_OS_SOURCE_TREE=$source_tree" \ + --build-arg "AGENT_OS_SOURCE_BRANCH=$source_branch" \ + --build-arg "AGENT_OS_SOURCE_ORIGIN=$source_origin" \ + --build-arg "AGENT_OS_SOURCE_MODE=$source_mode" \ + --build-arg "AGENT_OS_SOURCE_REF=$source_ref" \ + -t "$IMAGE" . + if [ -z "$IMAGE_IS_OVERRIDE" ]; then + docker tag "$IMAGE" "$(local_image_tag)" + fi + ;; + deploy) + if [ "$CONTEXT" = orbstack ]; then + orbctl start k8s + kubectl --context "$CONTEXT" wait --for=condition=Ready node/orbstack --timeout=120s + fi + if [ -z "$IMAGE_IS_OVERRIDE" ]; then + IMAGE=$(local_image_tag) + fi + render_profile "$IMAGE" + ;; + status) + kubectl --context "$CONTEXT" -n "$NAMESPACE" get statefulset agent-os-firstmate + ;; + shell) + kubectl --context "$CONTEXT" -n "$NAMESPACE" exec -it statefulset/agent-os-firstmate -- bash + ;; + attach) + kubectl --context "$CONTEXT" -n "$NAMESPACE" exec -it statefulset/agent-os-firstmate -- herdr + ;; + destroy) + if [ "${2:-}" != --yes ]; then + echo "error: destroy requires --yes and removes only namespaced Agent OS resources from '$NAMESPACE'" >&2 + exit 2 + fi + inputs=$(mktemp) + trap 'rm -f "$inputs"' EXIT + render_profile_inputs "$IMAGE" "$inputs" + AGENT_OS_CONTEXT="$CONTEXT" AGENT_OS_NAMESPACE="$NAMESPACE" AGENT_OS_INPUTS="$inputs" \ + "$ROOT/bin/agent-os-kubernetes.sh" uninstall --yes + ;; + *) + echo "usage: $0 build|deploy|status|shell|attach|destroy [--yes]" >&2 + exit 2 + ;; +esac diff --git a/bin/agent-os-runtime-bound.sh b/bin/agent-os-runtime-bound.sh new file mode 100755 index 000000000..e9a0c81da --- /dev/null +++ b/bin/agent-os-runtime-bound.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +export AGENT_OS_BOUND_PATH AGENT_OS_BOUND_TEST_FALLBACK_ACTIVE +AGENT_OS_BOUND_TEST_FALLBACK_ACTIVE=false +AGENT_OS_BOUND_DIR_HANDLES=() + +agent_os_register_bound_dir() { + AGENT_OS_BOUND_DIR_HANDLES+=("$1") +} + +agent_os_bound_dir_is_open() { + local candidate=$1 bound + [ -d "$candidate" ] || return 1 + for bound in "${AGENT_OS_BOUND_DIR_HANDLES[@]}"; do + [ "$candidate" = "$bound" ] && return 0 + done + return 1 +} + +agent_os_open_bound_dir() { + local dir=$1 fd fd_root + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + exec {fd}<"$dir" || return 1 + if [ -d "/proc/self/fd/$fd" ]; then + fd_root=/proc/self/fd + [ "$dir" -ef "$fd_root/$fd" ] || { + exec {fd}<&- + return 1 + } + AGENT_OS_BOUND_PATH=$fd_root/$fd + agent_os_register_bound_dir "$AGENT_OS_BOUND_PATH" + return 0 + elif [ "${AGENT_OS_TEST_BOUND_PATHS:-}" = true ]; then + exec {fd}<&- + AGENT_OS_BOUND_PATH=$(cd "$dir" && pwd -P) + AGENT_OS_BOUND_TEST_FALLBACK_ACTIVE=true + agent_os_register_bound_dir "$AGENT_OS_BOUND_PATH" + return 0 + else + exec {fd}<&- + return 1 + fi +} + +agent_os_bound_dir_matches() { + local dir=$1 bound=$2 + [ -d "$dir" ] && [ ! -L "$dir" ] && [ "$dir" -ef "$bound" ] +} + +agent_os_bound_git() { + local git_dir=$1 common_dir=$2 work_tree=$3 + shift 3 + agent_os_bound_dir_is_open "$git_dir" || return 1 + agent_os_bound_dir_is_open "$common_dir" || return 1 + agent_os_bound_dir_is_open "$work_tree" || return 1 + trusted_git --agent-os-common-dir "$common_dir" \ + --git-dir="$git_dir" --work-tree="$work_tree" "$@" +} diff --git a/bin/agent-os-runtime-secondmates.sh b/bin/agent-os-runtime-secondmates.sh new file mode 100755 index 000000000..1542eec88 --- /dev/null +++ b/bin/agent-os-runtime-secondmates.sh @@ -0,0 +1,765 @@ +#!/usr/bin/env bash +set -eu +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH + +FM_HOME=${FM_HOME:?FM_HOME is required} +FM_ROOT=${FM_ROOT_OVERRIDE:?FM_ROOT_OVERRIDE is required} +SOURCE_COMMIT=${AGENT_OS_SOURCE_COMMIT:?AGENT_OS_SOURCE_COMMIT is required} +SOURCE_TREE=${AGENT_OS_SOURCE_TREE:?AGENT_OS_SOURCE_TREE is required} +SOURCE_SHA=${AGENT_OS_SOURCE_SHA256:?AGENT_OS_SOURCE_SHA256 is required} +SOURCE_BRANCH=${AGENT_OS_SOURCE_BRANCH:?AGENT_OS_SOURCE_BRANCH is required} +SOURCE_ORIGIN=${AGENT_OS_SOURCE_ORIGIN:?AGENT_OS_SOURCE_ORIGIN is required} +SOURCE_MODE=${AGENT_OS_SOURCE_MODE:?AGENT_OS_SOURCE_MODE is required} +GIT_BIN=/usr/bin/git +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +SUB_HOME_MARKER=.fm-secondmate-home +# shellcheck source=bin/fm-ff-lib.sh +. "$SCRIPT_DIR/fm-ff-lib.sh" +# shellcheck source=bin/agent-os-runtime-bound.sh +. "$SCRIPT_DIR/agent-os-runtime-bound.sh" + +[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || exit 2 +[[ "$SOURCE_TREE" =~ ^[0-9a-f]{40}$ ]] || exit 2 +[[ "$SOURCE_SHA" =~ ^[0-9a-f]{64}$ ]] || exit 2 +case "$SOURCE_MODE" in candidate|release) ;; *) exit 2 ;; esac +[ -x "$GIT_BIN" ] || exit 2 + +trusted_git() { + local common_dir= + if [ "${1:-}" = --agent-os-common-dir ]; then + common_dir=${2:?bound common Git directory is required} + shift 2 + agent_os_bound_dir_is_open "$common_dir" || return 1 + fi + if [ -n "$common_dir" ]; then + env -i HOME=/nonexistent PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_COMMON_DIR="$common_dir" \ + "$GIT_BIN" -c credential.helper= -c core.hooksPath=/dev/null \ + -c http.proxy= -c https.proxy= "$@" + else + env -i HOME=/nonexistent PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + "$GIT_BIN" -c credential.helper= -c core.hooksPath=/dev/null \ + -c http.proxy= -c https.proxy= "$@" + fi +} + +resolve_treehouse_pool() { + local home=$1 parent pool + parent=$(cd "$home/.." 2>/dev/null && pwd -P) || return 1 + pool=$(cd "$parent/.." 2>/dev/null && pwd -P) || return 1 + [ -f "$pool/treehouse-state.json" ] && [ ! -L "$pool/treehouse-state.json" ] || return 1 + [ -f "$pool/treehouse-state.lock" ] && [ ! -L "$pool/treehouse-state.lock" ] || return 1 + RESOLVED_TREEHOUSE_POOL=$pool +} + +acquire_treehouse_lock() { + local pool=$1 flock_bin lock_file fd_root + flock_bin=/usr/bin/flock + if [ ! -x "$flock_bin" ]; then + flock_bin=${AGENT_OS_TEST_FLOCK_BIN:-} + fi + [ -x "$flock_bin" ] || return 1 + lock_file=$pool/treehouse-state.lock + exec {TREEHOUSE_LOCK_FD}<>"$lock_file" || return 1 + if [ -e "/proc/self/fd/$TREEHOUSE_LOCK_FD" ]; then + fd_root=/proc/self/fd + TREEHOUSE_LOCK_HANDLE=$fd_root/$TREEHOUSE_LOCK_FD + elif [ "$flock_bin" = "${AGENT_OS_TEST_FLOCK_BIN:-}" ]; then + TREEHOUSE_LOCK_HANDLE=$lock_file + else + return 1 + fi + [ "$lock_file" -ef "$TREEHOUSE_LOCK_HANDLE" ] || return 1 + AGENT_OS_TEST_LOCK_FILE_HINT="$lock_file" "$flock_bin" -x "$TREEHOUSE_LOCK_FD" || return 1 + [ "$lock_file" -ef "$TREEHOUSE_LOCK_HANDLE" ] || return 1 + TREEHOUSE_LOCK_FILE=$lock_file + TREEHOUSE_POOL=$pool +} + +acquire_primary_ownership_lock() { + local flock_bin lock_file fd_root + flock_bin=/usr/bin/flock + if [ ! -x "$flock_bin" ]; then + flock_bin=${AGENT_OS_TEST_FLOCK_BIN:-} + fi + [ -x "$flock_bin" ] || return 1 + lock_file=$FM_HOME/state/agent-os-runtime-secondmates.lock + [ -d "$FM_HOME/state" ] && [ ! -L "$FM_HOME/state" ] || return 1 + if [ -e "$lock_file" ]; then + [ -f "$lock_file" ] && [ ! -L "$lock_file" ] || return 1 + else + : > "$lock_file" || return 1 + fi + exec {PRIMARY_OWNERSHIP_LOCK_FD}<>"$lock_file" || return 1 + if [ -e "/proc/self/fd/$PRIMARY_OWNERSHIP_LOCK_FD" ]; then + fd_root=/proc/self/fd + PRIMARY_OWNERSHIP_LOCK_HANDLE=$fd_root/$PRIMARY_OWNERSHIP_LOCK_FD + elif [ "$flock_bin" = "${AGENT_OS_TEST_FLOCK_BIN:-}" ]; then + PRIMARY_OWNERSHIP_LOCK_HANDLE=$lock_file + else + return 1 + fi + [ "$lock_file" -ef "$PRIMARY_OWNERSHIP_LOCK_HANDLE" ] || return 1 + AGENT_OS_TEST_LOCK_FILE_HINT="$lock_file" "$flock_bin" -x "$PRIMARY_OWNERSHIP_LOCK_FD" || return 1 + [ "$lock_file" -ef "$PRIMARY_OWNERSHIP_LOCK_HANDLE" ] || return 1 + PRIMARY_OWNERSHIP_LOCK_FILE=$lock_file +} + +validate_treehouse_lease() { + local id=$1 state_home=$2 home=$3 state state_json entry_count index entry_home canonical_count validated_home + VALIDATED_TREEHOUSE_HOME= + state=$TREEHOUSE_POOL/treehouse-state.json + [ "$TREEHOUSE_LOCK_FILE" -ef "$TREEHOUSE_LOCK_HANDLE" ] || return 1 + [ -r "$state" ] && [ -f "$state" ] && [ ! -L "$state" ] || return 1 + state_json=$(cat "$state") || return 1 + jq -e --arg home "$state_home" --arg holder "$id" ' + (.worktrees | type == "array") and + (all(.worktrees[]; (.path | type == "string" and test("^[^\\t\\r\\n]+$")))) and + ([.worktrees[] | select(.path == $home)] | length == 1) and + ([.worktrees[] | select(.leased == true and .lease_holder == $holder)] | length == 1) and + ([.worktrees[] | select( + .path == $home and .leased == true and .lease_holder == $holder and + ((.destroying // false) == false) and ((.owner_pid // 0) == 0) and + ((.owner_started_at // 0) == 0) and + (.name | type == "string" and test("^(?!\\.{1,2}$)[^/\\t\\r\\n]+$")) and + (.leased_at | type == "string" and length > 0) + )] | length == 1)' <<< "$state_json" >/dev/null || return 1 + entry_count=$(jq -er '.worktrees | length' <<< "$state_json") || return 1 + canonical_count=0 + index=0 + while [ "$index" -lt "$entry_count" ]; do + entry_home=$(jq -er --argjson index "$index" '.worktrees[$index].path' <<< "$state_json") || return 1 + entry_home=$(cd "$entry_home" 2>/dev/null && pwd -P) || return 1 + [ "$entry_home" != "$home" ] || canonical_count=$((canonical_count + 1)) + index=$((index + 1)) + done + [ "$canonical_count" -eq 1 ] || return 1 + validated_home=$(jq -er --arg home "$state_home" --arg holder "$id" ' + [.worktrees[] | select( + .path == $home and .leased == true and .lease_holder == $holder and + ((.destroying // false) == false) and ((.owner_pid // 0) == 0) and + ((.owner_started_at // 0) == 0) + )] | select(length == 1) | .[0].path' <<< "$state_json") || return 1 + VALIDATED_TREEHOUSE_HOME=$(cd "$validated_home" 2>/dev/null && pwd -P) || return 1 +} + +resolve_git_metadata() { + local root=$1 pointer common + if [ -d "$root/.git" ] && [ ! -L "$root/.git" ]; then + RESOLVED_GIT_DIR=$(cd "$root/.git" && pwd -P) + elif [ -f "$root/.git" ] && [ ! -L "$root/.git" ]; then + [ "$(wc -l < "$root/.git" | tr -d ' ')" -eq 1 ] || return 1 + pointer=$(sed -n 's/^gitdir: //p' "$root/.git") + [ -n "$pointer" ] || return 1 + case "$pointer" in + /*) RESOLVED_GIT_DIR=$(cd "$pointer" 2>/dev/null && pwd -P) || return 1 ;; + *) RESOLVED_GIT_DIR=$(cd "$root/$(dirname "$pointer")" 2>/dev/null && printf '%s/%s\n' "$(pwd -P)" "$(basename "$pointer")") || return 1 ;; + esac + else + return 1 + fi + RESOLVED_COMMON_DIR=$RESOLVED_GIT_DIR + if [ -f "$RESOLVED_GIT_DIR/commondir" ] && [ ! -L "$RESOLVED_GIT_DIR/commondir" ]; then + [ "$(wc -l < "$RESOLVED_GIT_DIR/commondir" | tr -d ' ')" -eq 1 ] || return 1 + common=$(cat "$RESOLVED_GIT_DIR/commondir") + case "$common" in + /*) RESOLVED_COMMON_DIR=$(cd "$common" 2>/dev/null && pwd -P) || return 1 ;; + *) RESOLVED_COMMON_DIR=$(cd "$RESOLVED_GIT_DIR/$common" 2>/dev/null && pwd -P) || return 1 ;; + esac + fi +} + +resolve_file_path() { + local path=$1 parent + parent=$(cd "$(dirname "$path")" 2>/dev/null && pwd -P) || return 1 + printf '%s/%s\n' "$parent" "$(basename "$path")" +} + +validate_git_binding() { + local root=$1 expected backlink backlink_path relative runtime_root source_root source_key policy_commit policy_sha + expected=$(resolve_file_path "$root/.git") || return 1 + if [ -d "$root/.git" ] && [ ! -L "$root/.git" ]; then + [ "$RESOLVED_GIT_DIR" = "$expected" ] || return 1 + [ "$RESOLVED_COMMON_DIR" = "$RESOLVED_GIT_DIR" ] || return 1 + return 0 + fi + [ -f "$root/.git" ] && [ ! -L "$root/.git" ] || return 1 + [ -f "$RESOLVED_GIT_DIR/gitdir" ] && [ ! -L "$RESOLVED_GIT_DIR/gitdir" ] || return 1 + [ "$(wc -l < "$RESOLVED_GIT_DIR/gitdir" | tr -d ' ')" -eq 1 ] || return 1 + backlink=$(cat "$RESOLVED_GIT_DIR/gitdir") + case "$backlink" in + /*) backlink_path=$(resolve_file_path "$backlink") || return 1 ;; + *) backlink_path=$(resolve_file_path "$RESOLVED_GIT_DIR/$backlink") || return 1 ;; + esac + [ "$backlink_path" = "$expected" ] || return 1 + case "$RESOLVED_GIT_DIR" in + "$RESOLVED_COMMON_DIR"/worktrees/*) ;; + *) return 1 ;; + esac + relative=${RESOLVED_GIT_DIR#"$RESOLVED_COMMON_DIR"/worktrees/} + case "$relative" in ''|*/*) return 1 ;; esac + [ -d "$RESOLVED_COMMON_DIR" ] && [ ! -L "$RESOLVED_COMMON_DIR" ] || return 1 + runtime_root=$(cd "$FM_HOME/runtime-sources" 2>/dev/null && pwd -P) || return 1 + source_root=$(cd "$RESOLVED_COMMON_DIR/.." 2>/dev/null && pwd -P) || return 1 + case "$source_root" in "$runtime_root"/*) ;; *) return 1 ;; esac + source_key=${source_root#"$runtime_root"/} + case "$source_key" in ''|*/*) return 1 ;; esac + [ "$RESOLVED_COMMON_DIR" = "$source_root/.git" ] || return 1 + validate_policy "$RESOLVED_COMMON_DIR/agent-os-runtime-source" || return 1 + policy_commit=$(sed -n 's/^commit=//p' "$RESOLVED_COMMON_DIR/agent-os-runtime-source") + policy_sha=$(sed -n 's/^source_sha256=//p' "$RESOLVED_COMMON_DIR/agent-os-runtime-source") + [ "$source_key" = "$policy_commit-$policy_sha" ] +} + +validate_bound_git_binding() { + local root=$1 bound_home=$2 bound_git=$3 bound_common=$4 bound_source=$5 linked=$6 source_key=$7 + local expected_treehouse_home=$8 pointer pointer_path backlink backlink_path common common_path + local policy_commit policy_sha + [ "$PRIMARY_OWNERSHIP_LOCK_FILE" -ef "$PRIMARY_OWNERSHIP_LOCK_HANDLE" ] || return 1 + if [ "$linked" = false ]; then + [ -d "$bound_home/.git" ] && [ ! -L "$bound_home/.git" ] || return 1 + [ "$bound_home/.git" -ef "$bound_git" ] || return 1 + [ "$bound_git" -ef "$bound_common" ] || return 1 + [ "$bound_home" -ef "$bound_source" ] || return 1 + return 0 + fi + [ -f "$bound_home/.git" ] && [ ! -L "$bound_home/.git" ] || return 1 + [ "$(wc -l < "$bound_home/.git" | tr -d ' ')" -eq 1 ] || return 1 + pointer=$(sed -n 's/^gitdir: //p' "$bound_home/.git") + [ -n "$pointer" ] || return 1 + case "$pointer" in + /*) pointer_path=$pointer ;; + *) pointer_path="$bound_home/$pointer" ;; + esac + [ "$pointer_path" -ef "$bound_git" ] || return 1 + [ -f "$bound_git/gitdir" ] && [ ! -L "$bound_git/gitdir" ] || return 1 + [ "$(wc -l < "$bound_git/gitdir" | tr -d ' ')" -eq 1 ] || return 1 + backlink=$(cat "$bound_git/gitdir") + case "$backlink" in + /*) backlink_path=$backlink ;; + *) backlink_path="$bound_git/$backlink" ;; + esac + [ "$backlink_path" -ef "$bound_home/.git" ] || return 1 + [ -f "$bound_git/commondir" ] && [ ! -L "$bound_git/commondir" ] || return 1 + [ "$(wc -l < "$bound_git/commondir" | tr -d ' ')" -eq 1 ] || return 1 + common=$(cat "$bound_git/commondir") + case "$common" in + /*) common_path=$common ;; + *) common_path="$bound_git/$common" ;; + esac + [ "$common_path" -ef "$bound_common" ] || return 1 + [ -d "$expected_treehouse_home" ] && [ ! -L "$expected_treehouse_home" ] || return 1 + [ "$expected_treehouse_home" -ef "$bound_home" ] || return 1 + [ "$bound_source/.git" -ef "$bound_common" ] || return 1 + validate_policy "$bound_common/agent-os-runtime-source" || return 1 + policy_commit=$(sed -n 's/^commit=//p' "$bound_common/agent-os-runtime-source") + policy_sha=$(sed -n 's/^source_sha256=//p' "$bound_common/agent-os-runtime-source") + [ "$source_key" = "$policy_commit-$policy_sha" ] +} + +validate_primary_standalone_proof() { + local id=$1 home=$2 meta recorded_home + meta=$FM_HOME/state/$id.meta + [ "$PRIMARY_OWNERSHIP_LOCK_FILE" -ef "$PRIMARY_OWNERSHIP_LOCK_HANDLE" ] || return 1 + [ -f "$meta" ] && [ ! -L "$meta" ] && [ -r "$meta" ] || return 1 + [ "$(grep -c '^kind=secondmate$' "$meta")" -eq 1 ] || return 1 + [ "$(grep -c '^home=' "$meta")" -eq 1 ] || return 1 + recorded_home=$(sed -n 's/^home=//p' "$meta") + [ -d "$recorded_home" ] && [ ! -L "$recorded_home" ] || return 1 + recorded_home=$(cd "$recorded_home" && pwd -P) || return 1 + [ "$recorded_home" = "$home" ] +} + +validate_config() { + local config=$1 section='' line key value token seen='' + [ -f "$config" ] && [ ! -L "$config" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|'#'*|';'*) continue ;; + '[core]') section=core; continue ;; + '[remote "origin"]') section=remote.origin; continue ;; + "[branch \"$SOURCE_BRANCH\"]") section="branch.$SOURCE_BRANCH"; continue ;; + '['*) return 1 ;; + esac + case "$line" in *=*) ;; *) return 1 ;; esac + key=${line%%=*} + value=${line#*=} + key=$(printf '%s' "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + token="$section.$key" + case "|$seen|" in *"|$token|"*) return 1 ;; esac + case "$token" in + core.repositoryformatversion) [ "$value" = 0 ] ;; + core.filemode) [ "$value" = true ] || [ "$value" = false ] ;; + core.bare) [ "$value" = false ] ;; + core.logallrefupdates) [ "$value" = true ] ;; + core.ignorecase) [ "$value" = true ] || [ "$value" = false ] ;; + core.precomposeunicode) [ "$value" = true ] || [ "$value" = false ] ;; + remote.origin.url) [ -n "$value" ] ;; + remote.origin.fetch) [ -n "$value" ] ;; + "branch.$SOURCE_BRANCH.remote") [ "$value" = origin ] ;; + "branch.$SOURCE_BRANCH.merge") [ "$value" = "refs/heads/$SOURCE_BRANCH" ] ;; + *) return 1 ;; + esac || return 1 + seen="${seen:+$seen|}$token" + done < "$config" + for token in core.repositoryformatversion core.filemode core.bare core.logallrefupdates \ + remote.origin.url remote.origin.fetch; do + case "|$seen|" in *"|$token|"*) ;; *) return 1 ;; esac + done +} + +validate_policy() { + local policy=$1 policy_mode policy_commit policy_sha + [ -f "$policy" ] && [ ! -L "$policy" ] || return 1 + policy_mode=$(sed -n 's/^mode=//p' "$policy") + policy_commit=$(sed -n 's/^commit=//p' "$policy") + policy_sha=$(sed -n 's/^source_sha256=//p' "$policy") + case "$policy_mode" in candidate|release) ;; *) return 1 ;; esac + [[ "$policy_commit" =~ ^[0-9a-f]{40}$ ]] || return 1 + [[ "$policy_sha" =~ ^[0-9a-f]{64}$ ]] || return 1 + [ "$(wc -l < "$policy" | tr -d ' ')" -eq 3 ] +} + +trusted_tree_entry() { + local git_dir=$1 common_dir=$2 home=$3 commit=$4 path=$5 entry + entry=$(agent_os_bound_git "$git_dir" "$common_dir" "$home" ls-tree "$commit" -- "$path") || return 1 + printf '%s' "$entry" | awk 'NR == 1 { print $1 " " $3 }' +} + +worktree_entry() { + local git_dir=$1 common_dir=$2 home=$3 path=$4 mode hash + if [ -L "$home/$path" ]; then + mode=120000 + hash=$(printf '%s' "$(readlink "$home/$path")" | agent_os_bound_git "$git_dir" "$common_dir" "$home" hash-object --stdin) + elif [ -f "$home/$path" ]; then + if [ -x "$home/$path" ]; then mode=100755; else mode=100644; fi + hash=$(agent_os_bound_git "$git_dir" "$common_dir" "$home" hash-object -- "$home/$path") + elif [ ! -e "$home/$path" ]; then + printf '\n' + return 0 + else + return 1 + fi + printf '%s %s\n' "$mode" "$hash" +} + +entry_matches_transition() { + local git_dir=$1 common_dir=$2 home=$3 path=$4 actual=$5 commit expected + shift 5 + for commit in "$@"; do + expected=$(trusted_tree_entry "$git_dir" "$common_dir" "$home" "$commit" "$path") || return 1 + [ "$actual" = "$expected" ] && return 0 + done + return 1 +} + +verify_pending_checkout() { + local git_dir=$1 common_dir=$2 home=$3 pending=$4 current_commit pending_commit record path worktree index index_entries status_file valid + current_commit=$(agent_os_bound_git "$git_dir" "$common_dir" "$home" rev-parse HEAD) || return 1 + pending_commit=$(sed -n 's/^commit=//p' "$pending") + agent_os_bound_git "$git_dir" "$common_dir" "$home" rev-parse "$pending_commit^{commit}" >/dev/null || return 1 + status_file=$(mktemp "${TMPDIR:-/tmp}/agent-os-secondmate-status.XXXXXX") || return 1 + if ! agent_os_bound_git "$git_dir" "$common_dir" "$home" -c status.renames=false status \ + --porcelain=v1 -z --untracked-files=all > "$status_file"; then + rm -f "$status_file" + return 1 + fi + RECOVERY_PATHS=() + valid=true + while IFS= read -r -d '' record; do + path=${record:3} + if [ -z "$path" ]; then valid=false; break; fi + worktree=$(worktree_entry "$git_dir" "$common_dir" "$home" "$path") || { valid=false; break; } + entry_matches_transition "$git_dir" "$common_dir" "$home" "$path" "$worktree" \ + "$current_commit" "$pending_commit" "$SOURCE_COMMIT" || { valid=false; break; } + index_entries=$(agent_os_bound_git "$git_dir" "$common_dir" "$home" ls-files -s -- "$path") || { valid=false; break; } + index=$(printf '%s' "$index_entries" | awk ' + NR == 1 { entry=$1 " " $2 } + END { if (NR <= 1) print entry; else exit 1 } + ') || { valid=false; break; } + entry_matches_transition "$git_dir" "$common_dir" "$home" "$path" "$index" \ + "$current_commit" "$pending_commit" "$SOURCE_COMMIT" || { valid=false; break; } + RECOVERY_PATHS+=("$path") + done < "$status_file" + rm -f "$status_file" + [ "$valid" = true ] +} + +recover_pending_checkout() { + local git_dir=$1 common_dir=$2 home=$3 pending=$4 old_head path + verify_pending_checkout "$git_dir" "$common_dir" "$home" "$pending" || return 1 + old_head=$(agent_os_bound_git "$git_dir" "$common_dir" "$home" rev-parse HEAD) || return 1 + agent_os_bound_git "$git_dir" "$common_dir" "$home" restore --source="$SOURCE_COMMIT" --staged --worktree -- . + for path in "${RECOVERY_PATHS[@]}"; do + if [ -z "$(trusted_tree_entry "$git_dir" "$common_dir" "$home" "$SOURCE_COMMIT" "$path")" ] && \ + { [ -f "$home/$path" ] || [ -L "$home/$path" ]; }; then + rm -f -- "$home/$path" + fi + done + agent_os_bound_git "$git_dir" "$common_dir" "$home" update-ref --no-deref HEAD "$SOURCE_COMMIT" "$old_head" + [ -z "$(agent_os_bound_git "$git_dir" "$common_dir" "$home" status --porcelain --untracked-files=all)" ] +} + +ids=() +homes=() +proofs=() +add_home() { + local id=$1 home=$2 proof=$3 + [ -n "$id" ] && [ -n "$home" ] || return 0 + case "$id" in *[!A-Za-z0-9._-]*|.|..) echo "error: secondmate id is invalid" >&2; exit 2 ;; esac + case "$home" in /*) ;; *) echo "error: secondmate home is not absolute" >&2; exit 2 ;; esac + ids+=("$id") + homes+=("$home") + proofs+=("$proof") +} + +for meta in "$FM_HOME"/state/*.meta; do + [ -f "$meta" ] || continue + [ "$(sed -n 's/^kind=//p' "$meta")" = secondmate ] || continue + id=${meta##*/} + id=${id%.meta} + add_home "$id" "$(sed -n 's/^home=//p' "$meta")" primary-meta +done +if [ -f "$FM_HOME/data/secondmates.md" ]; then + while IFS= read -r line; do + case "$line" in '- '*) ;; *) continue ;; esac + id=$(printf '%s\n' "$line" | sed -n 's/^- \([^ ][^ ]*\) - .*/\1/p') + add_home "$id" "$(printf '%s\n' "$line" | sed -n 's/.*(home:[[:space:]]*\([^;]*\);.*/\1/p' | sed 's/[[:space:]]*$//')" registry + done < "$FM_HOME/data/secondmates.md" +fi + +[ "$(trusted_git -C "$FM_ROOT" rev-parse HEAD)" = "$SOURCE_COMMIT" ] || exit 2 +[ "$(trusted_git -C "$FM_ROOT" rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE" ] || exit 2 +[ -z "$(trusted_git -C "$FM_ROOT" status --porcelain --untracked-files=all)" ] || exit 2 +acquire_primary_ownership_lock || { + echo "error: primary secondmate ownership lock is unavailable" >&2 + exit 2 +} + +validated_ids=() +validated_homes=() +treehouse_homes=() +treehouse_bound_homes=() +ownership_modes=() +git_dirs=() +common_dirs=() +source_dirs=() +bound_homes=() +bound_configs=() +bound_git_dirs=() +bound_common_dirs=() +bound_source_dirs=() +pending_policies=() +seen_homes=$'\n' +for index in "${!homes[@]}"; do + id=${ids[$index]} + home=${homes[$index]} + proof=${proofs[$index]} + treehouse_home=$home + treehouse_bound_home= + validate_secondmate_home "$id" "$home" || { + echo "error: invalid secondmate home for $id: $VALIDATION_ERROR" >&2 + exit 2 + } + home=$VALIDATED_HOME + duplicate=false + for prior in "${!validated_homes[@]}"; do + prior_home=${validated_homes[$prior]} + prior_id=${validated_ids[$prior]} + if [ "$home" = "$prior_home" ]; then + [ "$id" = "$prior_id" ] || { + echo "error: secondmate home is registered to multiple identities: $home" >&2 + exit 2 + } + duplicate=true + break + fi + if [ "$id" = "$prior_id" ]; then + echo "error: secondmate identity is registered to multiple homes: $id" >&2 + exit 2 + fi + if path_is_ancestor_of "$home" "$prior_home" || path_is_ancestor_of "$prior_home" "$home"; then + echo "error: secondmate homes overlap: $home and $prior_home" >&2 + exit 2 + fi + done + [ "$duplicate" = false ] || continue + case "$seen_homes" in *$'\n'"$home"$'\n'*) continue ;; esac + seen_homes+="$home"$'\n' + if [ -d "$home/.git" ] && [ ! -L "$home/.git" ]; then + ownership_mode=primary-standalone + if [ "$proof" != primary-meta ] || ! validate_primary_standalone_proof "$id" "$home"; then + echo "error: standalone secondmate lacks exact primary-owned assignment proof: $id" >&2 + exit 2 + fi + else + ownership_mode=treehouse + resolve_treehouse_pool "$home" || { + echo "error: secondmate Treehouse state is unavailable: $home" >&2 + exit 2 + } + if [ -z "${TREEHOUSE_POOL:-}" ]; then + acquire_treehouse_lock "$RESOLVED_TREEHOUSE_POOL" || { + echo "error: secondmate Treehouse ownership lock is unavailable: $home" >&2 + exit 2 + } + elif [ "$TREEHOUSE_POOL" != "$RESOLVED_TREEHOUSE_POOL" ]; then + echo "error: secondmate homes do not share one authoritative Treehouse pool" >&2 + exit 2 + fi + validate_treehouse_lease "$id" "$treehouse_home" "$home" || { + echo "error: secondmate Treehouse lease holder is not exact: $id" >&2 + exit 2 + } + treehouse_bound_home=$VALIDATED_TREEHOUSE_HOME + fi + resolve_git_metadata "$home" || { echo "error: secondmate Git metadata is invalid: $home" >&2; exit 2; } + original_git_dir=$RESOLVED_GIT_DIR + original_common_dir=$RESOLVED_COMMON_DIR + if [ "$ownership_mode" = treehouse ]; then + case "$original_git_dir" in + "$original_common_dir"/worktrees/*) ;; + *) echo "error: secondmate Git metadata is not a linked worktree: $home" >&2; exit 2 ;; + esac + git_relative=${original_git_dir#"$original_common_dir"/worktrees/} + case "$git_relative" in ''|*/*) echo "error: secondmate Git metadata path is invalid: $home" >&2; exit 2 ;; esac + original_source_dir=$(cd "$original_common_dir/.." && pwd -P) || exit 2 + runtime_root=$(cd "$FM_HOME/runtime-sources" && pwd -P) || exit 2 + case "$original_source_dir" in "$runtime_root"/*) ;; *) exit 2 ;; esac + source_key=${original_source_dir#"$runtime_root"/} + case "$source_key" in ''|*/*) exit 2 ;; esac + else + original_source_dir=$home + source_key=${original_source_dir##*/} + fi + agent_os_open_bound_dir "$home" || { + echo "error: secondmate home cannot be bound for mutation: $home" >&2 + exit 2 + } + bound_home=$AGENT_OS_BOUND_PATH + agent_os_open_bound_dir "$home/config" || { + echo "error: secondmate configuration directory cannot be bound for mutation: $home" >&2 + exit 2 + } + bound_config=$AGENT_OS_BOUND_PATH + agent_os_open_bound_dir "$original_git_dir" || { + echo "error: secondmate Git directory cannot be bound for mutation: $home" >&2 + exit 2 + } + bound_git_dir=$AGENT_OS_BOUND_PATH + agent_os_open_bound_dir "$original_common_dir" || { + echo "error: secondmate common Git directory cannot be bound for mutation: $home" >&2 + exit 2 + } + bound_common_dir=$AGENT_OS_BOUND_PATH + agent_os_open_bound_dir "$original_source_dir" || { + echo "error: secondmate source directory cannot be bound for mutation: $home" >&2 + exit 2 + } + bound_source_dir=$AGENT_OS_BOUND_PATH + if ! { agent_os_bound_dir_matches "$home" "$bound_home" && \ + agent_os_bound_dir_matches "$home/config" "$bound_config" && \ + agent_os_bound_dir_matches "$original_git_dir" "$bound_git_dir" && \ + agent_os_bound_dir_matches "$original_common_dir" "$bound_common_dir" && \ + agent_os_bound_dir_matches "$original_source_dir" "$bound_source_dir"; }; then + echo "error: secondmate ownership changed while binding: $home" >&2 + exit 2 + fi + validate_bound_git_binding "$home" "$bound_home" "$bound_git_dir" \ + "$bound_common_dir" "$bound_source_dir" "$([ "$ownership_mode" = treehouse ] && printf true || printf false)" \ + "$source_key" "$treehouse_bound_home" || { + echo "error: bound secondmate Git ownership is invalid: $home" >&2 + exit 2 + } + validate_config "$bound_common_dir/config" || { + echo "error: secondmate Git configuration is invalid: $home" >&2 + exit 2 + } + if [ "$ownership_mode" = treehouse ]; then + common_commit=$(sed -n 's/^commit=//p' "$bound_common_dir/agent-os-runtime-source") + [ "$(agent_os_bound_git "$bound_common_dir" "$bound_common_dir" "$bound_source_dir" rev-parse HEAD)" = "$common_commit" ] && \ + [ -z "$(agent_os_bound_git "$bound_common_dir" "$bound_common_dir" "$bound_source_dir" status --porcelain --untracked-files=all)" ] || { + echo "error: secondmate linked Git ownership is invalid: $home" >&2 + exit 2 + } + fi + git_policy="$bound_git_dir/agent-os-runtime-source" + home_policy="$bound_config/agent-os-source-policy" + required_policy="$bound_config/agent-os-source-policy.required" + pending_policy="$bound_config/agent-os-source-policy.pending" + for policy in "$git_policy" "$home_policy"; do + if [ -e "$policy" ]; then + validate_policy "$policy" || { + echo "error: secondmate immutable policy is invalid: $home" >&2 + exit 2 + } + fi + done + if [ -e "$required_policy" ]; then + [ -f "$required_policy" ] && [ ! -L "$required_policy" ] && \ + [ "$(cat "$required_policy")" = immutable ] || { + echo "error: secondmate immutable policy requirement is invalid: $home" >&2 + exit 2 + } + fi + if [ -e "$pending_policy" ]; then + validate_policy "$pending_policy" || { + echo "error: secondmate immutable policy journal is invalid: $home" >&2 + exit 2 + } + selected_pending=$pending_policy + elif [ -e "$required_policy" ]; then + selected_pending= + if ! { [ -e "$git_policy" ] && [ -e "$home_policy" ] && \ + cmp "$git_policy" "$home_policy"; }; then + echo "error: secondmate immutable policy is incomplete: $home" >&2 + exit 2 + fi + else + selected_pending= + if [ -e "$home_policy" ] && [ ! -e "$git_policy" ]; then + echo "error: secondmate immutable policy is incomplete: $home" >&2 + exit 2 + fi + if [ -e "$git_policy" ] && [ -e "$home_policy" ]; then + cmp "$git_policy" "$home_policy" || { + echo "error: secondmate immutable policies do not match: $home" >&2 + exit 2 + } + fi + fi + if [ -z "$selected_pending" ] && \ + [ -n "$(agent_os_bound_git "$bound_git_dir" "$bound_common_dir" "$bound_home" status --porcelain --untracked-files=all)" ]; then + echo "error: secondmate source is not clean: $home" >&2 + exit 2 + fi + validated_ids+=("$id") + validated_homes+=("$home") + treehouse_homes+=("$treehouse_home") + treehouse_bound_homes+=("$treehouse_bound_home") + ownership_modes+=("$ownership_mode") + git_dirs+=("$original_git_dir") + common_dirs+=("$original_common_dir") + source_dirs+=("$original_source_dir") + bound_homes+=("$bound_home") + bound_configs+=("$bound_config") + bound_git_dirs+=("$bound_git_dir") + bound_common_dirs+=("$bound_common_dir") + bound_source_dirs+=("$bound_source_dir") + pending_policies+=("$selected_pending") +done + +assert_secondmate_binding() { + local id=$1 ownership_mode=$2 treehouse_home=$3 home=$4 git_dir=$5 common_dir=$6 source_dir=$7 + local bound_home=$8 bound_config=$9 bound_git=${10} bound_common=${11} bound_source=${12} + local expected_treehouse_home=${13} + if [ "$ownership_mode" = treehouse ]; then + validate_treehouse_lease "$id" "$treehouse_home" "$home" || return 1 + [ "$VALIDATED_TREEHOUSE_HOME" = "$expected_treehouse_home" ] || return 1 + else + validate_primary_standalone_proof "$id" "$home" || return 1 + fi + agent_os_bound_dir_matches "$home" "$bound_home" || return 1 + agent_os_bound_dir_matches "$home/config" "$bound_config" || return 1 + agent_os_bound_dir_matches "$git_dir" "$bound_git" || return 1 + agent_os_bound_dir_matches "$common_dir" "$bound_common" || return 1 + agent_os_bound_dir_matches "$source_dir" "$bound_source" || return 1 + resolve_git_metadata "$home" || return 1 + [ "$RESOLVED_GIT_DIR" -ef "$bound_git" ] || return 1 + [ "$RESOLVED_COMMON_DIR" -ef "$bound_common" ] || return 1 + validate_bound_git_binding "$home" "$bound_home" "$bound_git" "$bound_common" \ + "$bound_source" "$([ "$ownership_mode" = treehouse ] && printf true || printf false)" \ + "${source_dir##*/}" "$expected_treehouse_home" +} + +runtime_test_barrier() { + local phase=$1 + [ "${AGENT_OS_TEST_BARRIERS:-}" = true ] || return 0 + [ "${AGENT_OS_TEST_BARRIER_PHASE:-}" = "$phase" ] || return 0 + [ -n "${AGENT_OS_TEST_BARRIER_READY:-}" ] && [ -n "${AGENT_OS_TEST_BARRIER_RELEASE:-}" ] || return 1 + printf '%s\n' "$$" > "$AGENT_OS_TEST_BARRIER_READY" + while [ ! -e "$AGENT_OS_TEST_BARRIER_RELEASE" ]; do + /bin/sleep 0.01 + done +} + +marker="mode=$SOURCE_MODE +commit=$SOURCE_COMMIT +source_sha256=$SOURCE_SHA" +for index in "${!validated_homes[@]}"; do + id=${validated_ids[$index]} + ownership_mode=${ownership_modes[$index]} + treehouse_home=${treehouse_homes[$index]} + treehouse_bound_home=${treehouse_bound_homes[$index]} + home=${validated_homes[$index]} + git_dir=${git_dirs[$index]} + common_dir=${common_dirs[$index]} + source_dir=${source_dirs[$index]} + bound_home=${bound_homes[$index]} + bound_config=${bound_configs[$index]} + bound_git_dir=${bound_git_dirs[$index]} + bound_common_dir=${bound_common_dirs[$index]} + bound_source_dir=${bound_source_dirs[$index]} + recovery_policy=${pending_policies[$index]} + assert_secondmate_binding "$id" "$ownership_mode" "$treehouse_home" "$home" "$git_dir" \ + "$common_dir" "$source_dir" "$bound_home" "$bound_config" "$bound_git_dir" \ + "$bound_common_dir" "$bound_source_dir" "$treehouse_bound_home" || { + echo "error: secondmate ownership changed before mutation: $home" >&2 + exit 2 + } + runtime_test_barrier fetch + agent_os_bound_git "$bound_git_dir" "$bound_common_dir" "$bound_home" -c protocol.file.allow=always \ + fetch --no-tags "$FM_ROOT" "$SOURCE_COMMIT" + [ "$(agent_os_bound_git "$bound_git_dir" "$bound_common_dir" "$bound_home" rev-parse FETCH_HEAD)" = "$SOURCE_COMMIT" ] || exit 2 + [ "$(agent_os_bound_git "$bound_git_dir" "$bound_common_dir" "$bound_home" rev-parse 'FETCH_HEAD^{tree}')" = "$SOURCE_TREE" ] || exit 2 + if [ -n "$recovery_policy" ] && \ + [ -n "$(agent_os_bound_git "$bound_git_dir" "$bound_common_dir" "$bound_home" status --porcelain --untracked-files=all)" ]; then + recovery_policy="$bound_config/agent-os-source-policy.pending" + recover_pending_checkout "$bound_git_dir" "$bound_common_dir" "$bound_home" "$recovery_policy" || { + echo "error: secondmate immutable policy journal cannot recover source: $home" >&2 + exit 2 + } + fi + assert_secondmate_binding "$id" "$ownership_mode" "$treehouse_home" "$home" "$git_dir" \ + "$common_dir" "$source_dir" "$bound_home" "$bound_config" "$bound_git_dir" \ + "$bound_common_dir" "$bound_source_dir" "$treehouse_bound_home" || exit 2 + runtime_test_barrier policy + pending_policy="$bound_config/agent-os-source-policy.pending" + pending_tmp="$bound_config/.agent-os-source-policy.pending.$$" + printf '%s\n' "$marker" > "$pending_tmp" + mv "$pending_tmp" "$pending_policy" + required_policy="$bound_config/agent-os-source-policy.required" + required_tmp="$bound_config/.agent-os-source-policy.required.$$" + printf 'immutable\n' > "$required_tmp" + mv "$required_tmp" "$required_policy" + assert_secondmate_binding "$id" "$ownership_mode" "$treehouse_home" "$home" "$git_dir" \ + "$common_dir" "$source_dir" "$bound_home" "$bound_config" "$bound_git_dir" \ + "$bound_common_dir" "$bound_source_dir" "$treehouse_bound_home" || exit 2 + runtime_test_barrier checkout + agent_os_bound_git "$bound_git_dir" "$bound_common_dir" "$bound_home" checkout --detach "$SOURCE_COMMIT" + runtime_test_barrier remote + agent_os_bound_git "$bound_git_dir" "$bound_common_dir" "$bound_home" remote set-url origin "$SOURCE_ORIGIN" + assert_secondmate_binding "$id" "$ownership_mode" "$treehouse_home" "$home" "$git_dir" \ + "$common_dir" "$source_dir" "$bound_home" "$bound_config" "$bound_git_dir" \ + "$bound_common_dir" "$bound_source_dir" "$treehouse_bound_home" || exit 2 + runtime_test_barrier marker + home_marker_tmp="$bound_config/.agent-os-source-policy.$$" + printf '%s\n' "$marker" > "$home_marker_tmp" + mv "$home_marker_tmp" "$bound_config/agent-os-source-policy" + marker_tmp="$bound_git_dir/agent-os-runtime-source.$$" + printf '%s\n' "$marker" > "$marker_tmp" + mv "$marker_tmp" "$bound_git_dir/agent-os-runtime-source" + cmp "$bound_config/agent-os-source-policy" "$bound_git_dir/agent-os-runtime-source" || exit 2 + [ "$(agent_os_bound_git "$bound_git_dir" "$bound_common_dir" "$bound_home" rev-parse HEAD)" = "$SOURCE_COMMIT" ] || exit 2 + [ "$(agent_os_bound_git "$bound_git_dir" "$bound_common_dir" "$bound_home" rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE" ] || exit 2 + [ -z "$(agent_os_bound_git "$bound_git_dir" "$bound_common_dir" "$bound_home" status --porcelain --untracked-files=all)" ] || exit 2 + assert_secondmate_binding "$id" "$ownership_mode" "$treehouse_home" "$home" "$git_dir" \ + "$common_dir" "$source_dir" "$bound_home" "$bound_config" "$bound_git_dir" \ + "$bound_common_dir" "$bound_source_dir" "$treehouse_bound_home" || exit 2 + rm "$pending_policy" + printf 'selected: %s\n' "$home" +done diff --git a/bin/agent-os-runtime-source.sh b/bin/agent-os-runtime-source.sh new file mode 100755 index 000000000..284018da2 --- /dev/null +++ b/bin/agent-os-runtime-source.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -eu +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH + +FM_HOME=${FM_HOME:?FM_HOME is required} +IMAGE_SOURCE=${AGENT_OS_IMAGE_SOURCE:?AGENT_OS_IMAGE_SOURCE is required} +SOURCE_COMMIT=${AGENT_OS_SOURCE_COMMIT:?AGENT_OS_SOURCE_COMMIT is required} +SOURCE_TREE=${AGENT_OS_SOURCE_TREE:?AGENT_OS_SOURCE_TREE is required} +SOURCE_SHA=${AGENT_OS_SOURCE_SHA256:?AGENT_OS_SOURCE_SHA256 is required} +SOURCE_BRANCH=${AGENT_OS_SOURCE_BRANCH:?AGENT_OS_SOURCE_BRANCH is required} +SOURCE_ORIGIN=${AGENT_OS_SOURCE_ORIGIN:?AGENT_OS_SOURCE_ORIGIN is required} +SOURCE_MODE=${AGENT_OS_SOURCE_MODE:?AGENT_OS_SOURCE_MODE is required} +GIT_BIN=/usr/bin/git + +[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "error: immutable source commit is invalid" >&2; exit 2; } +[[ "$SOURCE_TREE" =~ ^[0-9a-f]{40}$ ]] || { echo "error: immutable source tree is invalid" >&2; exit 2; } +[[ "$SOURCE_SHA" =~ ^[0-9a-f]{64}$ ]] || { echo "error: immutable source digest is invalid" >&2; exit 2; } +case "$SOURCE_MODE" in candidate|release) ;; *) echo "error: runtime source mode is not immutable" >&2; exit 2 ;; esac +[ -x "$GIT_BIN" ] || { echo "error: trusted Git executable is unavailable" >&2; exit 2; } + +trusted_git() { + env -i HOME=/nonexistent PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + "$GIT_BIN" -c credential.helper= -c core.hooksPath=/dev/null \ + -c http.proxy= -c https.proxy= "$@" +} + +runtime_root="$FM_HOME/runtime-sources" +key="$SOURCE_COMMIT-$SOURCE_SHA" +target="$runtime_root/$key" +lock="$runtime_root/.$key.materializing" +marker="mode=$SOURCE_MODE +commit=$SOURCE_COMMIT +source_sha256=$SOURCE_SHA" + +validate_source_config() { + local root=$1 config section='' line key value token seen='' + config="$root/.git/config" + [ -f "$config" ] && [ ! -L "$config" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|'#'*|';'*) continue ;; + '[core]') section=core; continue ;; + '[remote "origin"]') section=remote.origin; continue ;; + "[branch \"$SOURCE_BRANCH\"]") section="branch.$SOURCE_BRANCH"; continue ;; + '['*) return 1 ;; + esac + case "$line" in *=*) ;; *) return 1 ;; esac + key=${line%%=*} + value=${line#*=} + key=$(printf '%s' "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + token="$section.$key" + case "|$seen|" in *"|$token|"*) return 1 ;; esac + case "$token" in + core.repositoryformatversion) [ "$value" = 0 ] ;; + core.filemode) [ "$value" = true ] || [ "$value" = false ] ;; + core.bare) [ "$value" = false ] ;; + core.logallrefupdates) [ "$value" = true ] ;; + core.ignorecase) [ "$value" = true ] || [ "$value" = false ] ;; + core.precomposeunicode) [ "$value" = true ] || [ "$value" = false ] ;; + remote.origin.url) [ "$value" = "$SOURCE_ORIGIN" ] ;; + remote.origin.fetch) + [ "$value" = '+refs/heads/*:refs/remotes/origin/*' ] || + [ "$value" = "+refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH" ] + ;; + "branch.$SOURCE_BRANCH.remote") [ "$value" = origin ] ;; + "branch.$SOURCE_BRANCH.merge") [ "$value" = "refs/heads/$SOURCE_BRANCH" ] ;; + *) return 1 ;; + esac || return 1 + seen="${seen:+$seen|}$token" + done < "$config" + for token in core.repositoryformatversion core.filemode core.bare core.logallrefupdates \ + remote.origin.url remote.origin.fetch "branch.$SOURCE_BRANCH.remote" "branch.$SOURCE_BRANCH.merge"; do + case "|$seen|" in *"|$token|"*) ;; *) return 1 ;; esac + done +} + +validate_source() { + local root=$1 actual_marker + [ -d "$root/.git" ] && [ ! -L "$root/.git" ] || { + echo "error: immutable runtime source Git metadata is unavailable" >&2 + return 1 + } + validate_source_config "$root" || { + echo "error: immutable runtime source Git configuration is invalid" >&2 + return 1 + } + [ "$(trusted_git -C "$root" rev-parse HEAD)" = "$SOURCE_COMMIT" ] || return 1 + [ "$(trusted_git -C "$root" rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE" ] || return 1 + [ "$(trusted_git -C "$root" symbolic-ref --quiet --short HEAD)" = "$SOURCE_BRANCH" ] || return 1 + [ "$(trusted_git -C "$root" remote get-url origin)" = "$SOURCE_ORIGIN" ] || return 1 + [ -z "$(trusted_git -C "$root" status --porcelain --untracked-files=all)" ] || return 1 + [ -f "$root/.git/agent-os-runtime-source" ] || return 1 + actual_marker=$(cat "$root/.git/agent-os-runtime-source") + [ "$actual_marker" = "$marker" ] || return 1 + ! find "$root/.git/hooks" -mindepth 1 -print -quit 2>/dev/null | grep -q . || return 1 +} + +mkdir -p "$runtime_root" +if [ -e "$target" ]; then + validate_source "$target" || { echo "error: immutable runtime source failed exact verification" >&2; exit 2; } + printf '%s\n' "$target" + exit 0 +fi + +if ! mkdir "$lock" 2>/dev/null; then + if [ -e "$target" ] && validate_source "$target"; then + printf '%s\n' "$target" + exit 0 + fi + echo "error: partial immutable runtime source materialization requires recovery" >&2 + exit 2 +fi + +lock_owner="$SOURCE_COMMIT-$$-$(date +%s)-$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')" +printf '%s\n' "$lock_owner" > "$lock/owner" +partial="$lock/source" +if ! trusted_git -c protocol.file.allow=always clone --no-local --branch "$SOURCE_BRANCH" "$IMAGE_SOURCE" "$partial"; then + echo "error: immutable runtime source materialization failed" >&2 + exit 2 +fi +trusted_git -C "$partial" remote set-url origin "$SOURCE_ORIGIN" +rm -rf "$partial/.git/hooks" +printf '%s\n' "$marker" > "$partial/.git/agent-os-runtime-source" +validate_source "$partial" || { echo "error: materialized immutable runtime source failed exact verification" >&2; exit 2; } +mv "$partial" "$target" +validate_source "$target" || { echo "error: committed immutable runtime source failed exact verification" >&2; exit 2; } +if [ -f "$lock/owner" ] && [ "$(cat "$lock/owner")" = "$lock_owner" ]; then + rm "$lock/owner" + rmdir "$lock" +else + validate_source "$target" || { echo "error: immutable runtime source lock handoff failed" >&2; exit 2; } +fi +printf '%s\n' "$target" diff --git a/bin/agent-os-source-bundle.sh b/bin/agent-os-source-bundle.sh new file mode 100755 index 000000000..9474fb180 --- /dev/null +++ b/bin/agent-os-source-bundle.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +set -eu + +SCRIPT_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +ROOT=$(cd "${AGENT_OS_SOURCE_ROOT:-$SCRIPT_ROOT}" && pwd) +OUTPUT_DIR=${1:-"$ROOT/image"} +SOURCE_MODE=${AGENT_OS_SOURCE_MODE:-main} +SOURCE_BRANCH=main +SOURCE_ORIGIN=https://github.com/akua-dev/agent-os.git +SOURCE_REF=refs/heads/main +RELEASE_TAG=${AGENT_OS_SOURCE_RELEASE_TAG:-} +RELEASE_RECORD_COMMIT=${AGENT_OS_RELEASE_RECORD_COMMIT:-} +RELEASE_BOOTSTRAP_ARCHIVE=${AGENT_OS_RELEASE_BOOTSTRAP_ARCHIVE:-} +EVENT_COMMIT=${AGENT_OS_SOURCE_EVENT_COMMIT:-} +SOURCE_ARCHIVE="$OUTPUT_DIR/agent-os-source.tar" +BOOTSTRAP_ARCHIVE="$OUTPUT_DIR/agent-os-bootstrap.tar" +ATTESTATION="$OUTPUT_DIR/agent-os-source.attestation" +TEMP="$OUTPUT_DIR/.agent-os-source.$$" +GIT_BIN=/usr/bin/git +[ -x "$GIT_BIN" ] || { echo "error: trusted Git executable is unavailable" >&2; exit 2; } + +git_isolated() { + env -i HOME=/nonexistent PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + "$GIT_BIN" -c credential.helper= -c core.hooksPath=/dev/null \ + -c http.proxy= -c https.proxy= "$@" +} + +canonical_github_origin() { + case "$1" in + https://github.com/akua-dev/agent-os|https://github.com/akua-dev/agent-os.git) + printf '%s' "$SOURCE_ORIGIN" + ;; + *) return 1 ;; + esac +} + +validate_source_git_config() { + local key value origin worktree_config worktree_config_enabled worktree_keys + while IFS= read -r key; do + [ -n "$key" ] || continue + value=$(git_isolated -C "$ROOT" config --local --no-includes --get-all "$key" || true) + [ -n "$value" ] && [ "$value" = "$(printf '%s\n' "$value" | head -n 1)" ] || { + echo "error: source repository Git config key is duplicated or empty" >&2 + exit 2 + } + case "$key" in + core.repositoryformatversion) [ "$value" = 0 ] ;; + core.filemode|core.ignorecase|core.precomposeunicode|core.logallrefupdates|core.bare) + [ "$value" = true ] || [ "$value" = false ] + ;; + extensions.worktreeconfig|receive.advertisepushoptions) + [ "$value" = true ] || [ "$value" = false ] + ;; + gc.auto) [ "$value" = 0 ] ;; + maintenance.auto) [ "$value" = false ] ;; + remote.origin.url) canonical_github_origin "$value" >/dev/null ;; + remote.origin.fetch) [ "$value" = '+refs/heads/*:refs/remotes/origin/*' ] ;; + remote.origin.tagopt) [ "$value" = --no-tags ] ;; + branch.main.remote) [ "$value" = origin ] ;; + branch.main.merge) [ "$value" = refs/heads/main ] ;; + *) echo "error: source repository Git config key is not allowlisted: $key" >&2; exit 2 ;; + esac || { echo "error: source repository Git config value is invalid: $key" >&2; exit 2; } + done < <(git_isolated -C "$ROOT" config --local --no-includes --name-only --list) + worktree_config_enabled=$(git_isolated -C "$ROOT" config --local --type=bool --get extensions.worktreeconfig || true) + if [ "$worktree_config_enabled" = true ]; then + worktree_config=$(git_isolated -C "$ROOT" rev-parse --path-format=absolute --git-path config.worktree) + if [ -f "$worktree_config" ]; then + worktree_keys=$(git_isolated config --file "$worktree_config" --no-includes --name-only --list) + [ -z "$worktree_keys" ] || { + echo "error: source repository worktree Git config is not allowed" >&2 + exit 2 + } + fi + fi + origin=$(git_isolated -C "$ROOT" config --local --no-includes --get remote.origin.url || true) + [ "$(canonical_github_origin "$origin" 2>/dev/null || true)" = "$SOURCE_ORIGIN" ] || { + echo "error: repository origin is not the exact trusted HTTPS origin" >&2 + exit 2 + } +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +sha256_stream() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum | awk '{print $1}'; else shasum -a 256 | awk '{print $1}'; fi +} + +validate_source_git_config +[ -z "$(git_isolated -C "$ROOT" status --porcelain --untracked-files=all)" ] || { + echo "error: exact-source image builds require a clean worktree" >&2 + exit 2 +} + +mkdir -p "$OUTPUT_DIR" +rm -rf "$TEMP" +mkdir -p "$TEMP" +trap 'rm -rf "$TEMP" "$SOURCE_ARCHIVE.tmp.$$" "$BOOTSTRAP_ARCHIVE.tmp.$$" "$ATTESTATION.tmp.$$"' EXIT +git_isolated init --bare "$TEMP/bootstrap.git" >/dev/null + +case "$SOURCE_MODE" in + main|candidate) + git_isolated --git-dir="$TEMP/bootstrap.git" fetch --depth=1 --no-tags "$SOURCE_ORIGIN" \ + refs/heads/main:refs/heads/main + ;; + event) + [ "${GITHUB_EVENT_NAME:-}" = pull_request ] && [[ "$EVENT_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { + echo "error: event source mode is restricted to an exact pull-request event commit" >&2 + exit 2 + } + [ "$(git_isolated -C "$ROOT" rev-parse --verify HEAD)" = "$EVENT_COMMIT" ] || { + echo "error: event checkout does not match the declared pull-request head" >&2 + exit 2 + } + git_isolated -c protocol.file.allow=always --git-dir="$TEMP/bootstrap.git" fetch --depth=1 --no-tags \ + "file://$ROOT" "$EVENT_COMMIT:refs/heads/main" + SOURCE_REF="event:$EVENT_COMMIT" + ;; + release) + [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] && \ + [[ "$RELEASE_RECORD_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { + echo "error: release mode requires a canonical semver tag and immutable record commit" >&2 + exit 2 + } + command -v jq >/dev/null 2>&1 || { echo "error: jq is required for release records" >&2; exit 2; } + [ -f "$RELEASE_BOOTSTRAP_ARCHIVE" ] || { + echo "error: release mode requires the retained candidate bootstrap archive" >&2 + exit 2 + } + git_isolated --git-dir="$TEMP/bootstrap.git" fetch --depth=1 --no-tags "$SOURCE_ORIGIN" \ + "$RELEASE_RECORD_COMMIT:refs/agent-os/release-record" + git_isolated --git-dir="$TEMP/bootstrap.git" fetch --depth=1 --no-tags "$SOURCE_ORIGIN" \ + "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + [ "$(git_isolated --git-dir="$TEMP/bootstrap.git" cat-file -t "refs/tags/$RELEASE_TAG")" = tag ] || { + echo "error: release tag must be annotated" >&2 + exit 2 + } + git_isolated --git-dir="$TEMP/bootstrap.git" show "refs/agent-os/release-record:image/releases/$RELEASE_TAG.json" > "$TEMP/release.json" + jq -e --arg tag "$RELEASE_TAG" ' + .tag == $tag and (.commit|test("^[0-9a-f]{40}$")) and (.tree|test("^[0-9a-f]{40}$")) and + (.source_archive_sha256|test("^[0-9a-f]{64}$")) and (.package_sha256|test("^[0-9a-f]{64}$")) and + (.bootstrap_archive_sha256|test("^[0-9a-f]{64}$")) and + (.schema_sha256|test("^[0-9a-f]{64}$")) and (.image_digest|test("^sha256:[0-9a-f]{64}$")) and + (.sbom_sha256|test("^[0-9a-f]{64}$")) and (.provenance_sha256|test("^[0-9a-f]{64}$")) and + (.buildkit_outputs_sha256|test("^[0-9a-f]{64}$")) and + (.platform_manifests_sha256|test("^[0-9a-f]{64}$")) and + (.candidate_record_digest|test("^sha256:[0-9a-f]{64}$")) and + .source_mode == "candidate" and + .image_repository == "ghcr.io/akua-dev/agent-os" and + (.platform_manifests|type == "array" and length == 2) and + ([.platform_manifests[].platform] | sort == ["linux/amd64","linux/arm64"]) and + (all(.platform_manifests[]; (.digest|test("^sha256:[0-9a-f]{64}$")) and + (.mediaType|type == "string" and length > 0) and (.size|type == "number" and . > 0))) and + (.quickstart_sha256|test("^[0-9a-f]{64}$")) and (.tag_ruleset_id|type == "number") and + (.tag_ruleset_sha256|test("^[0-9a-f]{64}$"))' "$TEMP/release.json" >/dev/null || { + echo "error: release record is incomplete or malformed" >&2 + exit 2 + } + SOURCE_REF="refs/tags/$RELEASE_TAG" + git_isolated --git-dir="$TEMP/bootstrap.git" update-ref refs/heads/main "refs/tags/$RELEASE_TAG^{}" + ;; + *) echo "error: unsupported source mode" >&2; exit 2 ;; +esac + +SOURCE_COMMIT=$(git_isolated --git-dir="$TEMP/bootstrap.git" rev-parse --verify refs/heads/main) +SOURCE_TREE=$(git_isolated --git-dir="$TEMP/bootstrap.git" rev-parse --verify "$SOURCE_COMMIT^{tree}") +if [ "$SOURCE_MODE" = candidate ]; then + SOURCE_REF="refs/agent-os/candidates/$SOURCE_COMMIT" +fi +if [ "$SOURCE_MODE" = main ] || [ "$SOURCE_MODE" = candidate ]; then + [ "$(git_isolated -C "$ROOT" rev-parse --verify HEAD)" = "$SOURCE_COMMIT" ] && \ + [ "$(git_isolated -C "$ROOT" rev-parse --verify 'HEAD^{tree}')" = "$SOURCE_TREE" ] || { + echo "error: local source does not exactly match the freshly fetched trusted source ref" >&2 + exit 2 + } +elif [ "$SOURCE_MODE" = release ]; then + [ "$(jq -r .commit "$TEMP/release.json")" = "$SOURCE_COMMIT" ] && \ + [ "$(jq -r .tree "$TEMP/release.json")" = "$SOURCE_TREE" ] || { + echo "error: release tag moved or differs from its immutable record" >&2 + exit 2 + } +fi + +git_isolated init --bare "$TEMP/sanitized.git" >/dev/null +git_isolated -c protocol.file.allow=always --git-dir="$TEMP/sanitized.git" fetch --depth=1 --no-tags \ + "file://$TEMP/bootstrap.git" "$SOURCE_COMMIT:refs/heads/main" +rm -rf "$TEMP/bootstrap.git" +mv "$TEMP/sanitized.git" "$TEMP/bootstrap.git" + +git_isolated --git-dir="$TEMP/bootstrap.git" symbolic-ref HEAD refs/heads/main +git_isolated --git-dir="$TEMP/bootstrap.git" remote add origin "$SOURCE_ORIGIN" +rm -rf "$TEMP/bootstrap.git/hooks" "$TEMP/bootstrap.git/logs" +rm -f "$TEMP/bootstrap.git/FETCH_HEAD" "$TEMP/bootstrap.git/ORIG_HEAD" +git_isolated --git-dir="$TEMP/bootstrap.git" archive --format=tar --output="$SOURCE_ARCHIVE.tmp.$$" "$SOURCE_COMMIT" +if [ "$SOURCE_MODE" = release ]; then + [ "$(sha256_file "$SOURCE_ARCHIVE.tmp.$$")" = "$(jq -r .source_archive_sha256 "$TEMP/release.json")" ] && \ + [ "$(git_isolated --git-dir="$TEMP/bootstrap.git" show "$SOURCE_COMMIT:tools/agent-os/packages/firstmate/package.k" | sha256_stream)" = "$(jq -r .package_sha256 "$TEMP/release.json")" ] && \ + [ "$(git_isolated --git-dir="$TEMP/bootstrap.git" show "$SOURCE_COMMIT:tools/agent-os/packages/firstmate/inputs.example.yaml" | sha256_stream)" = "$(jq -r .schema_sha256 "$TEMP/release.json")" ] && \ + [ "$(git_isolated --git-dir="$TEMP/bootstrap.git" show "$SOURCE_COMMIT:docs/kubernetes.md" | sha256_stream)" = "$(jq -r .quickstart_sha256 "$TEMP/release.json")" ] || { + echo "error: release source artifacts differ from the immutable release record" >&2 + exit 2 + } +fi +if [ "$SOURCE_MODE" = release ]; then + cp -- "$RELEASE_BOOTSTRAP_ARCHIVE" "$BOOTSTRAP_ARCHIVE.tmp.$$" +else + (cd "$TEMP" && find bootstrap.git -exec touch -t 197001010000 {} + && \ + find bootstrap.git -print | LC_ALL=C sort | COPYFILE_DISABLE=1 tar -cf "$BOOTSTRAP_ARCHIVE.tmp.$$" -T -) +fi +SOURCE_SHA=$(sha256_file "$SOURCE_ARCHIVE.tmp.$$") +BOOTSTRAP_SHA=$(sha256_file "$BOOTSTRAP_ARCHIVE.tmp.$$") +if [ "$SOURCE_MODE" = release ] && [ "$BOOTSTRAP_SHA" != "$(jq -r .bootstrap_archive_sha256 "$TEMP/release.json")" ]; then + echo "error: retained bootstrap archive differs from the immutable release record" >&2 + exit 2 +fi +if [ "$SOURCE_MODE" = release ]; then + mkdir "$TEMP/retained-bootstrap" + tar -xf "$BOOTSTRAP_ARCHIVE.tmp.$$" -C "$TEMP/retained-bootstrap" + [ "$(git_isolated --git-dir="$TEMP/retained-bootstrap/bootstrap.git" rev-parse refs/heads/main)" = "$SOURCE_COMMIT" ] && \ + [ "$(git_isolated --git-dir="$TEMP/retained-bootstrap/bootstrap.git" rev-parse "$SOURCE_COMMIT^{tree}")" = "$SOURCE_TREE" ] && \ + [ "$(git_isolated --git-dir="$TEMP/retained-bootstrap/bootstrap.git" remote get-url origin)" = "$SOURCE_ORIGIN" ] || { + echo "error: retained bootstrap archive provenance is invalid" >&2 + exit 2 + } +fi +{ + printf 'mode=%s\ncommit=%s\ntree=%s\nbranch=%s\norigin=%s\nref=%s\n' \ + "$SOURCE_MODE" "$SOURCE_COMMIT" "$SOURCE_TREE" "$SOURCE_BRANCH" "$SOURCE_ORIGIN" "$SOURCE_REF" + printf 'source_sha256=%s\nbootstrap_sha256=%s\n' "$SOURCE_SHA" "$BOOTSTRAP_SHA" + if [ "$SOURCE_MODE" = release ]; then + printf 'release_record_commit=%s\nrelease_record_sha256=%s\n' \ + "$RELEASE_RECORD_COMMIT" "$(sha256_file "$TEMP/release.json")" + fi +} > "$ATTESTATION.tmp.$$" +mv "$SOURCE_ARCHIVE.tmp.$$" "$SOURCE_ARCHIVE" +mv "$BOOTSTRAP_ARCHIVE.tmp.$$" "$BOOTSTRAP_ARCHIVE" +mv "$ATTESTATION.tmp.$$" "$ATTESTATION" +trap - EXIT +rm -rf "$TEMP" +cat "$ATTESTATION" diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 7a2b2a9f9..1daf373d8 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -43,8 +43,10 @@ # "treehouse get --lease" support. # no-mistakes is also MISSING when its installed version is older than # 1.31.2. -# tasks-axi and quota-axi are required bootstrap tools (same class as -# lavish-axi). tasks-axi is also version and feature gated (0.1.1+ +# Akua is optional and required only when an Agent OS Kubernetes or +# Akua integration path invokes it. +# tasks-axi and quota-axi are required bootstrap tools in the same class as +# lavish-axi. tasks-axi is also version and feature gated (0.1.1+ # with update --archive-body and mv [...]); an installed but incompatible build # reports MISSING like no-mistakes. When # config/backlog-backend is not manual and tasks-axi is compatible, @@ -314,6 +316,7 @@ install_cmd() { tmux|node|git|gh|curl|jq|orca) echo "brew install $1 # or the platform's package manager" ;; treehouse) echo "curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh" ;; no-mistakes) echo "curl -fsSL https://raw.githubusercontent.com/kunchenguid/no-mistakes/main/docs/install.sh | sh" ;; + akua) echo "curl -fsSL https://cli.akua.dev/install | sh" ;; gh-axi|chrome-devtools-axi|lavish-axi) echo "npm install -g $1 && $1 setup hooks" ;; tasks-axi|quota-axi) echo "npm install -g $1" ;; *) return 1 ;; diff --git a/bin/fm-update.sh b/bin/fm-update.sh index c3b0e674f..87eb97ad6 100755 --- a/bin/fm-update.sh +++ b/bin/fm-update.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Self-update a running firstmate and its secondmates to the latest origin. +# Update a running firstmate and its secondmates according to source policy. # -# Mechanical half of the /updatefirstmate skill. Fast-forwards the running -# firstmate repo's default branch from origin, then fast-forwards every +# Mechanical half of the /updatefirstmate skill. Mutable sources fast-forward +# the running firstmate repo's default branch from origin, then fast-forward every # registered secondmate home (each a treehouse worktree of this same repo, or # a standalone clone) the same way. FAST-FORWARD ONLY, exactly like # fm-fleet-sync.sh: never force, never create a merge commit, never stash; @@ -14,6 +14,8 @@ # fetched on their own. Secondmate homes are leased at a detached HEAD on the # default branch, so a fast-forward there advances HEAD only and never touches # any other worktree's checkout or the shared `main` branch. +# Candidate and release image sources carry persisted immutable provenance; +# this script refuses those sources before any fetch or checkout mutation. # # The fast-forward mechanics live in bin/fm-ff-lib.sh (base_mode "origin" here); # the same library drives the local-HEAD secondmate sync used by fm-spawn.sh and @@ -47,6 +49,105 @@ if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then fi [ $# -eq 0 ] || { usage; exit 1; } +persisted_policy=fast-forward +resolve_policy_git_dir() { + local root=$1 pointer + if [ -d "$root/.git" ] && [ ! -L "$root/.git" ]; then + POLICY_GIT_DIR=$(cd "$root/.git" && pwd -P) + elif [ -f "$root/.git" ] && [ ! -L "$root/.git" ]; then + [ "$(wc -l < "$root/.git" | tr -d ' ')" -eq 1 ] || return 1 + pointer=$(sed -n 's/^gitdir: //p' "$root/.git") + [ -n "$pointer" ] || return 1 + case "$pointer" in + /*) POLICY_GIT_DIR=$(cd "$pointer" 2>/dev/null && pwd -P) || return 1 ;; + *) POLICY_GIT_DIR=$(cd "$root/$(dirname "$pointer")" 2>/dev/null && printf '%s/%s\n' "$(pwd -P)" "$(basename "$pointer")") || return 1 ;; + esac + else + return 1 + fi +} + +POLICY_GIT_DIR= +resolve_policy_git_dir "$FM_ROOT" || true +policy_file=${POLICY_GIT_DIR:+$POLICY_GIT_DIR/agent-os-runtime-source} +home_policy_file="$FM_ROOT/config/agent-os-source-policy" +required_policy_file="$FM_ROOT/config/agent-os-source-policy.required" +pending_policy_file="$FM_ROOT/config/agent-os-source-policy.pending" +secondmate_home=false +if [ -e "$FM_ROOT/.fm-secondmate-home" ]; then + [ -f "$FM_ROOT/.fm-secondmate-home" ] && [ ! -L "$FM_ROOT/.fm-secondmate-home" ] || { + echo "error: invalid secondmate home provenance" >&2 + exit 2 + } + secondmate_home=true +fi +if [ "$secondmate_home" = true ] && \ + { [ -e "$policy_file" ] || [ -e "$home_policy_file" ] || \ + [ -e "$required_policy_file" ] || [ -e "$pending_policy_file" ]; }; then + [ ! -e "$pending_policy_file" ] || { + echo "error: immutable source provenance transition is incomplete" >&2 + exit 2 + } + if [ -e "$required_policy_file" ]; then + [ -f "$required_policy_file" ] && [ ! -L "$required_policy_file" ] && \ + [ "$(cat "$required_policy_file")" = immutable ] || { + echo "error: invalid immutable source provenance requirement" >&2 + exit 2 + } + fi + [ -f "$policy_file" ] && [ ! -L "$policy_file" ] && \ + [ -f "$home_policy_file" ] && [ ! -L "$home_policy_file" ] || { + echo "error: immutable source provenance is incomplete" >&2 + exit 2 + } + cmp "$policy_file" "$home_policy_file" >/dev/null || { + echo "error: immutable source provenance does not match the secondmate home policy" >&2 + exit 2 + } +elif [ "$secondmate_home" = false ] && \ + { [ -e "$home_policy_file" ] || [ -e "$required_policy_file" ] || [ -e "$pending_policy_file" ]; }; then + echo "error: immutable source provenance exists outside a secondmate home" >&2 + exit 2 +fi +if [ -f "$policy_file" ]; then + policy_mode=$(sed -n 's/^mode=//p' "$policy_file") + policy_commit=$(sed -n 's/^commit=//p' "$policy_file") + policy_source_sha=$(sed -n 's/^source_sha256=//p' "$policy_file") + case "$policy_mode" in candidate|release) ;; *) echo "error: invalid immutable source provenance" >&2; exit 2 ;; esac + [[ "$policy_commit" =~ ^[0-9a-f]{40}$ ]] && [[ "$policy_source_sha" =~ ^[0-9a-f]{64}$ ]] || { + echo "error: invalid immutable source provenance" >&2 + exit 2 + } + if [ "${FM_ROOT#"$FM_HOME/runtime-sources/"}" != "$FM_ROOT" ]; then + expected_root="$FM_HOME/runtime-sources/$policy_commit-$policy_source_sha" + [ "$(cd "$FM_ROOT" && pwd -P)" = "$(cd "$expected_root" 2>/dev/null && pwd -P)" ] || { + echo "error: immutable source provenance does not match FM_ROOT" >&2 + exit 2 + } + else + [ -f "$FM_ROOT/.fm-secondmate-home" ] && [ ! -L "$FM_ROOT/.fm-secondmate-home" ] || { + echo "error: immutable source provenance is outside a verified runtime home" >&2 + exit 2 + } + fi + persisted_policy=immutable +elif [ "${FM_ROOT#"$FM_HOME/runtime-sources/"}" != "$FM_ROOT" ]; then + echo "error: immutable source provenance is unavailable" >&2 + exit 2 +fi + +case "$persisted_policy" in + fast-forward) ;; + immutable) + echo "error: self-update is disabled for immutable image source" >&2 + exit 2 + ;; + *) + echo "error: invalid Agent OS source update policy" >&2 + exit 2 + ;; +esac + # --- main firstmate repo --------------------------------------------------- reread_firstmate="no" diff --git a/deploy/akua/firstmate-auth-grant.yaml b/deploy/akua/firstmate-auth-grant.yaml new file mode 100644 index 000000000..bc71d7b5b --- /dev/null +++ b/deploy/akua/firstmate-auth-grant.yaml @@ -0,0 +1,21 @@ +metadata: + annotations: + agent-os.dev/akua-auth-secret: __AKUA_AUTH_SECRET__ + agent-os.dev/akua-auth-rejected-record: null +spec: + template: + spec: + containers: + - name: firstmate + env: + - name: AKUA_AUTH_HEADER_FILE + value: /var/run/secrets/agent-os/akua/authorization + volumeMounts: + - name: akua-auth + mountPath: /var/run/secrets/agent-os/akua + readOnly: true + volumes: + - name: akua-auth + secret: + secretName: __AKUA_AUTH_SECRET__ + defaultMode: 256 diff --git a/deploy/akua/firstmate-auth-revoke.yaml b/deploy/akua/firstmate-auth-revoke.yaml new file mode 100644 index 000000000..146557654 --- /dev/null +++ b/deploy/akua/firstmate-auth-revoke.yaml @@ -0,0 +1,18 @@ +metadata: + annotations: + agent-os.dev/akua-auth-secret: null + agent-os.dev/akua-auth-rejected-record: null +spec: + template: + spec: + containers: + - name: firstmate + env: + - name: AKUA_AUTH_HEADER_FILE + $patch: delete + volumeMounts: + - mountPath: /var/run/secrets/agent-os/akua + $patch: delete + volumes: + - name: akua-auth + $patch: delete diff --git a/deploy/orbstack/inputs.yaml b/deploy/orbstack/inputs.yaml new file mode 100644 index 000000000..31c932636 --- /dev/null +++ b/deploy/orbstack/inputs.yaml @@ -0,0 +1,7 @@ +# Local profile for OrbStack's image store and isolated demo cluster. +namespace: agent-os-demo +image: agent-os:dev +imagePullPolicy: Never +allowMutableImage: true +rbac: cluster-admin +storage: 20Gi diff --git a/docs/acceptance.md b/docs/acceptance.md new file mode 100644 index 000000000..1851c6b64 --- /dev/null +++ b/docs/acceptance.md @@ -0,0 +1,30 @@ +# Agent OS acceptance ledger + +This ledger maps the Cortex distributed-Firstmate acceptance contract to current claim-matched evidence. +Update it from real runs; planned behavior and model narration never count as proof. + +| Requirement | Current evidence | State | +| --- | --- | --- | +| Portable Kubernetes core without Akua | `tools/agent-os/packages/firstmate/` and focused render/behavior tests prove a digest-required package, namespace-scoped default RBAC, persistent home, explicit lifecycle commands, and no package credential input | Render and command contracts proven; clean published-image run remains required | +| Persistent Firstmate and Herdr in Kubernetes | Canonical package render plus local lifecycle records in `docs/evidence/2026-07-12-firstmate-package.md` | Package render and local evidence proven; published-image run remains required | +| Firstmate cluster-admin limited to the intelligence cluster | Dedicated local namespace and explicit demo ClusterRoleBinding | Proven locally only | +| Direct Akua-managed KaaS and Hetzner bootstrap | Public endpoint study and `akua-intelligence-bootstrap` skill | Not yet run | +| Distinct clustered token and bootstrap-token revocation | Separate namespace-local Akua grant/revoke overlay and handoff procedure; the public package remains credential-free | Overlay contract implemented; live handoff and revocation not yet run | +| Firstmate-native Akua worker lifecycle | Native endpoint routing in the bootstrap skill | Not yet run | +| Replaceable usable model supply | `openai-codex/gpt-5.4-mini` completed the same-Pod task; Terra-low completed the separate-Pod and recovery tasks; the current local overlay converges direct Pi, crewmate, and Secondmate defaults to `openai-codex/gpt-5.6-terra` with low thinking | One provider with multiple models proven locally; replacement provider still unproven | +| Same-Pod general-purpose crewmate | Firstmate-supervised Pi scout, real Kubernetes test, in-cluster context check, and report in `docs/evidence/2026-07-13-same-pod-firstmate.md` | Proven locally | +| Separate persistent crewmate Pod with explicit authority | Terra-low task, explicit resources, read-only Pi Secret, no ServiceAccount token, and retained PVC in `docs/evidence/2026-07-13-separate-pod-recovery.md` | Proven locally | +| Parent-only supervision and human attach | Same-Pod and separate-Pod tasks accepted only Firstmate briefs and steers; a connected Herdr client observed the live workspaces | Proven locally | +| Real issue to tested review-ready PR | A private Cortex tracker issue became Terra-low task `fix-local-rebuild-image-r2`, tested commit `761223c`, and public PR [#3](https://github.com/akua-dev/agent-os/pull/3), then merged into review branch PR #1; see `docs/evidence/2026-07-13-github-issue-pr.md` | Proven locally | +| Parent and child restart without unique-work loss | Primary session recovery plus separate-Pod replacement with unique artifact, report, tools, and exact Pi session resumed | Proven locally | +| Separate production read-only and scoped write identity | Cortex contract only | Not yet implemented or Red-approved | +| Optional GitHub Issue/Project client | Firstmate read a private Cortex GitHub issue through `gh-axi` and delivered public PR #3 with no custom intake service; public Agent OS Issues are disabled because work tracking is private | Issue client proven locally; Project client remains optional and unproven | +| Repeatable end-to-end eval and recordable demo | Same-Pod and separate-Pod run records now capture model, time, cost estimate, interventions, resource IDs, and failures | Local agent lifecycle captured; Akua/GitHub/product path incomplete | +| Public installable image and release | Multi-architecture release workflow builds `linux/amd64` and `linux/arm64`, publishes only outside pull requests, and records the resulting digest in its job summary | Publication not yet proven | +| Herdr 0.7.3 source and notice bundle | `THIRD_PARTY_NOTICES.md`, `THIRD_PARTY_SOURCES.md`, the image documentation paths, and `tests/agent-os-container.test.sh` prove the unmodified-binary source offer and notice contract | Source and render contract proven; publication availability remains required | + +## Definition of done + +Agent OS is finished only when every row is backed by current external evidence and no row remains partial or unproven. +The portable Kubernetes gate and Akua integration gate are separate: the core must pass from published sources on local OrbStack without Akua, while the enhanced path separately proves Akua-managed bootstrap and guarded delivery. +The complete run must record time-to-cluster, time-to-Firstmate, time-to-crewmate, time-to-PR, recovery time, model and infrastructure cost, human interventions, immutable image digest, resource and operation IDs, and sanitized failure evidence. diff --git a/docs/agent-evals.md b/docs/agent-evals.md new file mode 100644 index 000000000..3696982c7 --- /dev/null +++ b/docs/agent-evals.md @@ -0,0 +1,48 @@ +# Agent OS evaluation strategy + +This evaluation strategy was established on 2026-07-12 from failures observed in the live OrbStack demo. + +## What to measure + +Agent OS must be evaluated as an operating system around agents, not as a standalone model benchmark. +The primary score is whether the requested external state and safety constraints hold after a multi-step run. +Transcripts explain failures but do not override an incorrect outcome. + +The first regression set covers these observed contracts: + +- a mate receives no AI credential unless an explicit namespace-local Secret reference is selected; +- a missing credential grant is rejected before any mate resource is created; +- a granted Pi auth file is mounted read-only from a Kubernetes Secret; +- an in-cluster kubeconfig follows the projected token file and never embeds its contents; +- a restored dead Herdr terminal may be replaced, while an idle or working agent may not; +- completion is the declared artifact, not a transient Herdr `idle` status; +- package output still denies the mate a Kubernetes token by default. + +These are deterministic code and environment graders. +The address contract runs in Bun tests, container and kubeconfig boundaries run in shell tests, package behavior is checked statically and with a direct Akua render smoke test, and the complete lifecycle is exercised in the local Kubernetes demo. + +## External research + +[Anthropic's agent-eval guidance](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents) recommends combining code-based, model-based, and human graders and separating capability evals from near-100-percent regression evals. +That matches Agent OS: deterministic resource and artifact assertions protect known behavior, while transcript review and later model graders evaluate ambiguous instruction-following quality. + +[OpenAI's agent eval guidance](https://developers.openai.com/api/docs/guides/agent-evals) provides datasets and trace grading that can later compare prompts, skills, models, and harness versions. +It is not needed to prove Kubernetes mounts, credential boundaries, or completion files. + +[Inspect](https://inspect.aisi.org.uk/) supports external agents, transcript inspection, multiple scorers, and Docker or Kubernetes sandbox extensions. +It is the strongest candidate when Agent OS needs repeatable cross-model or cross-harness capability suites. +Adding its Python runtime today would duplicate the existing deterministic test harness without improving the current regression signal. + +Public benchmarks such as tau-bench and SWE-bench measure useful general capabilities but do not exercise Agent OS's parent supervision, Kubernetes isolation, credential grants, Herdr recovery, or artifact delivery contracts. +They should inform model selection, not replace product-specific evals. + +## Herdr Spreader evaluation + +[`yuk1ty/herdr-spreader`](https://github.com/yuk1ty/herdr-spreader) was inspected at commit `1a42aae0f0dfb3a588da0dc6895ea24189de4012`. +It uses public Herdr CLI and plugin primitives to create declarative workspace, tab, pane, command, wait, environment, and focus layouts. +It is useful when one mate Pod needs several agents or supporting processes. +It does not create Kubernetes mates, supervise parent-child delivery, reconcile an existing layout, or remove stale terminals. +Repeated apply creates more workspaces rather than converging desired state. + +Do not make Herdr Spreader mandatory yet. +If repeated multi-process mate layouts emerge, add an optional trusted layout ConfigMap and invoke the spreader once after Herdr is ready. diff --git a/docs/architecture.md b/docs/architecture.md index ab23db09c..d3e07b8c8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -218,10 +218,8 @@ The refresh also prunes local branches whose remote is gone and that no worktree ## Self-updates stay safe -`/updatefirstmate` fast-forwards the running firstmate repo and registered secondmate homes from `origin`, then re-reads updated instructions and nudges updated secondmates without touching project clones. -The update is fast-forward only: dirty, diverged, offline, and off-default targets are reported and left untouched. -The origin-based updater and the local secondmate sync share the same guarded fast-forward helper; only the origin mode fetches. -The mechanics are owned by the `/updatefirstmate` skill and firstmate's operating manual in [`AGENTS.md`](../AGENTS.md) (self-update). +The [`/updatefirstmate` skill](../.agents/skills/updatefirstmate/SKILL.md) owns the source-policy contract and procedure. +Mutable-source installations use guarded origin fast-forwards, while immutable candidate/release images refuse in-place source changes and update only through a new image digest. ## Restart-proof diff --git a/docs/configuration.md b/docs/configuration.md index 61c52d6c5..62f0abe6d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -122,7 +122,7 @@ Each seed writes an `.fm-secondmate-home` identity marker at the home root. The tracked root `.gitignore` ignores that marker, so validation can read it without making a freshly seeded home appear dirty to porcelain-based safety checks. This does not relax protection for any other untracked file. An existing linked-worktree home that predates this rule advances through its marker-only state during its next bootstrap or spawn local sync, after which Git ignores the marker normally. -A standalone-clone home cannot receive a primary-local commit through that no-fetch sync, so it receives the rule through `/updatefirstmate`'s origin refresh instead. +A mutable-source standalone-clone home cannot receive a primary-local commit through that no-fetch sync, so it receives the rule through `/updatefirstmate`'s origin refresh instead. ## FM_HOME diff --git a/docs/evidence/2026-07-12-firstmate-package.md b/docs/evidence/2026-07-12-firstmate-package.md new file mode 100644 index 000000000..aa49fc105 --- /dev/null +++ b/docs/evidence/2026-07-12-firstmate-package.md @@ -0,0 +1,70 @@ +# Firstmate package verification + +Date: 2026-07-12 +Akua CLI: 0.8.20 +Kubernetes: OrbStack 1.34.8 +Agent OS image: `sha256:f87ae0f5f93d2781700a7c3a0828833433565322b7bb3e8f1d49244b40b8580e` + +## Claim + +The optional Firstmate Akua package renders ordinary resources and can start a persistent, cluster-admin Firstmate with a read-only Akua authorization mount in a dedicated Kubernetes namespace. + +## Render evidence + +Command: + +```sh +akua render --no-json --no-interactive \ + --package tools/agent-os/packages/firstmate/package.k \ + --inputs tools/agent-os/packages/firstmate/inputs.example.yaml \ + --out /tmp/agent-os-firstmate-render +``` + +Result: + +```text +rendered: 6 manifest(s) (sha256:4965345d355a00530a887f8f24454d5e858664c7fa4328ba584c15f96703ca0d) +Namespace +ServiceAccount +PersistentVolumeClaim +Service +ClusterRoleBinding +StatefulSet +``` + +## Disposable-cluster evidence + +The live eval used namespace `agent-os-package-eval`, the local immutable image above, `imagePullPolicy: Never`, and a synthetic authorization Secret. +It rendered with `createNamespace: false`, applied five manifests, waited for the StatefulSet, and ran the checks inside the Pod. + +Commands: + +```sh +kubectl --context orbstack apply -f /tmp/agent-os-firstmate-eval +kubectl --context orbstack -n agent-os-package-eval \ + rollout status statefulset/agent-os-firstmate --timeout=120s +kubectl --context orbstack -n agent-os-package-eval \ + exec statefulset/agent-os-firstmate -- herdr status --json +kubectl --context orbstack -n agent-os-package-eval \ + exec statefulset/agent-os-firstmate -- kubectl auth can-i '*' '*' --all-namespaces +``` + +Result: + +```text +rendered: 5 manifest(s) (sha256:ecf9cfb0052ca90677f14987ff823637aa27d3e914be9b7ab66906b55e99acc4) +partitioned roll out complete: 1 new pods have been updated +herdr=ready +home=writable +auth_mount=readonly +context=in-cluster +cluster_admin=yes +``` + +The write probe against `/var/run/secrets/agent-os/akua/authorization` failed with `Read-only file system`. +The eval namespace and its cluster-scoped binding were deleted after the checks. + +## Scope + +This proves the Kubernetes package contract locally. +It does not prove Akua workspace authentication, managed KaaS creation, Hetzner worker lifecycle, successor-token handoff, public-image availability, or bootstrap-token revocation. diff --git a/docs/evidence/2026-07-13-github-issue-pr.md b/docs/evidence/2026-07-13-github-issue-pr.md new file mode 100644 index 000000000..b32b28858 --- /dev/null +++ b/docs/evidence/2026-07-13-github-issue-pr.md @@ -0,0 +1,118 @@ +# GitHub Issue to Kubernetes crewmate PR verification + +Date: 2026-07-13 +Kubernetes context: `orbstack` +Namespace: `agent-os-demo` +Issue: private Cortex tracker item; public implementation PR below +Delivery PR: [#3 fix: select rebuilt local demo image](https://github.com/akua-dev/agent-os/pull/3) +Final review surface: [#1 feat: run Firstmate as a Kubernetes agent OS](https://github.com/akua-dev/agent-os/pull/1) + +## Claim + +A GitHub Issue can act as optional Agent OS intake without becoming the runtime +or communication layer. A Kubernetes-resident Firstmate can read that issue, +turn it into a precise brief, dispatch and supervise a general-purpose +crewmate, and return a tested review-ready PR. Ordinary Git, GitHub, Firstmate +files, treehouse worktrees, and Herdr terminals remain the operating substrate. + +## Topology and authority + +- Primary Firstmate: Pi in dedicated Herdr workspace `w6`, persistent + `FM_HOME=/home/agent`. +- Crewmate task: `fix-local-rebuild-image-r2`, Herdr pane `w2:p9`. +- Model policy: `openai-codex/gpt-5.6-terra`, low thinking, recorded in the + task metadata. +- Worktree: + `/home/agent/.treehouse/agent-os-eaf3be/1/agent-os`. +- Delivery mode: `direct-PR`; no custom task service, workflow engine, or + inter-agent protocol. +- GitHub authority came from the selected credential already persisted in the + Firstmate home. No credential value was copied into the brief or evidence. + +The evaluator initially discovered that the public Agent OS repository had +Issues disabled. It enabled only that native repository surface and created an +implementation issue from the observed stale-image failure. The tracker item +was later transferred into private Cortex and public Agent OS Issues were +disabled again. No GitHub Project or additional intake service was required. + +## Parent-only supervision + +The evaluator prompted only the primary Firstmate. Firstmate read the issue +with `gh-axi`, registered the existing Agent OS clone, wrote the child brief, +and spawned the crewmate through: + +```text +spawned fix-local-rebuild-image-r2 harness=pi kind=ship mode=direct-PR +model=openai-codex/gpt-5.6-terra effort=low +window=default:w2:p9 +``` + +Firstmate handled the Pi trust prompt through `fm-send.sh`, polled the child's +status and pane, armed `fm-pr-check.sh`, and inspected the delivered diff. The +evaluator made read-only Herdr observations and never prompted or steered the +child directly. The primary pane was moved into its own workspace early in the +run so task-tab cleanup could not remove the supervisor. + +## RED and GREEN evidence + +The child first extended `tests/agent-os-local.test.sh`. Against the old helper, +the focused test failed for the missing immutable tag operation: + +```text +not ok - build must assign the rebuilt image a unique local tag +(missing exact call: docker tag agent-os:dev agent-os:local-rebuilt) +RED_EXIT=1 +``` + +Commit `761223c533de5cba5a96681cf769ec12164a267e` then: + +- tags the default local image with its Docker image ID; +- updates both StatefulSet containers to that content-specific tag on deploy; +- preserves an explicit `AGENT_OS_IMAGE` without retagging it; +- documents the behavior; and +- adds hermetic stale-tag and override tests. + +The child passed `tests/agent-os-local.test.sh` and shell syntax checks. Its +attempt to run `tests/agent-os-kubernetes.test.sh` correctly exposed an +environment boundary: an in-cluster Firstmate Pod has a ServiceAccount token, +so it cannot represent the test's token-free host case. + +The evaluator closed that evidence gap from an isolated host worktree at the +exact child commit. All `tests/agent-os-*.test.sh` passed, including the +host-only refusal case. Focused ShellCheck, `bash -n`, and `git diff --check` +also passed. This independent result was recorded as a PR #3 comment before +integration. + +## GitHub result and timings + +- Private issue created: `2026-07-13T08:55:04Z`. +- PR #3 created: `2026-07-13T09:01:43Z`. +- Issue-to-review-ready-PR: 6 minutes 39 seconds. +- PR #3 merged into `feat/orbstack-demo`: `2026-07-13T09:05:28Z`. +- Issue-to-verified-integration: 10 minutes 24 seconds. +- Merge commit on the review branch: + `7679bda383d044aaec6d6fae7231bb7c0fb80576`. +- Files changed by the child: `AGENTS.md`, `bin/agent-os-local.sh`, + `docs/kubernetes.md`, and `tests/agent-os-local.test.sh`. + +Pi displayed approximate subscription cost estimates of `$0.966` for the child +and `$0.917` for the primary after final reconciliation. These are UI estimates, +not provider invoices. + +Four evaluator interventions occurred: move the primary into a dedicated +workspace, run the host-only verification, merge the verified stacked PR into +the final review branch, and ask Firstmate to reconcile and tear down the +completed child after that external merge. No evaluator intervention changed +the child's implementation or communicated with it directly. + +Firstmate then ran its ordinary teardown path, marked the task done in the +backlog, removed the child pane and task metadata, and left the retained +separate-Pod recovery fixture, primary workspace, and committed evidence intact. + +## Scope + +This proves native GitHub Issue intake, parent-only same-Pod delegation, real +RED-to-GREEN implementation, a tested review-ready PR, and integration into the +single Agent OS review branch. It does not prove GitHub Projects, an +Akua-managed intelligence cluster, Akua worker lifecycle, or product-cluster +access boundaries. diff --git a/docs/evidence/2026-07-13-same-pod-firstmate.md b/docs/evidence/2026-07-13-same-pod-firstmate.md new file mode 100644 index 000000000..45a063e80 --- /dev/null +++ b/docs/evidence/2026-07-13-same-pod-firstmate.md @@ -0,0 +1,97 @@ +# Same-Pod Firstmate verification + +Date: 2026-07-13 +Kubernetes context: `orbstack` +Namespace: `agent-os-demo` +Pod: `agent-os-firstmate-0` +Image ID: `docker-pullable://agent-os@sha256:1e6696938aafcec21748b83e3d68773d8b3cf1ccb967974f2cfbeda0c4fbed65` +Herdr: 0.7.3, protocol 16 +Pi: 0.80.6 + +## Claim + +A persistent Firstmate running in Herdr inside the Kubernetes Pod can create, supervise, recover, and finish a general-purpose Pi crewmate in another Herdr task pane in the same Pod. +The crewmate receives work only from Firstmate, runs real verification commands, writes its report to the persistent Firstmate home, and remains observable through Herdr. + +## Topology + +- Primary Firstmate: Herdr workspace `w3`, pane `w3:p1`, persistent `FM_HOME=/home/agent`. +- Crewmate task: `aeval-p4`, Herdr endpoint `default:w2:p6`. +- Model: `openai-codex/gpt-5.4-mini`, medium thinking. +- Worktree: `/home/agent/.treehouse/agent-os-eval-f1de56/2/agent-os-eval`. +- Report: `/home/agent/data/aeval-p4/report.md`. +- Both agents ran in `agent-os-firstmate-0`; no separate task service or communication protocol was used. + +## Task evidence + +Firstmate created a minimal local-only evaluation repository, registered it, scaffolded a scout brief, and dispatched the crewmate through `fm-spawn.sh` with the Herdr backend. +The successful spawn recorded: + +```text +spawned aeval-p4 harness=pi kind=scout mode=local-only yolo=off window=default:w2:p6 worktree=/home/agent/.treehouse/agent-os-eval-f1de56/2/agent-os-eval +``` + +The crewmate ran the real kubeconfig regression test: + +```sh +bash /opt/agent-os/tests/agent-os-kubeconfig.test.sh +``` + +```text +ok - Agent OS creates a rotation-safe in-cluster kubeconfig without exposing token contents +``` + +It also checked the active Kubernetes context: + +```text +CURRENT NAME CLUSTER AUTHINFO NAMESPACE +* in-cluster in-cluster in-cluster agent-os-demo +``` + +The task status reached: + +```text +working: inspecting project and kube integration +resolved: recovered with provider-qualified openai-codex/gpt-5.4-mini +done: report written with verification and evidence +``` + +The recorded task interval was `2026-07-13T08:18:30Z` to `2026-07-13T08:20:47Z`, or 2 minutes 17 seconds. +Pi displayed an approximate crewmate-session cost of `$0.195`; this is a model UI estimate, not a provider invoice. + +## Parent-only supervision and human observation + +The child received its initial brief from `fm-spawn.sh` and later steers through Firstmate's `fm-send.sh` path. +The external evaluator made only read-only Herdr observations of the child pane and never sent a prompt or command directly to the child. +Herdr server logs recorded a connected client while the fleet was live, proving that a human could attach and observe the same workspaces. + +## Failure and recovery evidence + +The first spawn used the unqualified model name `gpt-5.4-mini`. +Pi resolved it to the unauthenticated `azure-openai-responses` provider and failed with: + +```text +Error: No API key found for azure-openai-responses. +``` + +Firstmate preserved the task brief, state, report path, and worktree allocation, then respawned the same task id with `openai-codex/gpt-5.4-mini`. +The qualified route completed successfully. + +A connected Herdr client also closed the original primary workspace during the run. +The primary Pi session, persistent Firstmate home, task metadata, brief, and status survived. +A resumed primary reacquired the fleet through `bin/fm-session-start.sh` and supervised the task to completion. +The resumed primary had to remain in its own Herdr workspace; placing it inside the task tab made ordinary dead-task replacement close the supervisor with that tab. + +Three operator interventions were required: qualify the provider route, restart the primary after its workspace closed, and move the resumed primary into a dedicated workspace. +Firstmate then handled child steering, report acceptance, and agent exit itself. + +## Lessons promoted into the system + +- Operational state must always resolve through `$FM_HOME`; repo-relative `data/` and `projects/` created non-persistent duplicates during the first attempt. +- Pi dispatch must use a provider-qualified model id when authentication depends on the provider route. +- A primary Firstmate Herdr pane must not share a task tab that the backend may replace during recovery. + +## Scope + +This proves the same-Pod task path, parent-only task communication, human observation, provider-qualified model recovery, and primary-session recovery locally. +It does not prove a separately resourced crewmate Pod, unique child work surviving a Pod restart, Akua-managed infrastructure, GitHub delivery, or production access boundaries. diff --git a/docs/evidence/2026-07-13-separate-pod-recovery.md b/docs/evidence/2026-07-13-separate-pod-recovery.md new file mode 100644 index 000000000..972d38614 --- /dev/null +++ b/docs/evidence/2026-07-13-separate-pod-recovery.md @@ -0,0 +1,119 @@ +# Separate-Pod mate and recovery verification + +Date: 2026-07-13 +Kubernetes context: `orbstack` +Namespace: `agent-os-demo` +Package: `tools/agent-os/packages/mate/package.k` +Model policy: `openai-codex/gpt-5.6-terra`, low thinking + +## Claim + +Firstmate can use ordinary Akua package rendering, Kubernetes, and Herdr to create and supervise a separately resourced persistent mate Pod with explicit AI credentials and no ambient Kubernetes authority. +The mate can complete real work, survive Pod replacement without losing its unique artifact or Pi session, resume the exact persisted session, append recovery evidence, and return control to Firstmate without a custom controller or communication protocol. + +## Resource and authority boundary + +- Pod: `agent-os-mate-separate-eval`. +- Address: `/k8s/in-cluster/ns/agent-os-demo/pod/agent-os-mate-separate-eval`. +- Requests: `500m` CPU and `1Gi` memory. +- Limits: `2` CPU and `4Gi` memory. +- PVC: `agent-os-mate-separate-eval-home`, UID `2c141e3b-0585-4648-96e8-2bc496450a91`, retained and Bound. +- Pi Secret: `agent-os-mate-separate-eval-pi-auth`, UID `c4698208-bb08-437c-87e9-9346e8b14ed2`, retained. +- `automountServiceAccountToken`: `false`. +- No ServiceAccount token existed in the container. +- `/home/agent/.pi/agent/auth.json` was an explicit read-only Secret mount. +- The package added no privileged setting, host namespace, or host mount. + +Parent-side live verification returned: + +```text +NO_SA_TOKEN +/home/agent/.pi/agent/auth.json ro,relatime +{"server":"running","running":true,"socket":"/home/agent/.config/herdr/herdr.sock","compatible":true} +``` + +The child initially misread `/proc/mounts` and claimed that no auth material was mounted. +Firstmate caught the discrepancy and retained the authoritative parent-side `/proc/self/mountinfo` evidence above. + +## Initial task + +Firstmate launched `separate-eval-terra` through the mate's Herdr server with `openai-codex/gpt-5.6-terra` and low thinking. +The child created `/home/agent/unique-work.txt`: + +```text +separate-eval-20260713T083121Z-b8a6ef90edac4d55 +Short note: child evaluator artifact; parent-only communication. +``` + +Its immutable artifact hash was: + +```text +2aeb922cd51230ed7b62bc40cc7ce30d41e44b4df3963911fe860030c2f7765a +``` + +The task ran from `2026-07-13T08:31:12Z` through `2026-07-13T08:31:35Z` and wrote `/home/agent/separate-eval-report.md`. +The child communicated only through its initial Firstmate brief and returned its result through the persistent report and Herdr pane. + +## Pod and session recovery + +Firstmate exited the child, preserved the PVC and Secret, deleted only the Pod, and recreated it from the same Akua-rendered manifests. + +```text +Initial Pod UID: 960cc460-cd06-44d1-a8fa-da200d4384b9 +Replacement Pod UID: 1cb7ff92-5e0d-4ccd-9324-38515ae9823b +``` + +After replacement, Firstmate verified: + +```text +UNIQUE_SURVIVED +REPORT_SURVIVED +PI_SESSION_SURVIVED +/usr/local/bin/herdr +/usr/local/bin/pi +{"running":true,"socket":"/home/agent/.config/herdr/herdr.sock"} +``` + +The persisted Pi session was `019f5a99-f031-7f51-8cc0-156777204dc5`. +Firstmate relaunched `pi --session 019f5a99-f031-7f51-8cc0-156777204dc5` on Terra-low through the replacement Pod's Herdr server. +The resumed agent rehashed the unique artifact, confirmed Herdr health, and appended a recovery record at `2026-07-13T08:33:26Z`. +It then exited, leaving no live Terra child agent. + +The approximate model cost displayed by the final recovery pane was `$0.127`; this is a Pi subscription estimate, not provider billing evidence. + +## Model-policy intervention + +An earlier `openai-codex/gpt-5.4-mini` child was stopped immediately when the captain changed the testing policy. +The primary was restarted on Terra-low, and `/home/agent/config/crew-dispatch.json` now pins all subsequent testing crewmates to: + +```json +{ + "rules": [], + "default": { + "harness": "pi", + "model": "openai-codex/gpt-5.6-terra", + "effort": "low" + } +} +``` + +The aborted pane restored as a stale Herdr entry after Pod replacement but had no live Pi process or accepted result. + +The policy was then closed over all three local launch paths: + +- Pi's persisted defaults are `openai-codex`, `gpt-5.6-terra`, and `low`, so a + direct primary `pi` session uses Terra-low; +- `crew-dispatch.json` pins ordinary crewmates and scouts; +- `secondmate-harness` contains + `pi openai-codex/gpt-5.6-terra low` for Secondmate launches. + +After rebuilding and replacing the Firstmate Pod, image +`docker-pullable://agent-os@sha256:94b7eb6c435f1a226e7279c40491e49c505b30bf54c65de1cd3d5b8e0a102611` +converged all three settings and Herdr returned healthy. The retained separate +mate PVC was also updated to the same direct-Pi defaults. No child agent was +left running after verification. + +## Scope + +This proves the separate-Pod authority boundary, parent-only supervision, persistent home, Pod replacement, unique-work survival, persistent toolchain, and exact Pi session recovery in the local intelligence cluster. +It does not prove Akua-managed KaaS or worker lifecycle, GitHub issue-to-PR delivery, product-cluster read/write boundaries, or a second model provider. diff --git a/docs/herdr-compliance.md b/docs/herdr-compliance.md new file mode 100644 index 000000000..e8637a901 --- /dev/null +++ b/docs/herdr-compliance.md @@ -0,0 +1,34 @@ +# Herdr 0.7.3 distribution record + +This is a factual distribution record for the Agent OS image and is not legal advice. + +## License and source record + +Herdr v0.7.3 declares AGPL-3.0-or-later or a commercial license in its upstream `LICENSE` and README. +Agent OS uses the AGPL-3.0-or-later grant for this distribution. +The fixed upstream v0.7.3 tag resolves to commit `299dd4163a96381ec2d8e5bde13d7ba6d6432373`. +The matching source archive and its SHA-256 are recorded in [`THIRD_PARTY_SOURCES.md`](../THIRD_PARTY_SOURCES.md). +The image includes the upstream license at `/usr/share/licenses/herdr/LICENSE` and its source offer at `/usr/share/doc/agent-os/THIRD_PARTY_SOURCES.md`. + +## Technical boundary + +The Dockerfile downloads only Herdr's published Linux executable, verifies its release SHA-256, and marks it executable. +The Dockerfile does not patch the executable or copy Herdr source into Agent OS. +The image starts Herdr as the separate process `herdr server`. +Agent OS shell adapters invoke Herdr through its CLI and socket interfaces rather than importing, linking, or embedding Herdr code. + +These facts support the AGPL section 5 aggregate condition only while Herdr remains a separate and independent work that is not an extension of, or combined into a larger program with, Agent OS. +The AGPL states that inclusion of a covered work in such an aggregate does not apply the license to the other parts of the aggregate. +Any change that patches Herdr, copies its source into Agent OS, links it with Agent OS code, or creates another derivative coupling must stop this release path and receive a fresh license review before publication. + +## Conveyance obligations + +AGPL section 4 requires the applicable license and notices to remain with verbatim copies. +The image preserves Herdr's upstream license and puts the recipient-facing notice and source offer in standard documentation paths. +AGPL section 6 permits network conveyance when equivalent corresponding source access is clearly directed from the object-code distribution. +The checked source URL, immutable commit, archive checksum, and build command are present both in this repository and inside the image without an Agent OS, Akua, or Herdr account requirement. +Release maintainers must keep that source path available for every published image carrying this executable. + +Section 13 requires a no-charge network source offer when an operator modifies Herdr and users interact with that modified version remotely. +Agent OS conveys Herdr unmodified, so this image distribution does not rely on section 13 for the source offer. +If an operator or future release modifies Herdr, that version must prominently offer its corresponding source to remote users as section 13 requires. diff --git a/docs/kubernetes.md b/docs/kubernetes.md new file mode 100644 index 000000000..b9f5a489c --- /dev/null +++ b/docs/kubernetes.md @@ -0,0 +1,137 @@ +# Kubernetes Agent OS + +Agent OS installs one persistent Firstmate from the public, versioned package at `tools/agent-os/packages/firstmate/`. +The package renders ordinary Kubernetes resources. +It is free to render and apply on any conformant Kubernetes cluster without an Akua account, API key, managed control plane, or Akua-hosted worker. +Kubernetes distributes and isolates the crew; it does not replace Firstmate's supervision model or Herdr's terminal/session interface. + +## Requirements + +- A Kubernetes context that you are authorized to use explicitly. +- `kubectl` and the public `akua` renderer CLI on your PATH. +- Permission to create the selected namespace when `createNamespace: true`, plus the rendered ServiceAccount, Role, RoleBinding, Service, PVC, and StatefulSet. +- A default or selected StorageClass that satisfies the `ReadWriteOnce` PVC request. +- An immutable image digest published by an Agent OS release. + +No Kubernetes Secret is required for the portable install. +The package has no credential field or Secret reference. +Runtime AI, GitHub, or other authority is created separately by its owner after installation and is never supplied through package inputs, command arguments, or rendered YAML. + +The package requires one complete 64-hex image digest because a mutable or malformed reference cannot identify an upgrade or recovery input. +The source tree contains an intentionally non-installable placeholder until the first public image release is published. +Replace it only with the digest recorded by that release workflow. + +## Generic quickstart + +Start from a tagged Agent OS source checkout that matches the image release. +Copy the stable input schema and replace its image value with that release's immutable digest. + +```sh +cp tools/agent-os/packages/firstmate/inputs.example.yaml /tmp/agent-os-inputs.yaml +$EDITOR /tmp/agent-os-inputs.yaml + +export AGENT_OS_CONTEXT=your-kubernetes-context +export AGENT_OS_NAMESPACE=agent-os +export AGENT_OS_INPUTS=/tmp/agent-os-inputs.yaml + +bin/agent-os-kubernetes.sh install +``` + +The installer renders only `tools/agent-os/packages/firstmate/package.k`, applies that fresh output to the named context, and waits for `agent-os-firstmate` to roll out. +It never reads an ambient Kubernetes context. +The namespace in the rendered StatefulSet is authoritative, and an inconsistent `AGENT_OS_NAMESPACE` stops the operation before any Kubernetes request. +With `createNamespace: true`, install creates only an absent namespace or reuses one carrying the exact package installation identity. +It refuses to adopt a pre-existing unowned or foreign namespace. +With `createNamespace: false`, the selected namespace must already exist without Agent OS ownership metadata and remains outside package ownership. + +The default `rbac: namespace` creates a ServiceAccount plus a Role and RoleBinding scoped to the selected namespace. +That Role allows Firstmate to manage runtime crewmate Pods and PVCs and inspect its StatefulSet. +Set `rbac: none` only when another reviewed authority handles those runtime operations. +Set `rbac: cluster-admin` only for an isolated intelligence cluster after reviewing the broader ClusterRoleBinding. + +The persistent home PVC defaults to `20Gi` and is mounted at `/home/agent`. +Image-owned `/usr/local` remains immutable, while user-installed tools persist under `/home/agent/.local` and the other home-scoped prefixes on the PVC. +The init container authenticates an image ownership manifest and refuses ambiguous legacy `/usr/local` migrations instead of retaining stale image binaries. + +## Operations + +To upgrade, use a new released digest in the same input file and apply it through the same package. + +```sh +bin/agent-os-kubernetes.sh upgrade +``` + +Upgrade applies and verifies the desired workload and RBAC before removing obsolete namespace Role and RoleBinding resources. +Routine `namespace` and `none` operations never inspect or delete cluster-scoped RBAC. +When a downgrade may leave the exact package-owned ClusterRoleBinding, upgrade exits incomplete and prints a separately confirmed `cleanup-cluster-rbac --yes` command plus the required absence evidence. +Run that command only through an explicitly approved cluster-admin identity. +It refuses to delete a same-name binding unless its ownership label and installation annotation both match. + +To roll back only the Firstmate workload revision, use Kubernetes StatefulSet history. +This does not roll back package inputs, RBAC, or persistent data. + +```sh +bin/agent-os-kubernetes.sh rollback +``` + +To inspect the workload, use the same explicit context and namespace. + +```sh +bin/agent-os-kubernetes.sh status +``` + +Uninstall is deliberately confirmed and bounded to namespaced resources from a fresh render plus the deterministic namespace Role and RoleBinding names. +It retains the namespace by default and reports possible cluster-scoped residue without requesting cluster-wide authority. +Use the separately printed privileged cleanup command to remove an exactly owned ClusterRoleBinding. +The optional `--delete-namespace` flag works only for `createNamespace: true`, rechecks the exact installation identity, inventories every listable namespaced resource type, and refuses deletion while any foreign resource remains. +With `createNamespace: false`, the namespace is never deleted. + +```sh +bin/agent-os-kubernetes.sh uninstall --yes +``` + +To delete an exactly owned and otherwise empty namespace as part of the confirmed uninstall, use: + +```sh +bin/agent-os-kubernetes.sh uninstall --yes --delete-namespace +``` + +## Runtime mates + +Firstmate creates a separate-Pod crewmate at runtime with the internal `crewmate.yaml` template in the canonical package. +It is not a second public package and is not a Marketplace product. +Each runtime mate has its own PVC and no ambient Kubernetes ServiceAccount token. +Creating one requires an explicitly authorized, pre-created Secret in the same namespace with an `auth.json` key. +Pass only that Secret's name through `AGENT_OS_AI_SECRET`; the helper never discovers or copies the primary credential. +The Secret projects only `auth.json` into a dedicated read-only runtime directory, and the entrypoint links that file into the writable PVC-backed Pi state without copying credential bytes. +A missing Secret or key keeps the Pod unready, so creation fails closed and removes the non-running Pod while retaining its PVC for an authorized retry. + +```sh +AGENT_OS_AI_SECRET=scout-1-ai-auth bin/agent-os-crewmate.sh create scout-1 +``` + +Use `stop` for a Pod-only shutdown and `restart` for a Pod-only replacement after an approved Secret rotation. +The ambiguous `delete` operation is rejected. +Only `purge --yes` removes the PVC, and it requires the owned Pod to be absent, exact ownership, a fresh clean checkpoint annotation from the stopped home, and a non-secret evidence file. +The full rotation, urgent-revocation, checkpoint, and purge procedure is owned by the `kubernetes-fleet` operating skill. + +The existing [same-Pod](evidence/2026-07-13-same-pod-firstmate.md) and [separate-Pod recovery](evidence/2026-07-13-separate-pod-recovery.md) records remain local lifecycle evidence. +They do not substitute for a clean published-image installation. + +## OrbStack profile + +OrbStack is a test profile of the canonical package, not the default product contract. +Its `deploy/orbstack/inputs.yaml` changes only the local environment inputs: the isolated `agent-os-demo` namespace, a local image source with `imagePullPolicy: Never` and `allowMutableImage: true`, and its explicit local-demo `cluster-admin` grant. +The profile uses the same package, ServiceAccount, persistent home, runtime mate template, and lifecycle commands. + +```sh +bin/agent-os-local.sh build +bin/agent-os-local.sh deploy +bin/agent-os-local.sh status +bin/agent-os-local.sh shell +``` + +The local helper refuses a non-OrbStack context unless `AGENT_OS_ALLOW_NON_ORBSTACK=1` is set deliberately. +It tags each rebuilt local image by content before rendering the profile so a deployment cannot reuse a stale mutable image. +The local profile is an isolated test environment only. +Its broad RoleBinding and container-root policy are not a production-cluster access pattern. diff --git a/docs/scripts.md b/docs/scripts.md index 6ad6a4e54..092c04998 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -13,7 +13,7 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | -| `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from origin | +| `fm-update.sh` | Enforce source policy, fast-forwarding mutable homes and refusing immutable image sources | | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | | `fm-brief.sh` | Scaffold ship, scout, secondmate-charter, and Herdr-lab briefs | | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | diff --git a/docs/superpowers/plans/2026-07-12-orbstack-agent-os-demo.md b/docs/superpowers/plans/2026-07-12-orbstack-agent-os-demo.md new file mode 100644 index 000000000..75da12bad --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-orbstack-agent-os-demo.md @@ -0,0 +1,258 @@ +# OrbStack Agent OS Demo Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Package this Firstmate fork as a persistent Kubernetes-native Agent OS demo that runs on OrbStack and can launch one isolated crewmate Pod. + +**Architecture:** Keep Firstmate's supervision and Herdr backend unchanged. Add one OCI image, one namespace-scoped manifest set, and thin Bash CLIs that always target an explicit Kubernetes context. The trusted primary Pod gets cluster-admin only for the local demo; crewmate Pods get no service-account token and keep work on their own PVC. + +**Tech Stack:** Bash, Docker, Kubernetes YAML, Kustomize, OrbStack Kubernetes, Herdr 0.7.3, Node.js 24, Pi 0.80.6. + +## Global Constraints + +- Every host-side `kubectl` command must pass `--context`, defaulting to `orbstack`. +- The demo namespace is exactly `agent-os-demo`; scripts must not enumerate, mutate, or delete unrelated namespaces. +- No host model credentials, kubeconfigs, Git credentials, or home directories may enter the image build context. +- The primary home is a PVC mounted at `/home/agent`; each crewmate gets a different PVC. +- Herdr remains an unmodified separate AGPL executable and must ship with its license and exact source link. +- Firstmate remains usable outside Kubernetes; no existing backend contract is replaced. +- Tests use fake `docker`, `kubectl`, and `orbctl` commands and run before any live mutation. + +--- + +### Task 1: Guarded local bootstrap CLI + +**Files:** +- Create: `tests/agent-os-local.test.sh` +- Create: `bin/agent-os-local.sh` + +**Interfaces:** +- Consumes: `docker`, `kubectl`, and optional `orbctl` executables from `PATH`. +- Produces: `bin/agent-os-local.sh build|deploy|status|shell|attach|destroy` and environment variables `AGENT_OS_CONTEXT`, `AGENT_OS_NAMESPACE`, and `AGENT_OS_IMAGE`. + +- [ ] **Step 1: Write the failing CLI test** + +Create a fake-tool directory that appends every invocation to a log, run `status`, `deploy`, and `destroy`, and assert the exact safety contract: + +```bash +PATH="$fakebin:$PATH" AGENT_OS_TEST_LOG="$log" bin/agent-os-local.sh status +grep -F 'kubectl --context orbstack -n agent-os-demo get statefulset agent-os-firstmate' "$log" + +PATH="$fakebin:$PATH" AGENT_OS_TEST_LOG="$log" bin/agent-os-local.sh deploy +grep -F 'kubectl --context orbstack apply -k deploy/orbstack' "$log" + +if PATH="$fakebin:$PATH" bin/agent-os-local.sh destroy 2>/dev/null; then + fail "destroy must require --yes" +fi +``` + +Also assert that `AGENT_OS_CONTEXT=minekube-prod` is rejected unless `AGENT_OS_ALLOW_NON_ORBSTACK=1`, and that no command omits `--context`. + +- [ ] **Step 2: Run the test to verify RED** + +Run: `bash tests/agent-os-local.test.sh` + +Expected: FAIL because `bin/agent-os-local.sh` does not exist. + +- [ ] **Step 3: Implement the minimal guarded CLI** + +Implement strict Bash with these command mappings: + +```bash +build) docker build -t "$IMAGE" . ;; +deploy) kubectl --context "$CONTEXT" apply -k deploy/orbstack ;; +status) kubectl --context "$CONTEXT" -n "$NAMESPACE" get statefulset agent-os-firstmate ;; +shell) kubectl --context "$CONTEXT" -n "$NAMESPACE" exec -it statefulset/agent-os-firstmate -- bash ;; +attach) kubectl --context "$CONTEXT" -n "$NAMESPACE" exec -it statefulset/agent-os-firstmate -- herdr ;; +destroy) [ "${2:-}" = --yes ] || exit 2; kubectl --context "$CONTEXT" delete namespace "$NAMESPACE" ;; +``` + +`deploy` may call `orbctl start k8s` only when the context is `orbstack`; it must then wait with `kubectl --context orbstack wait --for=condition=Ready node/orbstack --timeout=120s`. + +- [ ] **Step 4: Run the test to verify GREEN** + +Run: `bash tests/agent-os-local.test.sh` + +Expected: all assertions print `ok` and exit 0. + +### Task 2: Reproducible container and persistent primary + +**Files:** +- Create: `tests/agent-os-container.test.sh` +- Create: `.dockerignore` +- Create: `Dockerfile` +- Create: `bin/agent-os-container-entrypoint.sh` +- Create: `THIRD_PARTY_NOTICES.md` + +**Interfaces:** +- Consumes: repository source as Docker build context. +- Produces: image `agent-os:dev`, `/opt/agent-os`, persistent `FM_HOME=/home/agent`, Herdr server as PID 1, Pi on `PATH`. + +- [ ] **Step 1: Write the failing static container test** + +Assert: + +```bash +grep -F 'FROM node:24-bookworm-slim' Dockerfile +grep -F 'HERDR_VERSION=0.7.3' Dockerfile +grep -F '@earendil-works/pi-coding-agent@0.80.6' Dockerfile +grep -F 'FM_HOME=/home/agent' Dockerfile +grep -F 'exec herdr server' bin/agent-os-container-entrypoint.sh +grep -F '.git' .dockerignore +grep -F '.pi' .dockerignore +grep -F 'https://github.com/ogulcancelik/herdr/tree/v0.7.3' THIRD_PARTY_NOTICES.md +``` + +Also assert `bash -n bin/agent-os-container-entrypoint.sh` succeeds. + +- [ ] **Step 2: Run the test to verify RED** + +Run: `bash tests/agent-os-container.test.sh` + +Expected: FAIL on the first missing file. + +- [ ] **Step 3: Implement the image and entrypoint** + +Use `node:24-bookworm-slim`, install only `bash`, `ca-certificates`, `curl`, `git`, `jq`, `openssh-client`, `procps`, and `tmux`, and download the architecture-specific Herdr 0.7.3 release binary. +Install Pi at the exact package version. +Copy the repository to `/opt/agent-os`. +The entrypoint must create `$FM_HOME/config`, write `herdr` to `config/backend` only when absent, set `HOME=$FM_HOME`, and execute `herdr server` without copying any credentials. + +- [ ] **Step 4: Run static tests and build the image** + +Run: + +```bash +bash tests/agent-os-container.test.sh +docker build -t agent-os:dev . +docker run --rm --entrypoint bash agent-os:dev -lc 'herdr version && pi --version && test -x /opt/agent-os/bin/fm-spawn.sh' +``` + +Expected: static test exits 0, image builds, Herdr reports 0.7.3, Pi reports 0.80.6, and the Firstmate toolbelt exists. + +### Task 3: Kubernetes manifests and isolated crewmate launcher + +**Files:** +- Create: `tests/agent-os-kubernetes.test.sh` +- Create: `deploy/orbstack/kustomization.yaml` +- Create: `deploy/orbstack/namespace.yaml` +- Create: `deploy/orbstack/rbac.yaml` +- Create: `deploy/orbstack/primary.yaml` +- Create: `bin/agent-os-crewmate.sh` +- Create: `.agents/skills/kubernetes-fleet/SKILL.md` +- Modify: `AGENTS.md` + +**Interfaces:** +- Consumes: image `agent-os:dev`, explicit `kubectl` context on a host or in-cluster service-account configuration in the primary Pod. +- Produces: namespace `agent-os-demo`, StatefulSet `agent-os-firstmate`, PVC `agent-os-firstmate-home`, and `bin/agent-os-crewmate.sh create|status|delete `. + +- [ ] **Step 1: Write failing manifest and launcher tests** + +Assert rendered Kustomize output contains: + +```text +Namespace/agent-os-demo +ServiceAccount/agent-os-firstmate +ClusterRoleBinding/agent-os-firstmate-local-demo +PersistentVolumeClaim/agent-os-firstmate-home +StatefulSet/agent-os-firstmate +imagePullPolicy: Never +``` + +Use a fake `kubectl` to assert `agent-os-crewmate.sh create scout-1` applies exactly one PVC and one Pod in `agent-os-demo`, labels both with `agent-os.akua.dev/crewmate=scout-1`, sets `automountServiceAccountToken: false`, and refuses IDs outside `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`. + +- [ ] **Step 2: Run tests to verify RED** + +Run: `bash tests/agent-os-kubernetes.test.sh` + +Expected: FAIL because the manifests and launcher do not exist. + +- [ ] **Step 3: Implement manifests and launcher** + +The primary StatefulSet must mount the PVC at `/home/agent`, run as UID/GID 1000, request `500m` CPU and `1Gi` memory, limit at `4` CPU and `8Gi`, and use the dedicated ServiceAccount. +The local-demo ClusterRoleBinding may grant `cluster-admin` only to `system:serviceaccount:agent-os-demo:agent-os-firstmate` and must include a comment that production installations need a reviewed narrower role. +The crewmate launcher creates a separate PVC mounted at `/home/agent`, reuses `agent-os:dev`, and disables service-account token automount. + +- [ ] **Step 4: Add the conditional operating skill** + +Add a focused internal skill that tells Firstmate to use the launcher only when it is running in Kubernetes, keep children general-purpose, communicate parent-to-child through terminals/files, preserve unique work on the child PVC, and never claim the local-demo RBAC is production-safe. +Add exactly one trigger line to `AGENTS.md` section 13: load the skill before creating, supervising, recovering, or deleting Kubernetes crewmates. + +- [ ] **Step 5: Run tests to verify GREEN** + +Run: + +```bash +bash tests/agent-os-kubernetes.test.sh +for script in bin/agent-os-*.sh; do bash -n "$script"; done +kubectl kustomize deploy/orbstack >/tmp/agent-os-rendered.yaml +``` + +Expected: all tests exit 0 and rendering succeeds. + +### Task 4: Live OrbStack proof and concise documentation + +**Files:** +- Create: `docs/kubernetes.md` +- Modify: `README.md` + +**Interfaces:** +- Consumes: Tasks 1-3 and an active OrbStack Kubernetes context. +- Produces: reproducible local-demo commands and empirical proof of primary/child persistence. + +- [ ] **Step 1: Document only the verified local path** + +Document `build`, `deploy`, `status`, `shell`, `attach`, crewmate create/status/delete, and `destroy --yes`. +State that no credentials are copied automatically and that the user launches Pi from `/opt/agent-os` after authenticating inside the persistent home. +Add one concise README link; keep mechanism detail in `docs/kubernetes.md`. + +- [ ] **Step 2: Deploy to OrbStack** + +Run: + +```bash +bin/agent-os-local.sh build +bin/agent-os-local.sh deploy +kubectl --context orbstack -n agent-os-demo rollout status statefulset/agent-os-firstmate --timeout=180s +``` + +Expected: the StatefulSet has one Ready replica. + +- [ ] **Step 3: Prove Herdr, RBAC, and persistence** + +Run: + +```bash +kubectl --context orbstack -n agent-os-demo exec statefulset/agent-os-firstmate -- herdr status --json +kubectl --context orbstack -n agent-os-demo auth can-i create pods --as system:serviceaccount:agent-os-demo:agent-os-firstmate +kubectl --context orbstack -n agent-os-demo exec statefulset/agent-os-firstmate -- sh -lc 'printf persisted > /home/agent/persistence-proof' +kubectl --context orbstack -n agent-os-demo delete pod agent-os-firstmate-0 +kubectl --context orbstack -n agent-os-demo wait --for=condition=Ready pod/agent-os-firstmate-0 --timeout=180s +kubectl --context orbstack -n agent-os-demo exec statefulset/agent-os-firstmate -- grep -F persisted /home/agent/persistence-proof +``` + +Expected: Herdr server is compatible, RBAC says `yes`, and the proof survives Pod replacement. + +- [ ] **Step 4: Prove isolated crewmate lifecycle** + +Run the launcher inside the primary Pod to create `scout-1`, wait for readiness, confirm it has a different PVC and no automounted token, write a proof file, restart it, and confirm the file remains. +Delete only `scout-1` through the launcher and confirm the primary remains Ready. + +- [ ] **Step 5: Run final repository verification** + +Run: + +```bash +for script in bin/*.sh bin/backends/*.sh; do bash -n "$script"; done +bin/fm-lint.sh +bash tests/agent-os-local.test.sh +bash tests/agent-os-container.test.sh +bash tests/agent-os-kubernetes.test.sh +git diff --check +``` + +Expected: every command exits 0 with no warnings attributable to the new files. + +- [ ] **Step 6: Commit and ship through the repository gate** + +Commit the reviewed files on `feat/orbstack-demo`, initialize no-mistakes for the `akua-dev/agent-os` push target, run the configured pipeline without `--yes`, and stop at a CI-green PR for the captain's merge decision. diff --git a/docs/superpowers/plans/2026-07-12-root-userns-persistent-toolchain.md b/docs/superpowers/plans/2026-07-12-root-userns-persistent-toolchain.md new file mode 100644 index 000000000..3779d2312 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-root-userns-persistent-toolchain.md @@ -0,0 +1,464 @@ +# Local Root Agents and Persistent Toolchain Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Run every local Agent OS Pod as container root while providing Firstmate's complete baseline toolchain and preserving runtime-installed tools across Pod replacement. + +**Architecture:** Extend the reproducible image with exact binary and npm package versions, then seed the image's `/usr/local` tree into each agent PVC from an init container. The main container mounts that tree at `/usr/local`, uses the same PVC for `/home/agent`, and runs as UID 0 on the isolated local OrbStack VM node. + +**Tech Stack:** Docker, Bash, Kubernetes v1.34, Kustomize, StatefulSet, Pods, PVCs, Herdr, Pi, Node.js, npm. + +## Global Constraints + +- Every primary and crewmate Pod sets `runAsUser: 0` and `runAsGroup: 0`. +- OrbStack's unsupported `hostUsers: false` field is omitted from this isolated local demo. +- The primary retains the existing local-demo `cluster-admin` ServiceAccount binding, while crewmates receive no Kubernetes credentials by default. +- No Pod uses privileged mode, host PID, host IPC, host networking, raw block devices, or host-path mounts. +- Each agent has its own PVC-backed `/home/agent` and `/usr/local`; no persistent state is shared between agents. +- Runtime additions in `/usr/local` and persistent home prefixes survive Pod replacement. +- Runtime `apt` changes outside the persistent prefixes remain intentionally ephemeral. +- Host model and GitHub credentials never enter the image or Docker build context. +- Host Kubernetes commands always pin `--context orbstack`. +- Use TDD for every behavior change and run `bin/fm-lint.sh` before shipping. +- Do not run no-mistakes unless the captain asks for it again. + +--- + +### Task 1: Complete the reproducible image toolchain + +**Files:** +- Modify: `Dockerfile` +- Modify: `tests/agent-os-container.test.sh` + +**Interfaces:** +- Consumes: Docker BuildKit `TARGETARCH` values `amd64` and `arm64`. +- Produces: image commands `gh`, `rg`, `fd`, `treehouse`, `no-mistakes`, `gh-axi`, `chrome-devtools-axi`, `lavish-axi`, `tasks-axi`, and `quota-axi`. + +- [ ] **Step 1: Add failing static pin assertions** + +Add these assertions to `tests/agent-os-container.test.sh`: + +```bash +assert_grep 'ARG GH_VERSION=2.96.0' "$ROOT/Dockerfile" "image must pin GitHub CLI 2.96.0" +assert_grep 'ARG TREEHOUSE_VERSION=2.0.0' "$ROOT/Dockerfile" "image must pin treehouse 2.0.0" +assert_grep 'ARG NO_MISTAKES_VERSION=1.34.0' "$ROOT/Dockerfile" "image must pin no-mistakes 1.34.0" +assert_grep 'gh-axi@0.1.27' "$ROOT/Dockerfile" "image must pin gh-axi 0.1.27" +assert_grep 'chrome-devtools-axi@0.1.26' "$ROOT/Dockerfile" "image must pin chrome-devtools-axi 0.1.26" +assert_grep 'lavish-axi@0.1.40' "$ROOT/Dockerfile" "image must pin lavish-axi 0.1.40" +assert_grep 'tasks-axi@0.2.2' "$ROOT/Dockerfile" "image must pin tasks-axi 0.2.2" +assert_grep 'quota-axi@0.1.5' "$ROOT/Dockerfile" "image must pin quota-axi 0.1.5" +assert_grep 'ripgrep' "$ROOT/Dockerfile" "image must install ripgrep" +assert_grep 'fd-find' "$ROOT/Dockerfile" "image must install fd" +``` + +- [ ] **Step 2: Run the test and observe the missing pins** + +Run: `bash tests/agent-os-container.test.sh` + +Expected: FAIL at `image must pin GitHub CLI 2.96.0`. + +- [ ] **Step 3: Install the Debian and exact npm baseline** + +Add `fd-find`, `ripgrep`, and `rsync` to the existing apt package list. +Create `/usr/local/bin/fd` as a symlink to `/usr/bin/fdfind`. +Replace the existing Pi-only npm install with this exact install: + +```dockerfile +RUN npm install --global \ + @earendil-works/pi-coding-agent@0.80.6 \ + gh-axi@0.1.27 \ + chrome-devtools-axi@0.1.26 \ + lavish-axi@0.1.40 \ + tasks-axi@0.2.2 \ + quota-axi@0.1.5 +``` + +- [ ] **Step 4: Install checksum-verified GitHub CLI** + +Add `ARG GH_VERSION=2.96.0` and a `RUN` block that downloads `gh_${GH_VERSION}_linux_${TARGETARCH}.tar.gz`, verifies the architecture checksum, and copies `bin/gh` into `/usr/local/bin`. +Use these checksums: + +```text +amd64 83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60 +arm64 06f86ec7103d41993b76cd78072f43595c34aaa56506d971d9860e67140bf909 +``` + +- [ ] **Step 5: Install checksum-verified treehouse and no-mistakes** + +Add `ARG TREEHOUSE_VERSION=2.0.0` and `ARG NO_MISTAKES_VERSION=1.34.0`. +Download the matching Linux tarball for `TARGETARCH`, verify it, and install its binary into `/usr/local/bin`. +Use these checksums: + +```text +treehouse amd64 b7926c19633ee94582b7f1b58369f22b304ae7228a47253c2148e3a8176f03b0 +treehouse arm64 91bca451bab84df685ee17975c8a9d8cf671b3e95c96b7fc6ff0121ea0aae991 +no-mistakes amd64 449d0276e1b35369ea332dae0eddb5be326c2d4fc9643270af98858cf3906536 +no-mistakes arm64 f157df3e18350edea8abdaa065681bd115a9d321fca86f51e9a0184b3a9d8756 +``` + +- [ ] **Step 6: Run static tests** + +Run: `bash tests/agent-os-container.test.sh` + +Expected: PASS with `container files pin dependencies and exclude host credentials`. + +- [ ] **Step 7: Build and verify every baseline command** + +Run: + +```bash +bin/agent-os-local.sh build +docker run --rm --entrypoint bash agent-os:dev -lc ' + for tool in bash curl git ssh jq tmux ps rsync gh rg fd node npm pi herdr kubectl treehouse no-mistakes gh-axi chrome-devtools-axi lavish-axi tasks-axi quota-axi; do + command -v "$tool" || exit 1 + done + FM_HOME=/tmp/fm HOME=/tmp/fm mkdir -p /tmp/fm/config /tmp/fm/data /tmp/fm/state + FM_HOME=/tmp/fm HOME=/tmp/fm FM_BOOTSTRAP_DETECT_ONLY=1 /opt/agent-os/bin/fm-bootstrap.sh | grep "^MISSING:" && exit 1 || true +' +``` + +Expected: every command resolves and bootstrap emits no `MISSING:` line. + +- [ ] **Step 8: Commit the image baseline** + +```bash +git add Dockerfile tests/agent-os-container.test.sh +git commit -m "feat: bundle the Firstmate toolchain" +``` + +--- + +### Task 2: Add the persistent-prefix initializer + +**Files:** +- Create: `bin/agent-os-init.sh` +- Create: `tests/agent-os-init.test.sh` +- Modify: `Dockerfile` +- Modify: `bin/agent-os-container-entrypoint.sh` + +**Interfaces:** +- Consumes: `AGENT_OS_PERSISTENT_ROOT`, defaulting to `/persistent-agent`, and `AGENT_OS_IMAGE_USR_LOCAL`, defaulting to `/opt/image-usr-local`. +- Produces: a PVC root used as `/home/agent` and a seeded `$AGENT_OS_PERSISTENT_ROOT/usr-local`. + +- [ ] **Step 1: Write failing initializer tests** + +Create `tests/agent-os-init.test.sh` with image-baseline and runtime-added fixtures: + +```bash +#!/usr/bin/env bash +set -u +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP=$(fm_test_tmproot agent-os-init) +mkdir -p "$TMP/source/bin" "$TMP/persistent/.local/bin" "$TMP/persistent/usr-local/bin" +printf 'baseline\n' > "$TMP/source/bin/baseline" +printf 'runtime\n' > "$TMP/persistent/usr-local/bin/runtime-added" + +AGENT_OS_IMAGE_USR_LOCAL="$TMP/source" \ +AGENT_OS_PERSISTENT_ROOT="$TMP/persistent" \ + "$ROOT/bin/agent-os-init.sh" + +assert_grep baseline "$TMP/persistent/usr-local/bin/baseline" "initializer must seed image tools" +assert_grep runtime "$TMP/persistent/usr-local/bin/runtime-added" "initializer must preserve runtime tools" + +pass "initializer preserves image and runtime-installed tools" +``` + +- [ ] **Step 2: Run the test and observe the missing initializer** + +Run: `bash tests/agent-os-init.test.sh` + +Expected: FAIL because `bin/agent-os-init.sh` does not exist. + +- [ ] **Step 3: Implement the initializer** + +Create `bin/agent-os-init.sh` as a strict Bash script. +Read three integers from the first UID-map row. +Require inside UID `0`, outside UID greater than `0`, and range at least `65536`. +Create persistent `.config`, `.cache`, `.local/bin`, `.local/share`, `.bun`, `.cargo`, and `usr-local` directories below the PVC root. +Copy the image baseline with `rsync -a "$IMAGE_USR_LOCAL/" "$PERSISTENT_ROOT/usr-local/"` and do not pass `--delete`. + +- [ ] **Step 4: Configure persistent package-manager paths** + +In `Dockerfile`, set: + +```dockerfile +ENV FM_HOME=/home/agent \ + HOME=/home/agent \ + XDG_CONFIG_HOME=/home/agent/.config \ + XDG_DATA_HOME=/home/agent/.local/share \ + XDG_CACHE_HOME=/home/agent/.cache \ + NPM_CONFIG_PREFIX=/usr/local \ + BUN_INSTALL=/home/agent/.bun \ + CARGO_HOME=/home/agent/.cargo \ + PATH=/home/agent/.local/bin:/home/agent/.bun/bin:/home/agent/.cargo/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin \ + HERDR_SESSION=default +``` + +Remove `USER node` and create `/opt/image-usr-local` as a snapshot of the built `/usr/local` tree before runtime mounts hide it. + +- [ ] **Step 5: Make runtime setup persistent and idempotent** + +Update `bin/agent-os-container-entrypoint.sh` to create persistent XDG, local-bin, Bun, and Cargo directories. +After the Herdr backend configuration, install the three AXI SessionStart hooks into persistent HOME only when their marker is absent: + +```bash +for tool in gh-axi chrome-devtools-axi lavish-axi; do + marker="$HOME/.config/agent-os/setup-$tool" + if [ ! -e "$marker" ]; then + "$tool" setup hooks + mkdir -p "$(dirname "$marker")" + : > "$marker" + fi +done +``` + +- [ ] **Step 6: Run initializer and container tests** + +Run: + +```bash +bash tests/agent-os-init.test.sh +bash tests/agent-os-container.test.sh +bash -n bin/agent-os-init.sh bin/agent-os-container-entrypoint.sh +``` + +Expected: all tests pass and Bash reports no syntax errors. + +- [ ] **Step 7: Commit the persistent initializer** + +```bash +git add Dockerfile bin/agent-os-init.sh bin/agent-os-container-entrypoint.sh tests/agent-os-init.test.sh +git commit -m "feat: persist agent-installed tools" +``` + +--- + +### Task 3: Run primary and crewmates as local container root + +**Files:** +- Modify: `deploy/orbstack/primary.yaml` +- Modify: `bin/agent-os-crewmate.sh` +- Modify: `tests/agent-os-kubernetes.test.sh` + +**Interfaces:** +- Consumes: image path `/opt/image-usr-local` and per-agent PVC. +- Produces: Pod specs with root security context, init container `agent-os-init`, whole-PVC home mount, and `usr-local` subPath mount. + +- [ ] **Step 1: Add failing manifest assertions** + +Extend `tests/agent-os-kubernetes.test.sh` for both rendered primary YAML and captured child YAML: + +```bash +assert_not_contains "$rendered" 'hostUsers: false' "OrbStack demo must not request unsupported Pod user namespaces" +assert_contains "$rendered" 'runAsUser: 0' "primary must run as container root" +assert_contains "$rendered" 'name: agent-os-init' "primary must seed persistent tools" +assert_contains "$rendered" 'mountPath: /usr/local' "primary must persist /usr/local" +assert_no_grep 'hostUsers: false' "$STDIN_LOG" "OrbStack children must not request unsupported Pod user namespaces" +assert_grep 'runAsUser: 0' "$STDIN_LOG" "children must run as container root" +assert_grep 'name: agent-os-init' "$STDIN_LOG" "children must seed persistent tools" +assert_grep 'mountPath: /usr/local' "$STDIN_LOG" "children must persist /usr/local" +``` + +Retain the existing assertion for `automountServiceAccountToken: false`. + +- [ ] **Step 2: Run the Kubernetes test and observe failure** + +Run: `bash tests/agent-os-kubernetes.test.sh` + +Expected: FAIL while the manifest still requests unsupported Pod user namespaces. + +- [ ] **Step 3: Update the primary StatefulSet** + +Replace the non-root security context with `runAsUser: 0` and `runAsGroup: 0`. +Add an `agent-os-init` init container using `agent-os:dev`, command `/opt/agent-os/bin/agent-os-init.sh`, and a whole-PVC mount at `/persistent-agent`. +Mount the PVC root at `/home/agent` and PVC subPath `usr-local` at `/usr/local` in the main container. +Do not set `privileged: true` or any host namespace option. + +- [ ] **Step 4: Update generated crewmate Pods** + +Make `bin/agent-os-crewmate.sh create` emit the same root, init-container, home, and `/usr/local` structure. +Keep the distinct child PVC and `automountServiceAccountToken: false` contract unchanged. + +- [ ] **Step 5: Run static Kubernetes tests** + +Run: + +```bash +bash tests/agent-os-kubernetes.test.sh +kubectl kustomize deploy/orbstack >/dev/null +bash -n bin/agent-os-crewmate.sh +``` + +Expected: all checks pass. + +- [ ] **Step 6: Commit the Pod model** + +```bash +git add deploy/orbstack/primary.yaml bin/agent-os-crewmate.sh tests/agent-os-kubernetes.test.sh +git commit -m "feat: run local agents as container root" +``` + +--- + +### Task 4: Prove the live root and persistent tool lifecycle + +**Files:** +- Modify if evidence reveals a defect: files owned by Tasks 1 through 3 and their colocated tests. + +**Interfaces:** +- Consumes: OrbStack context, `agent-os:dev`, namespace `agent-os-demo`. +- Produces: empirical proof for container root, baseline completeness, and durable runtime tools. + +- [ ] **Step 1: Rebuild and deploy** + +Run: + +```bash +bin/agent-os-local.sh build +bin/agent-os-local.sh deploy +kubectl --context orbstack -n agent-os-demo rollout status statefulset/agent-os-firstmate --timeout=180s +``` + +Expected: the StatefulSet reaches `1/1` Ready. + +- [ ] **Step 2: Verify container root** + +Run: + +```bash +kubectl --context orbstack -n agent-os-demo exec statefulset/agent-os-firstmate -- id +``` + +Expected: `id` reports UID 0. + +- [ ] **Step 3: Verify the complete baseline** + +Run: + +```bash +kubectl --context orbstack -n agent-os-demo exec statefulset/agent-os-firstmate -- bash -lc ' + for tool in gh rg fd treehouse no-mistakes gh-axi chrome-devtools-axi lavish-axi tasks-axi quota-axi; do command -v "$tool" || exit 1; done + FM_BOOTSTRAP_DETECT_ONLY=1 /opt/agent-os/bin/fm-bootstrap.sh | tee /tmp/bootstrap.out + ! grep -q "^MISSING:" /tmp/bootstrap.out +' +``` + +Expected: all tools resolve and bootstrap reports `NEEDS_GH_AUTH` but no missing tool. + +- [ ] **Step 4: Add persistent runtime tools** + +Run: + +```bash +kubectl --context orbstack -n agent-os-demo exec statefulset/agent-os-firstmate -- bash -lc ' + printf "#!/bin/sh\necho home-persisted\n" > /home/agent/.local/bin/home-proof + printf "#!/bin/sh\necho usr-local-persisted\n" > /usr/local/bin/usr-local-proof + chmod +x /home/agent/.local/bin/home-proof /usr/local/bin/usr-local-proof +' +``` + +Expected: both writes succeed as container root. + +- [ ] **Step 5: Replace the primary Pod and prove persistence** + +Run: + +```bash +kubectl --context orbstack -n agent-os-demo delete pod agent-os-firstmate-0 --wait=true +kubectl --context orbstack -n agent-os-demo rollout status statefulset/agent-os-firstmate --timeout=180s +kubectl --context orbstack -n agent-os-demo exec statefulset/agent-os-firstmate -- home-proof +kubectl --context orbstack -n agent-os-demo exec statefulset/agent-os-firstmate -- usr-local-proof +``` + +Expected: output is `home-persisted` and `usr-local-persisted`. + +- [ ] **Step 6: Prove a root crewmate remains Kubernetes-unprivileged** + +Run: + +```bash +kubectl --context orbstack -n agent-os-demo exec statefulset/agent-os-firstmate -- /opt/agent-os/bin/agent-os-crewmate.sh create root-proof +kubectl --context orbstack -n agent-os-demo wait --for=condition=Ready pod/agent-os-crewmate-root-proof --timeout=180s +kubectl --context orbstack -n agent-os-demo exec agent-os-crewmate-root-proof -- id +kubectl --context orbstack -n agent-os-demo exec agent-os-crewmate-root-proof -- test ! -e /var/run/secrets/kubernetes.io/serviceaccount/token +kubectl --context orbstack -n agent-os-demo exec statefulset/agent-os-firstmate -- /opt/agent-os/bin/agent-os-crewmate.sh delete root-proof +``` + +Expected: child UID 0 is active, no ServiceAccount token exists, and cleanup removes its Pod and PVC. + +- [ ] **Step 7: Convert any live defect into a failing test before fixing it** + +For each failure, add the smallest regression assertion to the owning test, run it to observe failure, implement the fix, and rerun Tasks 1 through 3 tests. + +- [ ] **Step 8: Commit live-proof fixes if needed** + +```bash +git add Dockerfile bin deploy tests +git diff --cached --quiet || git commit -m "fix: complete the persistent root agent runtime" +``` + +--- + +### Task 5: Document, validate, and update the review branch + +**Files:** +- Modify: `docs/kubernetes.md` +- Modify: `README.md` only if the current Kubernetes summary becomes inaccurate. + +**Interfaces:** +- Consumes: verified runtime behavior from Task 4. +- Produces: operator guidance for root isolation, persistent installs, authentication, and known ephemeral paths. + +- [ ] **Step 1: Update the Kubernetes guide** + +Document these verified facts in `docs/kubernetes.md`, one sentence per physical line: + +```text +Agents run as UID 0 on the isolated local OrbStack VM node. +The complete Firstmate baseline is part of the image. +Tools installed below /home/agent persistent prefixes or /usr/local survive Pod replacement. +apt remains available, but files it writes outside persistent prefixes are ephemeral. +GitHub and model authentication live on the individual agent PVC. +The Pods do not use privileged mode, host namespaces, or host mounts. +``` + +- [ ] **Step 2: Run the complete focused verification set** + +Run: + +```bash +bash tests/agent-os-local.test.sh +bash tests/agent-os-container.test.sh +bash tests/agent-os-init.test.sh +bash tests/agent-os-kubernetes.test.sh +for script in bin/agent-os-*.sh; do bash -n "$script"; done +git diff --check +bin/fm-lint.sh +``` + +Expected: all tests pass, `git diff --check` is silent, and `fm-lint.sh` exits zero. + +- [ ] **Step 3: Commit documentation** + +```bash +git add docs/kubernetes.md README.md +git diff --cached --quiet || git commit -m "docs: explain persistent root agents" +``` + +- [ ] **Step 4: Verify clean committed state** + +Run: + +```bash +git status --short --branch +git log --oneline origin/feat/orbstack-demo..HEAD +``` + +Expected: no uncommitted files and only the approved spec, plan, and implementation commits are ahead of the remote branch. + +- [ ] **Step 5: Push the existing review branch** + +Run: `git push origin feat/orbstack-demo` + +Expected: GitHub updates pull request . diff --git a/docs/superpowers/specs/2026-07-12-root-userns-persistent-toolchain-design.md b/docs/superpowers/specs/2026-07-12-root-userns-persistent-toolchain-design.md new file mode 100644 index 000000000..55f40abd3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-root-userns-persistent-toolchain-design.md @@ -0,0 +1,106 @@ +# Local root agents and persistent toolchain design + +**Date:** 2026-07-12 + +## Purpose + +Agent OS Pods must be able to administer their own container environment in the isolated local OrbStack cluster. +The image must already contain the complete Firstmate toolchain so a fresh controller can begin useful work without an installation round. +Tools and authentication added by an agent at runtime must survive Pod replacement. + +## Decisions + +Every Agent OS Pod runs as UID 0 inside its container. +OrbStack's built-in Kubernetes uses cri-dockerd and rejects `hostUsers: false`, so the local demo does not request a Pod user namespace. +Container root is therefore root on the dedicated OrbStack VM node for this local demo. +No Agent OS Pod uses privileged mode, a host PID namespace, a host IPC namespace, a host network namespace, a raw block device, or a host-path mount. + +The image remains the reproducible baseline. +Each agent receives its own PVC-backed home and persistent `/usr/local` tree for runtime adaptation. +No agent mounts another agent's persistent state. + +## Image baseline + +The image includes the universal Firstmate toolchain and the Kubernetes demo runtime: + +- Bash, CA certificates, curl, Git, OpenSSH client, jq, tmux, procps, and rsync. +- GitHub CLI, ripgrep, and fd. +- Node.js, npm, and Pi. +- Herdr and kubectl. +- treehouse and no-mistakes. +- gh-axi, chrome-devtools-axi, lavish-axi, tasks-axi, and quota-axi. + +The Herdr backend does not require Orca. +Agent harnesses other than Pi remain optional runtime additions rather than baseline requirements. +Downloaded release binaries use exact versions and checksums where upstream publishes them. +Global npm packages use exact versions. +The build fails for an unsupported architecture or failed integrity check. + +## Persistent filesystem model + +Every agent PVC contains two independently used trees: + +- `/home/agent` stores Agent OS state, GitHub and model authentication, configuration, projects, package-manager state, and user-installed binaries. +- a PVC subdirectory mounted at `/usr/local` stores global npm packages and tools that installers place in `/usr/local`. + +An init container runs from the same image before the agent starts. +It copies the image's `/usr/local` baseline into the PVC-backed `/usr/local` without deleting agent-installed files. +Image-owned files overwrite older image-owned copies so a new image can update the baseline. +Agent-added paths that do not collide with the baseline remain intact. + +The runtime environment puts `/home/agent/.local/bin`, `/home/agent/.bun/bin`, `/home/agent/.cargo/bin`, and `/usr/local/bin` before system paths. +It sets persistent XDG directories below `/home/agent` and configures npm's global prefix to `/usr/local`. +Installers that honor HOME, XDG paths, npm prefix, Bun home, Cargo home, or `/usr/local` therefore survive Pod replacement. + +`apt` remains available because the process is root inside the container. +Packages installed into `/usr`, `/etc`, or `/var` with `apt` are intentionally ephemeral and disappear on Pod replacement. +Firstmate's required tools do not depend on runtime `apt` because they are part of the image baseline. +An agent that needs an additional durable CLI should prefer `/usr/local` or a persistent home prefix. + +## Pod security and authority + +The primary and crewmate containers set `runAsUser: 0` and `runAsGroup: 0`. +The init container uses the same container-root identity to seed the persistent tool tree. +The Pods use the runtime's normal container isolation and do not request privileged mode. + +The Firstmate ServiceAccount retains cluster-admin only for the isolated local Agent OS cluster. +Crewmates continue to receive no Kubernetes ServiceAccount token by default. +Container root and Kubernetes API authority remain separate controls. + +This root policy is explicitly limited to the disposable, agents-only local OrbStack VM cluster. +A remote or shared Agent OS cluster must define and verify its own stronger runtime isolation before reusing this policy. + +## Startup flow + +1. Kubernetes creates or reattaches the agent's PVC. +2. The init container ensures the persistent home and `/usr/local` directories exist. +3. The init container refreshes image-owned `/usr/local` files while preserving runtime additions. +4. The main container starts as container root with the persistent environment configured. +5. Herdr starts as PID 1. +6. Firstmate bootstrap finds its required tools and reports only missing authentication or genuinely optional additions. + +## Failure behavior + +The image build stops on missing releases, unsupported architectures, checksum failures, or missing baseline commands. +The init container stops startup if it cannot seed the persistent tool tree. +No fallback grants privileged mode, host namespaces, or host mounts. + +## Verification + +Static tests assert that both primary and generated crewmate Pod specs use container UID 0 without requesting the unsupported `hostUsers: false` field. +Container tests assert every baseline command is present and Firstmate bootstrap emits no `MISSING:` diagnostics. +Runtime tests prove root can write to normal container paths and durable installs can write to both persistent prefixes. +Persistence tests write one tool below `/home/agent/.local/bin` and one below `/usr/local/bin`, replace the Pod, and execute both tools afterward. +Isolation tests prove crewmates have distinct PVCs and no ServiceAccount token. +Authentication verification checks that GitHub CLI configuration survives Pod replacement without embedding credentials in the image. + +## Out of scope + +This change does not persist the entire mutable container root filesystem. +It does not add privileged Pods, host mounts, a custom package service, GitOps, or a new inter-agent protocol. +It does not define production-cluster RBAC beyond retaining the existing local-demo boundary. + +## Sources + +- Kubernetes v1.34 user-namespace documentation, which records cri-dockerd as unsupported: . +- Firstmate's universal toolchain contract: `docs/configuration.md` under `Toolchain` and `bin/fm-bootstrap.sh`. diff --git a/image/debian.sources b/image/debian.sources new file mode 100644 index 000000000..f12f97c47 --- /dev/null +++ b/image/debian.sources @@ -0,0 +1,13 @@ +Types: deb +URIs: http://snapshot.debian.org/archive/debian/20260624T235959Z +Suites: trixie trixie-updates +Components: main +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg +Check-Valid-Until: no + +Types: deb +URIs: http://snapshot.debian.org/archive/debian-security/20260624T235959Z +Suites: trixie-security +Components: main +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg +Check-Valid-Until: no diff --git a/image/npm/package-lock.json b/image/npm/package-lock.json new file mode 100644 index 000000000..bf2aca81d --- /dev/null +++ b/image/npm/package-lock.json @@ -0,0 +1,3338 @@ +{ + "name": "agent-os-image-runtime", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agent-os-image-runtime", + "version": "1.0.0", + "dependencies": { + "@earendil-works/pi-coding-agent": "0.80.6", + "chrome-devtools-axi": "0.1.26", + "gh-axi": "0.1.27", + "lavish-axi": "0.1.40", + "quota-axi": "0.1.5", + "tasks-axi": "0.2.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.6.tgz", + "integrity": "sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-tui": "^0.80.6", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.1.38", + "undici": "8.5.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", + "integrity": "sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.80.6", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", + "integrity": "sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "./dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz", + "integrity": "sha512-bSuzS4EVSqEPj/Qr/p9eqCESfKsGuDNbl77EGci8Iaqqt/C/XCBZL1MjXaxSWW1NsT5afjp/Cb0NTPzOLv/aPA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@tailwindcss/browser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/browser/-/browser-4.2.4.tgz", + "integrity": "sha512-yd+3CxxuF1KDt9Q+405JR3/vyotvS5eMIsZPEynEak/JybvFVn8mVmLjVUxgNmrFB6EGCc89lXBECzIZA+YeXQ==", + "license": "MIT" + }, + "node_modules/@toon-format/toon": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.0.tgz", + "integrity": "sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/axi-sdk-js": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/axi-sdk-js/-/axi-sdk-js-0.1.8.tgz", + "integrity": "sha512-N8Qd/9sVBpG8QRVSw0lLevdFuaBfjHHPdcbcj8DCuxr68sA1/c5KCnGEDqO7lI3kt52/bgymCZajmpTqz/rLOw==", + "license": "MIT", + "dependencies": { + "@toon-format/toon": "^2.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-devtools-axi": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/chrome-devtools-axi/-/chrome-devtools-axi-0.1.26.tgz", + "integrity": "sha512-ZAquXPHjvQQL+r6rb0rrr0sHvIf6uPPzBCm7NgVpcPYDjBvdkeYfuX0WINLDdIFDga1PTu2jTC8CdcXLLKusFw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "@toon-format/toon": "^2.1.0", + "axi-sdk-js": "^0.1.8" + }, + "bin": { + "chrome-devtools-axi": "dist/bin/chrome-devtools-axi.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/daisyui": { + "version": "5.6.18", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.6.18.tgz", + "integrity": "sha512-6y9rboRQl8fosnusy/5pg11yp+H0LtK3YdtfS4Vf2QVnaYOKasNdYm6hDxeiT6bwZPdl4WRPzVIlIApdo2ssBg==", + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gh-axi": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/gh-axi/-/gh-axi-0.1.27.tgz", + "integrity": "sha512-SoA5VgSa/hil7/TAQaaAeU4guOCCXKoJLdpOnAO8Bzt5jnZohqUHYJQ1P1C85jwKDN1urQn2RezE2wz5t6va9Q==", + "license": "MIT", + "dependencies": { + "@toon-format/toon": "^2.1.0", + "axi-sdk-js": "^0.1.8" + }, + "bin": { + "gh-axi": "dist/bin/gh-axi.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.30", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", + "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lavish-axi": { + "version": "0.1.40", + "resolved": "https://registry.npmjs.org/lavish-axi/-/lavish-axi-0.1.40.tgz", + "integrity": "sha512-vISqcfR3KcdInhzJHE8vUM2yaoZbGHY+uxrAUjMC5psyInfJwDvbgiRwK9GZFcCT2Rvs652wZqXtMa8dLCYoCA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/browser": "4.2.4", + "axi-sdk-js": "^0.1.8", + "chokidar": "^4.0.3", + "daisyui": "^5.5.19", + "express": "^5.2.1", + "open": "^10.2.0", + "parse5": "^8.0.1" + }, + "bin": { + "lavish-axi": "dist/cli.mjs" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quota-axi": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/quota-axi/-/quota-axi-0.1.5.tgz", + "integrity": "sha512-EGaA78dBNnnIu5i2t7JSjhhwZogsAsHKpdG67wrxsVEy/CTw9uuYCBi+DJ3bO6+lrFnzXpDXs4h1NZ6J8DOLfA==", + "license": "MIT", + "dependencies": { + "@toon-format/toon": "2.1.0" + }, + "bin": { + "quota-axi": "dist/bin/quota-axi.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/quota-axi/node_modules/@toon-format/toon": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.1.0.tgz", + "integrity": "sha512-JwWptdF5eOA0HaQxbKAzkpQtR4wSWTEfDlEy/y3/4okmOAX1qwnpLZMmtEWr+ncAhTTY1raCKH0kteHhSXnQqg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tasks-axi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/tasks-axi/-/tasks-axi-0.2.2.tgz", + "integrity": "sha512-k5kN2RYFAaVkslN5nWKndf6Wmv99EFl0GuQTbDvVNfu0reoDZmBnZV1a+ZYHqb/owQhFcHiUykMbn8mO/I0Utw==", + "license": "MIT", + "dependencies": { + "@toon-format/toon": "^2.1.0", + "axi-sdk-js": "^0.1.7" + }, + "bin": { + "tasks-axi": "dist/bin/tasks-axi.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/image/npm/package.json b/image/npm/package.json new file mode 100644 index 000000000..712959dbe --- /dev/null +++ b/image/npm/package.json @@ -0,0 +1,13 @@ +{ + "name": "agent-os-image-runtime", + "private": true, + "version": "1.0.0", + "dependencies": { + "@earendil-works/pi-coding-agent": "0.80.6", + "chrome-devtools-axi": "0.1.26", + "gh-axi": "0.1.27", + "lavish-axi": "0.1.40", + "quota-axi": "0.1.5", + "tasks-axi": "0.2.2" + } +} diff --git a/tests/agent-os-candidate-state.test.sh b/tests/agent-os-candidate-state.test.sh new file mode 100755 index 000000000..7748c327a --- /dev/null +++ b/tests/agent-os-candidate-state.test.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +STATE="$ROOT/bin/agent-os-candidate-state.sh" +TMP=$(fm_test_tmproot agent-os-candidate-state) +mkdir -p "$TMP" +OWNER_A=101 +OWNER_B=202 +OWNER_C=303 +CLAIM_A=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +CLAIM_B=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +CLAIM_C=cccccccccccccccccccccccccccccccccccccccc + +write_chain() { + printf '%b\n' "$@" > "$TMP/chain.tsv" +} + +expect_decision() { + local expected=$1 actual + actual=$("$STATE" "$TMP/chain.tsv") || fail "claim chain did not produce $expected" + [ "$actual" = "$expected" ] || fail "claim chain produced $actual instead of $expected" +} + +expect_refusal() { + if "$STATE" "$TMP/chain.tsv" >/dev/null 2>&1; then + fail "claim chain authorized another build" + fi +} + +write_chain "$OWNER_A\t$CLAIM_A\tabsent\t0\t0" +expect_decision build + +write_chain \ + "$OWNER_A\t$CLAIM_A\tattempted\t0\t1" \ + "$OWNER_B\t$CLAIM_B\tabsent\t0\t0" +expect_decision $'reuse-build\t'"$OWNER_A" + +write_chain \ + "$OWNER_A\t$CLAIM_A\tattempted\t1\t0" \ + "$OWNER_B\t$CLAIM_B\tabsent\t0\t0" \ + "$OWNER_C\t$CLAIM_C\tabsent\t0\t0" +expect_decision $'reuse-record\t'"$OWNER_A" + +write_chain \ + "$OWNER_A\t$CLAIM_A\tattempted\t1\t1" \ + "$OWNER_B\t$CLAIM_B\tabsent\t0\t0" +expect_decision $'reuse-record-pair\t'"$OWNER_A"$'\t'"$OWNER_A" + +write_chain \ + "$OWNER_A\t$CLAIM_A\tattempted\t0\t1" \ + "$OWNER_B\t$CLAIM_B\tabsent\t1\t0" +expect_decision $'reuse-record-pair\t'"$OWNER_B"$'\t'"$OWNER_A" + +builds=0 +write_chain "$OWNER_A\t$CLAIM_A\tabsent\t0\t0" +decision=$("$STATE" "$TMP/chain.tsv") || fail "initial claim did not authorize its build" +[ "$decision" = build ] && builds=$((builds + 1)) +write_chain \ + "$OWNER_A\t$CLAIM_A\tattempted\t0\t1" \ + "$OWNER_B\t$CLAIM_B\tabsent\t0\t0" +decision=$("$STATE" "$TMP/chain.tsv") || fail "handoff did not reuse ancestor evidence" +[ "$decision" = build ] && builds=$((builds + 1)) +write_chain \ + "$OWNER_A\t$CLAIM_A\tattempted\t0\t1" \ + "$OWNER_B\t$CLAIM_B\tabsent\t1\t0" \ + "$OWNER_C\t$CLAIM_C\tabsent\t0\t0" +decision=$("$STATE" "$TMP/chain.tsv") || fail "second handoff did not prefer paired ancestor evidence" +[ "$decision" = build ] && builds=$((builds + 1)) +[ "$decision" = $'reuse-record-pair\t'"$OWNER_B"$'\t'"$OWNER_A" ] || \ + fail "second handoff did not reuse paired ancestor evidence" +[ "$builds" -eq 1 ] || fail "claim-chain classifier authorized $builds builds" + +write_chain \ + "$OWNER_A\t$CLAIM_A\tattempted\t0\t0" \ + "$OWNER_B\t$CLAIM_B\tabsent\t0\t0" +expect_refusal + +write_chain \ + "$OWNER_A\t$CLAIM_A\tattempted\t0\t1" \ + "$OWNER_B\t$CLAIM_B\tattempted\t0\t1" +expect_refusal + +write_chain \ + "$OWNER_A\t$CLAIM_A\tabsent\t0\t1" \ + "$OWNER_B\t$CLAIM_B\tabsent\t0\t0" +expect_refusal + +write_chain \ + "$OWNER_A\t$CLAIM_A\tabsent\t1\t0" \ + "$OWNER_B\t$CLAIM_B\tattempted\t0\t1" +expect_refusal + +write_chain \ + "$OWNER_A\t$CLAIM_A\tattempted\t0\t1" \ + "$OWNER_B\t$CLAIM_B\tabsent\t1\t0" \ + "$OWNER_C\t$CLAIM_C\tabsent\t1\t0" +expect_refusal + +for state in corrupt mismatched unreadable metadata-read-error partial ambiguous; do + write_chain "$OWNER_A\t$CLAIM_A\t$state\t0\t0" + expect_refusal + write_chain "$OWNER_A\t$CLAIM_A\tattempted\t$state\t0" + expect_refusal +done + +for counts in '2\t0' '0\t2' '2\t2'; do + write_chain "$OWNER_A\t$CLAIM_A\tattempted\t$counts" + expect_refusal +done + +write_chain \ + "$OWNER_A\t$CLAIM_A\tabsent\t0\t0" \ + "$OWNER_A\t$CLAIM_B\tabsent\t0\t0" +expect_refusal +write_chain \ + "$OWNER_A\t$CLAIM_A\tabsent\t0\t0" \ + "$OWNER_B\t$CLAIM_A\tabsent\t0\t0" +expect_refusal + +pass "candidate claim-chain classifier permits one build and ancestor reuse only" diff --git a/tests/agent-os-container.test.sh b/tests/agent-os-container.test.sh new file mode 100755 index 000000000..3f2755c00 --- /dev/null +++ b/tests/agent-os-container.test.sh @@ -0,0 +1,459 @@ +#!/usr/bin/env bash +# Static reproducibility and credential-boundary tests for the Agent OS image. +# Source assertions intentionally use single quotes for literal variable references. +# shellcheck disable=SC2016 +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +IMAGE_WORKFLOW="$ROOT/.github/workflows/agent-os-image.yml" + +assert_grep 'FROM node:24-trixie-slim@sha256:366fdef91728b1b7fa18c84fba63b6e79ed77b7e10cc206878e9705da4d7b169' \ + "$ROOT/Dockerfile" "image must pin the multi-architecture Node 24 Trixie base" +assert_grep 'sha256:366fdef91728b1b7fa18c84fba63b6e79ed77b7e10cc206878e9705da4d7b169' \ + "$ROOT/THIRD_PARTY_NOTICES.md" "base image provenance must record the reviewed index digest" +assert_grep 'ARG HERDR_VERSION=0.7.3' "$ROOT/Dockerfile" "image must pin Herdr 0.7.3" +assert_grep 'ARG KUBECTL_VERSION=1.34.8' "$ROOT/Dockerfile" "image must pin kubectl 1.34.8" +assert_grep 'ARG GH_VERSION=2.96.0' "$ROOT/Dockerfile" "image must pin GitHub CLI 2.96.0" +assert_grep 'ARG TREEHOUSE_VERSION=2.0.0' "$ROOT/Dockerfile" "image must pin treehouse 2.0.0" +assert_grep 'ARG NO_MISTAKES_VERSION=1.34.0' "$ROOT/Dockerfile" "image must pin no-mistakes 1.34.0" +assert_grep 'ARG BUN_VERSION=1.3.14' "$ROOT/Dockerfile" "image must pin stable Bun 1.3.14" +assert_grep 'ARG AKUA_VERSION=0.8.25' "$ROOT/Dockerfile" "image must pin Akua 0.8.25" +assert_grep 'ARG K9S_VERSION=0.51.0' "$ROOT/Dockerfile" "image must pin K9s 0.51.0" +assert_present "$ROOT/image/debian.sources" "image must commit immutable Debian snapshot inputs" +assert_grep 'snapshot.debian.org/archive/debian/20260624T235959Z' "$ROOT/image/debian.sources" \ + "image must use the reviewed immutable Debian snapshot" +assert_grep 'snapshot.debian.org/archive/debian-security/20260624T235959Z' "$ROOT/image/debian.sources" \ + "image must pin the matching Debian security snapshot" +assert_grep 'debian.sources' "$ROOT/Dockerfile" \ + "image build must install and checksum the committed Debian source input" +assert_present "$ROOT/image/npm/package.json" "image runtime npm dependencies must have a committed manifest" +assert_present "$ROOT/image/npm/package-lock.json" "image runtime npm dependencies must have a committed lockfile" +assert_grep '"lockfileVersion": 3' "$ROOT/image/npm/package-lock.json" \ + "image runtime npm lock must use the reproducible current lock format" +assert_grep '"integrity": "sha512-' "$ROOT/image/npm/package-lock.json" \ + "image runtime npm lock must checksum resolved artifacts" +node - "$ROOT/image/npm/package-lock.json" <<'NODE' || fail "every fetched npm artifact must have committed integrity" +const fs = require("fs"); +const lock = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const missing = Object.entries(lock.packages) + .filter(([, value]) => value.resolved && !value.integrity) + .map(([name]) => name); +if (missing.length) { + console.error(`missing npm integrity: ${missing.join(", ")}`); + process.exit(1); +} +NODE +assert_grep 'npm ci --omit=dev --ignore-scripts' "$ROOT/Dockerfile" \ + "image runtime npm installation must consume only the committed lock" +assert_no_grep 'npm install --global' "$ROOT/Dockerfile" \ + "image build must not resolve mutable global npm dependency graphs" +assert_grep 'sha256sum -c -' "$ROOT/Dockerfile" "downloaded runtime binaries must be checksum verified" +assert_grep '"@earendil-works/pi-coding-agent": "0.80.6"' "$ROOT/image/npm/package.json" "image must pin Pi 0.80.6" +assert_grep '"gh-axi": "0.1.27"' "$ROOT/image/npm/package.json" "image must pin gh-axi 0.1.27" +assert_grep '"chrome-devtools-axi": "0.1.26"' "$ROOT/image/npm/package.json" "image must pin chrome-devtools-axi 0.1.26" +assert_grep '"lavish-axi": "0.1.40"' "$ROOT/image/npm/package.json" "image must pin lavish-axi 0.1.40" +assert_grep '"tasks-axi": "0.2.2"' "$ROOT/image/npm/package.json" "image must pin tasks-axi 0.2.2" +assert_grep '"quota-axi": "0.1.5"' "$ROOT/image/npm/package.json" "image must pin quota-axi 0.1.5" +assert_grep 'ripgrep' "$ROOT/Dockerfile" "image must install ripgrep" +assert_grep 'fd-find' "$ROOT/Dockerfile" "image must install fd" +assert_grep 'FM_HOME=/home/agent' "$ROOT/Dockerfile" "image must declare the persistent firstmate home" +assert_grep 'never create or read operational state through repo-relative' "$ROOT/AGENTS.md" \ + "Firstmate must anchor operational state to FM_HOME" +assert_grep 'pass a provider-qualified model id' "$ROOT/.agents/skills/harness-adapters/SKILL.md" \ + "Pi dispatch must preserve the selected provider route" +assert_grep 'XDG_CONFIG_HOME=/home/agent/.config' "$ROOT/Dockerfile" "image must persist XDG configuration" +assert_grep 'NPM_CONFIG_PREFIX=/home/agent/.local' "$ROOT/Dockerfile" "global npm installs must use the persistent user prefix" +assert_grep 'PATH=/home/agent/.local/bin:/home/agent/.bun/bin:/home/agent/.cargo/bin:/usr/local/bin' "$ROOT/Dockerfile" \ + "persistent tool prefixes must lead PATH" +assert_grep 'agent-os-image-usr-local.manifest.sha256' "$ROOT/Dockerfile" "image must authenticate immutable /usr/local ownership" +assert_no_grep 'mountPath = "/usr/local"' "$ROOT/tools/agent-os/packages/firstmate/package.k" \ + "image-owned /usr/local must not be overlaid by persistent state" +# shellcheck disable=SC2016 # Match the literal Docker build argument reference. +assert_grep 'akua-dev/akua/releases/download/v${AKUA_VERSION}' "$ROOT/Dockerfile" "image must install Akua from its release" +# shellcheck disable=SC2016 # Match the literal Docker build argument reference. +assert_grep 'derailed/k9s/releases/download/v${K9S_VERSION}' "$ROOT/Dockerfile" "image must install K9s from its release" +assert_grep 'ln -s /opt/agent-os/tools/agent-os/src/cli.ts /usr/local/bin/agent-os' "$ROOT/Dockerfile" \ + "image must expose the Agent OS tool" +assert_grep 'bun install --frozen-lockfile --production --ignore-scripts' "$ROOT/Dockerfile" \ + "image install must not clone development source checkouts" +assert_no_grep 'USER node' "$ROOT/Dockerfile" "Agent OS containers must start as container root" +assert_grep 'exec herdr server' "$ROOT/bin/agent-os-container-entrypoint.sh" "entrypoint must keep Herdr as PID 1" +assert_grep 'setup hooks' "$ROOT/bin/agent-os-container-entrypoint.sh" "entrypoint must install persistent AXI hooks" +assert_grep 'agent-os-kubeconfig.sh' "$ROOT/bin/agent-os-container-entrypoint.sh" "entrypoint must prepare in-cluster kubectl access" +assert_grep 'AGENT_OS_TEST_PI_MODEL' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "test-mode Pods must converge the Pi model policy" +assert_grep 'defaultThinkingLevel' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "test-mode Pods must also pin direct Pi sessions" +# shellcheck disable=SC2016 # Match literal entrypoint variables. +assert_grep 'ln -sfn -- "$AGENT_OS_PI_AUTH_FILE" "$HOME/.pi/agent/auth.json"' \ + "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "entrypoint must link projected authorization without copying Secret bytes" +assert_no_grep 'cp .*AGENT_OS_PI_AUTH_FILE' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "entrypoint must never persist projected Secret bytes" +assert_grep 'image: agent-os:dev' "$ROOT/deploy/orbstack/inputs.yaml" \ + "the OrbStack profile must define its local image source" +assert_grep 'imagePullPolicy: Never' "$ROOT/deploy/orbstack/inputs.yaml" \ + "the OrbStack profile must use its local image store" +assert_grep 'rbac: cluster-admin' "$ROOT/deploy/orbstack/inputs.yaml" \ + "the OrbStack profile must make its local-demo grant explicit" +assert_grep 'tokenFile:' "$ROOT/bin/agent-os-kubeconfig.sh" "kubeconfig must follow the projected token file" +assert_no_grep 'set-credentials.*--token' "$ROOT/bin/agent-os-kubeconfig.sh" "kubeconfig must not copy a bearer token" +assert_grep 'automountServiceAccountToken: false' "$ROOT/tools/agent-os/packages/firstmate/crewmate.yaml" \ + "runtime mate template must deny ambient Kubernetes credentials" +assert_grep 'agent-os.dev/crewmate' "$ROOT/tools/agent-os/packages/firstmate/crewmate.yaml" \ + "runtime mate template must use the portable Agent OS label" +assert_grep 'AGENT_OS_CREWMATE_TEMPLATE' "$ROOT/bin/agent-os-crewmate.sh" \ + "mate runtime must render the canonical package template" +assert_absent "$ROOT/tools/agent-os/packages/mate/package.k" \ + "mate creation must not remain a separately installable package" +assert_grep '**' "$ROOT/.dockerignore" "the Docker context must default-deny every local path" +assert_grep '!image/agent-os-source.tar' "$ROOT/.dockerignore" "the Docker context must admit only the tracked source export" +assert_grep '!image/agent-os-bootstrap.tar' "$ROOT/.dockerignore" "the Docker context must admit only the shallow bootstrap export" +assert_no_grep '!.pi/' "$ROOT/.dockerignore" "local harness homes must never be admitted directly" +assert_grep 'agent-os-source.tar' "$ROOT/Dockerfile" "image must consume the tracked exact-source export" +assert_grep 'agent-os-bootstrap.tar' "$ROOT/Dockerfile" "image must consume the shallow sanitized bootstrap" +assert_grep 'rev-parse "$AGENT_OS_SOURCE_COMMIT^{tree}"' "$ROOT/Dockerfile" "image source bootstrap must verify its exact tree" +assert_grep 'cmp /tmp/verified-source.tar /tmp/agent-os-source.tar' "$ROOT/Dockerfile" \ + "image source must be byte-identical to the archive materialized from the verified commit" +assert_grep 'AS source-bootstrap' "$ROOT/Dockerfile" "source processing must stay in an isolated build stage" +assert_no_grep 'COPY \. /opt/agent-os' "$ROOT/Dockerfile" "the image must never copy the ambient workspace" +assert_grep 'fetch --depth=1 --no-tags' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source preparation must fetch an allowlisted remote ref freshly" +assert_grep 'GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source preparation must isolate trusted Git operations from ambient configuration" +assert_grep 'env -i' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source preparation must use an environment allowlist for trusted Git operations" +assert_grep 'validate_source_git_config' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source preparation must reject executable repository-local Git configuration" +assert_grep 'GIT_BIN=/usr/bin/git' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source preparation must pin trusted Git outside the caller PATH" +assert_no_grep 'config --worktree' "$ROOT/bin/agent-os-source-bundle.sh" \ + "standard clones must not alias their local config through --worktree" +assert_grep 'extensions.worktreeconfig' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source preparation must inspect config.worktree only when explicitly enabled" +assert_grep 'canonical_github_origin' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source preparation must canonicalize the trusted GitHub HTTPS origin" +assert_grep 'https://github.com/akua-dev/agent-os|https://github.com/akua-dev/agent-os.git)' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source preparation must accept the checkout origin without a dot-git suffix" +assert_grep 'repository origin is not the exact trusted HTTPS origin' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source preparation must require the exact trusted HTTPS origin" +assert_grep 'AGENT_OS_SOURCE_MODE=event AGENT_OS_SOURCE_EVENT_COMMIT' "$IMAGE_WORKFLOW" \ + "pull requests must build their exact event head without publication credentials" +assert_no_grep 'git clone --depth=1 --branch main' "$IMAGE_WORKFLOW" \ + "pull-request validation must not substitute protected main for the reviewed source" +assert_grep 'release-record-commit=' "$IMAGE_WORKFLOW" \ + "historical releases must name an immutable allowlisted release record" +assert_grep 'tag_ruleset_sha256' "$ROOT/bin/agent-os-source-bundle.sh" \ + "release records must bind the protected tag ruleset" +assert_grep 'source_archive_sha256' "$ROOT/bin/agent-os-source-bundle.sh" \ + "release records must bind the exact archived source" +assert_grep 'AGENT_OS_RELEASE_BOOTSTRAP_ARCHIVE' "$ROOT/bin/agent-os-source-bundle.sh" \ + "release preparation must consume the retained candidate bootstrap artifact" +assert_grep 'cp -- "$RELEASE_BOOTSTRAP_ARCHIVE" "$BOOTSTRAP_ARCHIVE.tmp.$$"' \ + "$ROOT/bin/agent-os-source-bundle.sh" \ + "release preparation must preserve exact candidate bootstrap bytes" +assert_grep 'GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "runtime provenance must isolate trusted Git operations" +assert_grep 'env -i' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "runtime provenance must clear ambient Git paths, objects, helpers, and transport state" +assert_grep 'GIT_BIN=/usr/bin/git' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "runtime provenance must pin trusted Git outside persistent user paths" +assert_grep 'canonical FM_ROOT Git config key is not allowlisted' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "runtime provenance must use a strict repository-config allowlist" +assert_no_grep 'config --file "$config" --no-includes --name-only --get-regexp' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "runtime provenance must not rely on a dangerous-key blacklist" +assert_grep 'status --porcelain --untracked-files=all' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source preparation must reject a dirty workspace" +assert_no_grep 'bundle create.*HEAD' "$ROOT/bin/agent-os-source-bundle.sh" \ + "source bootstrap must not retain reachable deleted history" +assert_grep 'merge --ff-only' "$ROOT/bin/agent-os-container-entrypoint.sh" "persistent source transitions must be fast-forward only" +assert_grep 'refs/remotes/agent-os-verified' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "canonical runtime source must use a freshly fetched verification ref" +assert_grep 'trusted_git -C "$FM_ROOT" fetch --no-tags --prune "$SOURCE_ORIGIN"' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "runtime provenance must fail closed unless the trusted remote is reachable" +assert_no_grep 'checkout --detach' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "canonical runtime source must remain on the declared default branch" +assert_grep 'FM_ROOT_OVERRIDE=' "$ROOT/bin/agent-os-container-entrypoint.sh" "runtime must use the persistent canonical Firstmate repository" +assert_grep 'agent-os-runtime-source.sh' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "immutable runtimes must select a content-addressed source checkout" +assert_grep 'agent-os-source.sha256' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "immutable runtime source identity must include the exact source archive digest" +assert_present "$ROOT/bin/agent-os-runtime-source.sh" \ + "immutable runtime source materialization must have one transactional owner" +assert_grep 'runtime-sources' "$ROOT/bin/agent-os-runtime-source.sh" \ + "immutable runtime sources must remain separate from persistent home state" +assert_grep 'agent-os-runtime-source' "$ROOT/bin/agent-os-runtime-source.sh" \ + "immutable source selections must persist their verified update policy" +assert_present "$ROOT/bin/agent-os-runtime-secondmates.sh" \ + "immutable runtimes must converge each secondmate to the exact selected source" +assert_grep 'agent-os-runtime-secondmates.sh' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "immutable runtimes must select exact source for registered secondmates" +assert_grep 'agent-os-kubernetes-control.sh' "$ROOT/bin/agent-os-kubernetes.sh" \ + "primary lifecycle paths must share the stable control-namespace lock identity" +assert_grep 'agent-os-kubernetes-control.sh' "$ROOT/bin/agent-os-akua-auth.sh" \ + "authorization mutations must share the stable control-namespace lock identity" +assert_grep 'require_no_active_rollback_checkpoint' "$ROOT/bin/agent-os-akua-auth.sh" \ + "authorization mutations must reject unresolved rollback checkpoints" +auth_lock_line=$(grep -n '^acquire_lock$' "$ROOT/bin/agent-os-akua-auth.sh" | tail -n 1 | cut -d: -f1) +auth_secret_line=$(grep -n '^SECRET_RECORD=' "$ROOT/bin/agent-os-akua-auth.sh" | cut -d: -f1) +[ -n "$auth_lock_line" ] && [ -n "$auth_secret_line" ] && [ "$auth_lock_line" -lt "$auth_secret_line" ] || \ + fail "authorization mutation must acquire the control lock before Secret metadata" +assert_grep 'https://github.com/ogulcancelik/herdr/tree/v0.7.3' "$ROOT/THIRD_PARTY_NOTICES.md" \ + "Herdr's exact corresponding source must be named" +assert_present "$ROOT/THIRD_PARTY_SOURCES.md" \ + "the image must ship an operator-visible Herdr source offer" +assert_present "$ROOT/docs/herdr-compliance.md" \ + "the repository must record the Herdr distribution boundary" +assert_grep '299dd4163a96381ec2d8e5bde13d7ba6d6432373' "$ROOT/THIRD_PARTY_SOURCES.md" \ + "the Herdr source offer must pin the v0.7.3 commit" +assert_grep '4e4a536fff8cd74019a1f8b4f1eef7fce556042f2b3e389eb6f9a155c1a7c6d5' "$ROOT/THIRD_PARTY_SOURCES.md" \ + "the Herdr source offer must checksum the source archive" +assert_grep 'cargo build --release' "$ROOT/THIRD_PARTY_SOURCES.md" \ + "the Herdr source offer must name its build command" +assert_grep 'unmodified executable' "$ROOT/THIRD_PARTY_SOURCES.md" \ + "the Herdr source offer must state the modification boundary" +assert_grep 'install -D -m 0644 /opt/agent-os/THIRD_PARTY_NOTICES.md /usr/share/doc/agent-os/THIRD_PARTY_NOTICES.md' "$ROOT/Dockerfile" \ + "the image must expose third-party notices to operators" +assert_grep 'install -D -m 0644 /opt/agent-os/THIRD_PARTY_SOURCES.md /usr/share/doc/agent-os/THIRD_PARTY_SOURCES.md' "$ROOT/Dockerfile" \ + "the image must expose Herdr's source offer to operators" +assert_grep '/usr/share/doc/agent-os/THIRD_PARTY_SOURCES.md' "$ROOT/THIRD_PARTY_NOTICES.md" \ + "the Herdr notice must direct image recipients to the bundled source offer" +assert_no_grep 'publication is gated on a compliant license path' "$ROOT/THIRD_PARTY_NOTICES.md" \ + "the Herdr notice must state the selected compliance path" +if grep -Eq '^ packages: write$' "$IMAGE_WORKFLOW"; then + fail "workflow defaults must keep pull-request validation read-only" +fi +[ "$(grep -c '^ packages: write$' "$IMAGE_WORKFLOW")" -eq 1 ] || \ + fail "only the protected publication job may receive packages write" +assert_grep ' validate:' "$IMAGE_WORKFLOW" \ + "pull requests must use a distinct read-only validation job" +assert_grep ' publish:' "$IMAGE_WORKFLOW" \ + "push and tag publication must use a distinct privileged job" +assert_grep 'needs: [behavior, provenance, validate]' "$IMAGE_WORKFLOW" \ + "the packages-write job must require exact behavior, provenance, and image gates" +assert_grep 'cancel-in-progress: false' "$IMAGE_WORKFLOW" \ + "publication must not strand an unrecorded candidate by cancelling an in-flight main build" +assert_grep 'trusted_git()' "$IMAGE_WORKFLOW" \ + "workflow provenance reads must isolate trusted Git from ambient configuration and paths" +assert_grep 'github.ref_protected' "$IMAGE_WORKFLOW" \ + "main publication must require GitHub protected-ref provenance" +assert_grep 'release tag ruleset differs from immutable record' "$IMAGE_WORKFLOW" \ + "tag publication must require its recorded protected-tag ruleset" +assert_grep 'git merge-base --is-ancestor "$(jq -r .commit' "$IMAGE_WORKFLOW" \ + "tag publication must require the recorded release commit on protected main" +assert_grep '.enforcement == "active" and .target == "tag"' "$IMAGE_WORKFLOW" \ + "tag publication must require an active tag-targeting ruleset" +assert_grep '.bypass_actors | length == 0' "$IMAGE_WORKFLOW" \ + "tag publication must reject rulesets with bypass actors" +assert_no_grep '$IMAGE:candidate-record-' "$IMAGE_WORKFLOW" \ + "candidate records must never depend on a mutable registry tag" +assert_grep 'pre-existing release candidate lacks an independently trusted record' "$IMAGE_WORKFLOW" \ + "publication must reject unrecorded pre-existing candidate images" +assert_no_grep 'EXISTING_CANDIDATE_IMAGE_DIGEST' "$IMAGE_WORKFLOW" \ + "publication must never adopt a pre-existing image from its embedded provenance" +assert_grep 'oras manifest push --descriptor "$IMAGE@$candidate_record_digest"' "$IMAGE_WORKFLOW" \ + "candidate records must be created only at their content-addressed digest" +assert_grep 'application/vnd.akua.agent-os.candidate-reservation.v1' "$IMAGE_WORKFLOW" \ + "candidate builds must first reserve one content-addressed commit coordinate" +assert_grep 'refs/agent-os/candidate-reservations/' "$IMAGE_WORKFLOW" \ + "candidate builds must use an atomic owner-bound repository coordinator" +assert_grep 'repos/$GITHUB_REPOSITORY/git/refs' "$IMAGE_WORKFLOW" \ + "candidate reservation ownership must use atomic Git ref creation" +assert_grep '.status == "completed"' "$IMAGE_WORKFLOW" \ + "candidate recovery must require the prior owner run to be terminal" +assert_grep 'oras discover --distribution-spec v1.1-referrers-api' "$IMAGE_WORKFLOW" \ + "candidate retries must resolve the single record attached to their reservation" +assert_grep 'actions/artifacts?name=$artifact_name' "$IMAGE_WORKFLOW" \ + "candidate retries must authenticate recovered records through trusted workflow storage" +assert_grep '.workflow_run.head_sha == $sha' "$IMAGE_WORKFLOW" \ + "candidate recovery must bind the trusted record to the exact protected-main commit" +assert_grep 'recovering incomplete candidate reservation' "$IMAGE_WORKFLOW" \ + "candidate retries must recover an incomplete reservation under its trusted coordinator" +assert_grep 'candidate reservation recovered from durable trusted artifact' "$IMAGE_WORKFLOW" \ + "zero-referrer retries must recover durable trusted candidate evidence" +assert_grep 'candidate reservation recovered from exact build evidence' "$IMAGE_WORKFLOW" \ + "zero-referrer retries must reuse an owner-bound retained image digest" +assert_grep 'agent-os-candidate-build-$GITHUB_SHA' "$IMAGE_WORKFLOW" \ + "candidate builds must persist their exact digest immediately" +assert_grep 'refs/agent-os/candidate-build-attempts/$GITHUB_SHA/$owner_run_id' "$IMAGE_WORKFLOW" \ + "candidate retries must resolve the exact owner-bound build attempt" +assert_grep 'candidate build attempt has no durable exact evidence; refusing rebuild' \ + "$ROOT/bin/agent-os-candidate-state.sh" \ + "candidate cancellation must fail closed instead of rebuilding" +assert_grep 'bin/agent-os-candidate-state.sh "$chain_state"' "$IMAGE_WORKFLOW" \ + "candidate rebuild authorization must classify the full claim chain" +assert_grep 'done < "$CLAIM_CHAIN_FILE"' "$IMAGE_WORKFLOW" \ + "candidate retries must inspect every validated reservation owner" +assert_grep 'read -r candidate_action artifact_owner paired_build_owner' "$IMAGE_WORKFLOW" \ + "candidate recovery must preserve distinct record and build evidence owners" +assert_grep 'load_exact_build_artifact "$build_artifact_owner"' "$IMAGE_WORKFLOW" \ + "candidate recovery must load exact build evidence from the attempt owner" +assert_grep '[ "$EXACT_BUILD_IMAGE_DIGEST" = "$image_digest" ]' "$IMAGE_WORKFLOW" \ + "candidate recovery must digest-match split-owner evidence" +assert_no_grep 'if load_build_attempt' "$IMAGE_WORKFLOW" \ + "candidate attempt validation must never run in a conditional errexit context" +attempt_line=$(grep -n 'name: Claim owner-bound candidate build attempt' "$IMAGE_WORKFLOW" | cut -d: -f1) +build_line=$(grep -n 'name: Build release candidate once' "$IMAGE_WORKFLOW" | cut -d: -f1) +build_evidence_line=$(grep -n 'name: Stage exact candidate build evidence' "$IMAGE_WORKFLOW" | cut -d: -f1) +image_publish_line=$(grep -n 'name: Publish exact evidenced candidate image' "$IMAGE_WORKFLOW" | cut -d: -f1) +record_line=$(grep -n 'name: Prepare immutable release candidate record' "$IMAGE_WORKFLOW" | cut -d: -f1) +[ -n "$attempt_line" ] && [ -n "$build_line" ] && [ -n "$build_evidence_line" ] && \ + [ -n "$image_publish_line" ] && [ -n "$record_line" ] && [ "$attempt_line" -lt "$build_line" ] && \ + [ "$build_line" -lt "$build_evidence_line" ] && \ + [ "$build_evidence_line" -lt "$image_publish_line" ] && [ "$image_publish_line" -lt "$record_line" ] || \ + fail "owner-bound attempt state must precede build and exact evidence publication" +attempt_guard_line=$(grep -n 'load_build_attempt "$owner"' "$IMAGE_WORKFLOW" | cut -d: -f1) +rebuild_line=$(grep -n "printf 'build=true" "$IMAGE_WORKFLOW" | cut -d: -f1) +[ -n "$attempt_guard_line" ] && [ -n "$rebuild_line" ] && [ "$attempt_guard_line" -lt "$rebuild_line" ] || \ + fail "candidate upload failure must be rejected before any rebuild is authorized" +assert_grep 'type=oci,dest=${{ runner.temp }}/candidate-image.tar' "$IMAGE_WORKFLOW" \ + "candidate builds must retain their exact OCI output before publication" +assert_grep 'oci_archive_sha256' "$IMAGE_WORKFLOW" \ + "candidate recovery must authenticate the retained OCI output" +assert_grep 'oras cp --from-oci-layout "$RUNNER_TEMP/candidate-image.tar@$image_digest" "$IMAGE"' "$IMAGE_WORKFLOW" \ + "candidate publication must copy only the evidenced content-addressed image" +assert_grep '.path == ".github/workflows/agent-os-image.yml"' "$IMAGE_WORKFLOW" \ + "candidate artifact recovery must bind the authorized workflow" +assert_grep '.repository.full_name == $repo' "$IMAGE_WORKFLOW" \ + "candidate artifact recovery must bind the authorized repository" +assert_grep 'candidate artifact run is outside the authorized claim chain' "$IMAGE_WORKFLOW" \ + "candidate artifact recovery must bind reservation-owner lineage" +stage_line=$(grep -n 'name: Stage trusted candidate record evidence' "$IMAGE_WORKFLOW" | cut -d: -f1) +publish_line=$(grep -n 'name: Publish immutable release candidate record' "$IMAGE_WORKFLOW" | cut -d: -f1) +[ -n "$stage_line" ] && [ -n "$publish_line" ] && [ "$stage_line" -lt "$publish_line" ] || \ + fail "trusted candidate evidence must be durable before referrer publication" +assert_grep 'org.opencontainers.image.title":"agent-os-bootstrap.tar"' "$IMAGE_WORKFLOW" \ + "candidate records must retain the exact bootstrap artifact" +assert_grep 'candidate record coordinate already contains different artifacts' "$IMAGE_WORKFLOW" \ + "candidate record creation must reject immutable coordinate conflicts" +assert_grep 'release candidate image provenance conflicts with the exact protected-main source' "$IMAGE_WORKFLOW" \ + "publication must reject an unrecorded candidate image whose provenance conflicts" +assert_grep 'oras blob fetch --output "$provenance_file" "$IMAGE@$provenance_digest"' "$IMAGE_WORKFLOW" \ + "candidate recovery must inspect the exact published BuildKit provenance bytes" +assert_grep 'source_mode:"candidate"' "$IMAGE_WORKFLOW" \ + "candidate records must bind the immutable runtime source policy" +assert_grep 'platform_manifests' "$IMAGE_WORKFLOW" \ + "candidate records must bind every published platform manifest" +assert_grep 'candidate-platform-subjects' "$IMAGE_WORKFLOW" \ + "candidate attestations must map one-to-one onto platform manifests" +assert_grep 'candidate-sbom-' "$IMAGE_WORKFLOW" \ + "candidate validation must inspect each exact SBOM statement" +assert_grep '.predicateType == "https://spdx.dev/Document"' "$IMAGE_WORKFLOW" \ + "candidate validation must bind every SBOM subject to its platform" +assert_grep 'buildkit_outputs_sha256' "$IMAGE_WORKFLOW" \ + "candidate records must bind the exact BuildKit output descriptors" +assert_grep '.bootstrap_archive_sha256 == $bootstrapArchive' "$IMAGE_WORKFLOW" \ + "candidate retries and promotion must enforce the exact bootstrap archive" +assert_grep 'retained bootstrap archive differs from the immutable release record' "$ROOT/bin/agent-os-source-bundle.sh" \ + "release source preparation must verify the retained exact bootstrap archive" +assert_grep 'actual_image_digest' "$IMAGE_WORKFLOW" \ + "release publication must compare the actual multi-platform image digest" +assert_grep 'actual_sbom_sha256' "$IMAGE_WORKFLOW" \ + "release publication must compare the actual SBOM artifact set" +assert_grep 'actual_provenance_sha256' "$IMAGE_WORKFLOW" \ + "release publication must compare the actual provenance artifact set" +assert_no_grep 'oras tag ' "$IMAGE_WORKFLOW" \ + "release publication must never create a mutable registry semver tag" +assert_grep 'release registry tag coordinate already exists' "$IMAGE_WORKFLOW" \ + "release publication must reject conflicting mutable registry coordinates" +[ "$(grep -c 'oras cp --from-oci-layout' "$IMAGE_WORKFLOW")" -eq 1 ] || \ + fail "only initial evidenced candidate publication may upload an OCI layout" +assert_grep 'oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6' "$IMAGE_WORKFLOW" \ + "release publication must pin the OCI transfer implementation" +assert_grep 'Canonical install digest:' "$IMAGE_WORKFLOW" \ + "release publication must expose only the immutable install digest" +assert_grep 'published-platform-subjects' "$IMAGE_WORKFLOW" \ + "release promotion must reverify the per-platform attestation bijection" +assert_grep 'File.fnmatch?(pattern, ref, File::FNM_PATHNAME)' "$IMAGE_WORKFLOW" \ + "tag ruleset coverage must use GitHub's documented pathname matcher" +assert_no_grep 'import fnmatch' "$IMAGE_WORKFLOW" \ + "tag ruleset coverage must not let a star wildcard cross ref separators" +assert_grep 'Section 13' "$ROOT/docs/herdr-compliance.md" \ + "the Herdr audit must account for the network-interaction clause" +assert_grep 'https://github.com/akua-dev/akua/tree/v0.8.25' "$ROOT/THIRD_PARTY_NOTICES.md" \ + "Akua's exact source must be named" +assert_grep 'https://github.com/derailed/k9s/tree/v0.51.0' "$ROOT/THIRD_PARTY_NOTICES.md" \ + "K9s's exact source must be named" +assert_grep '1f328c5e9dd683d0c5e69f3d7d58f8371278dec2/LICENSE' "$ROOT/Dockerfile" \ + "the image must bundle kubectl's license from the immutable Kubernetes source commit" +assert_grep 'b300f2ec7ec9dc9addc39b2ad88c54097ded7ca0/LICENSE' "$ROOT/Dockerfile" \ + "the image must bundle GitHub CLI's license from its immutable source commit" +assert_grep '0d9b296af33f2b851fcbf4df3e9ec89751734ba4/LICENSE.md' "$ROOT/Dockerfile" \ + "the image must bundle Bun's complete license from its immutable source commit" +assert_grep '68fa3d2556542add76bf80255787b8625a5041a6/LICENSE' "$ROOT/Dockerfile" \ + "the image must bundle Treehouse's license from its immutable source commit" +assert_grep 'dc5a80059d3c0f1abbf28f20f43d994b8399bee6/LICENSE' "$ROOT/Dockerfile" \ + "the image must bundle no-mistakes' license from its immutable source commit" +assert_grep '/usr/share/licenses/kubectl/LICENSE' "$ROOT/Dockerfile" \ + "kubectl's license must be installed in the image license bundle" +assert_grep '/usr/share/licenses/gh/LICENSE' "$ROOT/Dockerfile" \ + "GitHub CLI's license must be installed in the image license bundle" +assert_grep '/usr/share/licenses/bun/LICENSE.md' "$ROOT/Dockerfile" \ + "Bun's complete license must be installed in the image license bundle" +assert_grep '/usr/share/licenses/treehouse/LICENSE' "$ROOT/Dockerfile" \ + "Treehouse's license must be installed in the image license bundle" +assert_grep '/usr/share/licenses/no-mistakes/LICENSE' "$ROOT/Dockerfile" \ + "no-mistakes' license must be installed in the image license bundle" +assert_grep '## kubectl' "$ROOT/THIRD_PARTY_NOTICES.md" "third-party notices must cover kubectl" +assert_grep '## GitHub CLI' "$ROOT/THIRD_PARTY_NOTICES.md" "third-party notices must cover GitHub CLI" +assert_grep '## Bun' "$ROOT/THIRD_PARTY_NOTICES.md" "third-party notices must cover Bun" +assert_grep '## Treehouse' "$ROOT/THIRD_PARTY_NOTICES.md" "third-party notices must cover Treehouse" +assert_grep '## no-mistakes' "$ROOT/THIRD_PARTY_NOTICES.md" "third-party notices must cover no-mistakes" +assert_grep 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' "$ROOT/.github/workflows/ci.yml" \ + "CI checkout actions must be pinned to the reviewed full SHA" +assert_grep 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' "$ROOT/.github/workflows/ci.yml" \ + "CI must pin Bun setup to a reviewed full SHA" +assert_grep 'bun-version-file: tools/agent-os/.bun-version' "$ROOT/.github/workflows/ci.yml" \ + "CI must use the repository-pinned Bun version" +assert_grep 'bun run check' "$ROOT/.github/workflows/ci.yml" \ + "CI behavior tests must execute the Agent OS tool check" +assert_grep "mkdir -p \"\$RUNNER_TEMP/bin\"" "$ROOT/.github/workflows/ci.yml" \ + "CI must create its local verified-tool destination before install" +assert_grep 'bc57afbffe7e18aacd2146e2cd67151c56e7a3c279fe659312ff7ffb359cd03a' "$ROOT/.github/workflows/ci.yml" \ + "CI must authenticate the x86_64 Akua release artifact" +assert_grep '3a3c6bae72764cbd85a6e4e0877a05e5def8f7aeee8563b7918099214a1a313a' "$ROOT/.github/workflows/ci.yml" \ + "CI must authenticate the aarch64 Akua release artifact" +assert_no_grep 'https://cli.akua.dev/install' "$ROOT/.github/workflows/ci.yml" \ + "CI must not execute the mutable remote Akua installer" +assert_grep 'ghcr.io/akua-dev/agent-os' "$ROOT/.github/workflows/agent-os-image.yml" \ + "release workflow must publish the image expected by the portable package" +assert_grep 'linux/amd64,linux/arm64' "$ROOT/.github/workflows/agent-os-image.yml" \ + "release workflow must build the two supported container architectures" +[ "$(grep -c '^ push: false$' "$IMAGE_WORKFLOW")" -eq 1 ] || \ + fail "the read-only validation job must build without publishing" +[ "$(grep -c '^ push: true$' "$IMAGE_WORKFLOW")" -eq 1 ] || \ + fail "only the protected main publication build may use mutable-tag push mode" +assert_no_grep 'push-by-digest=true,name-canonical=true,push=true' "$IMAGE_WORKFLOW" \ + "the candidate build must not push before exact evidence is durable" +assert_grep 'id: build' "$ROOT/.github/workflows/agent-os-image.yml" \ + "release workflow must expose its build result" +assert_grep 'steps.build.outputs.digest' "$ROOT/.github/workflows/agent-os-image.yml" \ + "release workflow must record the immutable published digest" +assert_grep 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' "$ROOT/.github/workflows/agent-os-image.yml" \ + "release checkout action must be pinned to the reviewed full SHA" +assert_grep 'docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130' "$ROOT/.github/workflows/agent-os-image.yml" \ + "release QEMU action must be pinned to the reviewed full SHA" +assert_grep 'docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f' "$ROOT/.github/workflows/agent-os-image.yml" \ + "release Buildx action must be pinned to the reviewed full SHA" +assert_grep 'docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9' "$ROOT/.github/workflows/agent-os-image.yml" \ + "release login action must be pinned to the reviewed full SHA" +assert_grep 'docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051' "$ROOT/.github/workflows/agent-os-image.yml" \ + "release metadata action must be pinned to the reviewed full SHA" +assert_grep 'docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8' "$ROOT/.github/workflows/agent-os-image.yml" \ + "release build action must be pinned to the reviewed full SHA" +assert_grep 'agent-os-runtime-source' "$ROOT/bin/fm-update.sh" \ + "self-update must derive immutable policy from selected source provenance" +assert_grep 'resolve_policy_git_dir' "$ROOT/bin/fm-update.sh" \ + "self-update must resolve immutable policy through linked-worktree Git pointers" +assert_grep 'TRUSTED_SOURCE_REF=$SOURCE_COMMIT' "$ROOT/bin/agent-os-container-entrypoint.sh" \ + "candidate runtime provenance must fetch its immutable commit" +if grep -E 'uses: (actions/checkout|docker/[^@]+|oras-project/setup-oras)@v[0-9]+' "$ROOT/.github/workflows/agent-os-image.yml" >/dev/null; then + fail "release workflow must not use mutable major action tags" +fi +bash -n "$ROOT/bin/agent-os-container-entrypoint.sh" +bash -n "$ROOT/bin/agent-os-runtime-source.sh" +bash -n "$ROOT/bin/agent-os-runtime-secondmates.sh" +bash -n "$ROOT/bin/agent-os-kubeconfig.sh" +pass "container files pin dependencies and exclude host credentials" diff --git a/tests/agent-os-init.test.sh b/tests/agent-os-init.test.sh new file mode 100755 index 000000000..ac98d5354 --- /dev/null +++ b/tests/agent-os-init.test.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# User-namespace verification and persistent tool seeding tests. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP=$(fm_test_tmproot agent-os-init) +mkdir -p "$TMP/image" "$TMP/persistent/.local/bin" +printf 'image-manifest\n' > "$TMP/image/manifest" +(cd "$TMP/image" && sha256sum manifest > manifest.sha256) + +AGENT_OS_IMAGE_MANIFEST="$TMP/image/manifest" \ +AGENT_OS_IMAGE_MANIFEST_SIGNATURE="$TMP/image/manifest.sha256" \ +AGENT_OS_PERSISTENT_ROOT="$TMP/persistent" "$ROOT/bin/agent-os-init.sh" + +for directory in .config .cache .local/bin .local/share .bun .cargo; do + [ -d "$TMP/persistent/$directory" ] || fail "initializer must create persistent $directory" +done +assert_grep image-manifest "$TMP/persistent/.config/agent-os/previous-image-usr-local.manifest" \ + "initializer must persist authenticated image ownership provenance" + +mkdir -p "$TMP/ambiguous/usr-local/bin" +printf 'unknown\n' > "$TMP/ambiguous/usr-local/bin/tool" +if AGENT_OS_IMAGE_MANIFEST="$TMP/image/manifest" \ + AGENT_OS_IMAGE_MANIFEST_SIGNATURE="$TMP/image/manifest.sha256" \ + AGENT_OS_PERSISTENT_ROOT="$TMP/ambiguous" "$ROOT/bin/agent-os-init.sh" >/dev/null 2>&1; then + fail "initializer must reject ambiguous legacy /usr/local ownership" +fi + +pass "initializer separates immutable image tools from persistent user tools" diff --git a/tests/agent-os-kubeconfig.test.sh b/tests/agent-os-kubeconfig.test.sh new file mode 100755 index 000000000..a06d76f07 --- /dev/null +++ b/tests/agent-os-kubeconfig.test.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Behavioral regression tests for automatic in-cluster kubeconfig creation. +set -eu + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +tmp=$(fm_test_tmproot agent-os-kubeconfig-tests) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/serviceaccount" "$tmp/home" +printf '%s\n' demo-token > "$tmp/serviceaccount/token" +printf '%s\n' demo-ca > "$tmp/serviceaccount/ca.crt" + +HOME="$tmp/home" \ +AGENT_OS_SERVICE_ACCOUNT_DIR="$tmp/serviceaccount" \ +KUBERNETES_SERVICE_HOST=kubernetes.default.svc \ +KUBERNETES_SERVICE_PORT_HTTPS=443 \ +AGENT_OS_NAMESPACE=agent-os-eval \ + "$ROOT/bin/agent-os-kubeconfig.sh" + +config="$tmp/home/.kube/config" +[ -f "$config" ] || fail "in-cluster kubeconfig was not created" +assert_grep 'current-context: in-cluster' "$config" "kubeconfig must select the local cluster alias" +assert_grep 'namespace: agent-os-eval' "$config" "kubeconfig must select the Agent OS namespace" +assert_grep "tokenFile: $tmp/serviceaccount/token" "$config" "kubeconfig must follow the rotating token file" +assert_no_grep 'demo-token' "$config" "kubeconfig must not copy token contents" +case $(uname -s) in + Darwin) mode=$(stat -f '%Lp' "$config") ;; + *) mode=$(stat -c '%a' "$config") ;; +esac +[ "$mode" = 600 ] || fail "kubeconfig must be mode 600" + +printf '%s\n' sentinel > "$config" +HOME="$tmp/home" \ +AGENT_OS_SERVICE_ACCOUNT_DIR="$tmp/serviceaccount" \ +KUBERNETES_SERVICE_HOST=kubernetes.default.svc \ + "$ROOT/bin/agent-os-kubeconfig.sh" +[ "$(cat "$config")" = sentinel ] || fail "existing kubeconfig must not be overwritten" + +pass "Agent OS creates a rotation-safe in-cluster kubeconfig without exposing token contents" diff --git a/tests/agent-os-kubernetes.test.sh b/tests/agent-os-kubernetes.test.sh new file mode 100755 index 000000000..bbf5b0915 --- /dev/null +++ b/tests/agent-os-kubernetes.test.sh @@ -0,0 +1,2073 @@ +#!/usr/bin/env bash +# Kubernetes manifest and isolated crewmate launcher tests. +# Source assertions intentionally use single quotes for literal variable references. +# shellcheck disable=SC2016 +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +assert_grep 'configure_control_lock' "$ROOT/bin/agent-os-crewmate.sh" \ + "crewmate mutations must acquire the stable installation control lock" +assert_grep 'CONTROL_LOCK_UID' "$ROOT/bin/agent-os-crewmate.sh" \ + "crewmate mutations must retain control-lock CAS evidence" +assert_grep 'LOCK_INSTALLATION_ID=$CONTROL_LOCK_INSTALLATION_ID' "$ROOT/bin/agent-os-crewmate.sh" \ + "crewmate control Leases must render their scope-specific installation identity" +assert_grep 'configure_control_lock' "$ROOT/bin/agent-os-akua-auth.sh" \ + "authorization mutations must acquire the stable installation control lock" +assert_grep 'agent-os-firstmate-lifecycle' "$ROOT/bin/agent-os-akua-auth.sh" \ + "authorization mutations must also acquire the namespace fleet lock" +assert_grep 'current=absent' "$ROOT/bin/agent-os-kubernetes.sh" \ + "upgrade snapshots must represent an absent authorization overlay explicitly" +assert_grep 'transfer_runtime_authority' "$ROOT/bin/agent-os-kubernetes.sh" \ + "rollback must transactionally transfer runtime authority" +assert_grep 'compensate_rollback' "$ROOT/bin/agent-os-kubernetes.sh" \ + "failed rollback must restore the current revision and authority" +assert_grep 'wait_for_revision_history_absence' "$ROOT/bin/agent-os-kubernetes.sh" \ + "uninstall must retire historical ServiceAccounts only after revision history" +assert_grep 'fail_grant_closed "grant CAS failed ambiguously"' "$ROOT/bin/agent-os-akua-auth.sh" \ + "ambiguous grant mutations must enter fail-closed reconciliation" +assert_grep 'fail_grant_closed "grant rollout failed"' "$ROOT/bin/agent-os-akua-auth.sh" \ + "failed grant rollouts must enter fail-closed reconciliation" +assert_grep 'reconcile_failed_akua_upgrade' "$ROOT/bin/agent-os-kubernetes.sh" \ + "upgrade authorization drift must be removed and verified fail closed" +assert_grep 'inventory_revision_service_accounts' "$ROOT/bin/agent-os-kubernetes.sh" \ + "uninstall must inventory every historical revision ServiceAccount" +assert_grep 'verify_no_runtime_authority' "$ROOT/bin/agent-os-kubernetes.sh" \ + "RBAC none must verify namespace, cluster, and control authority absence" +assert_grep 'rollback_source_digest' "$ROOT/bin/agent-os-kubernetes.sh" \ + "rollback compensation must verify its immutable source revision digest" +assert_grep 'if [ "$COMMAND" = grant ]; then' "$ROOT/bin/agent-os-akua-auth.sh" \ + "authorization Secret validation must apply only to grant" +assert_grep 'verify_overlay absent "$STATE_UID" 0' "$ROOT/bin/agent-os-akua-auth.sh" \ + "authorization revoke must verify absence without requiring a Secret record" +assert_grep '.metadata.annotations["agent-os.dev/akua-auth-rejected-record"] // ""' "$ROOT/bin/agent-os-akua-auth.sh" \ + "authorization revoke must verify the rejected Secret identity marker is absent" +assert_grep 'STATEFULSET_CAS_ATTEMPTED' "$ROOT/bin/agent-os-kubernetes.sh" \ + "post-CAS upgrade failures must enter fail-closed authorization reconciliation" +assert_grep 'STATEFULSET_CAS_UID' "$ROOT/bin/agent-os-kubernetes.sh" \ + "upgrade compensation must remain bound to the pre-CAS StatefulSet UID" +assert_grep 'inventory_owned_service_accounts' "$ROOT/bin/agent-os-kubernetes.sh" \ + "uninstall retries must recover exact-owned historical ServiceAccounts independently" +assert_grep 'verify_runtime_role_rules' "$ROOT/bin/agent-os-kubernetes.sh" \ + "rollback must verify exact namespace runtime Role rules" +assert_grep 'verify_control_role_rules' "$ROOT/bin/agent-os-kubernetes.sh" \ + "rollback must verify exact control Role rules" +assert_grep ' REMOVE_CONTROL_ACCESS=1' "$ROOT/bin/agent-os-kubernetes.sh" \ + "uninstall must always remove exact-owned control RBAC" + +LAUNCHER="$ROOT/bin/agent-os-crewmate.sh" +TMP=$(fm_test_tmproot agent-os-kubernetes) +FAKEBIN=$(fm_fakebin "$TMP") +CALLS="$TMP/calls.log" +STDIN_LOG="$TMP/stdin.yaml" +PURGE_EVIDENCE="$TMP/purge-evidence.log" + +PROFILE="$ROOT/deploy/orbstack/inputs.yaml" +PROFILE_OUT="$TMP/orbstack-rendered" + +[ -f "$PROFILE" ] || fail "OrbStack profile inputs must exist" +[ ! -e "$ROOT/deploy/orbstack/kustomization.yaml" ] || fail "OrbStack must not keep a second static installer" +assert_grep 'namespace: agent-os-demo' "$PROFILE" "OrbStack profile must preserve its isolated namespace" +assert_grep 'imagePullPolicy: Never' "$PROFILE" "OrbStack profile must use its local image store" +assert_grep 'allowMutableImage: true' "$PROFILE" "OrbStack profile must explicitly allow its local image tag" +assert_grep 'rbac: cluster-admin' "$PROFILE" "OrbStack profile must make its local-demo grant explicit" + +akua render --no-agent-mode --package "$ROOT/tools/agent-os/packages/firstmate/package.k" \ + --inputs "$PROFILE" --out "$PROFILE_OUT" >/dev/null || fail "OrbStack profile did not render the canonical package" +rendered=$(cat "$PROFILE_OUT"/*.yaml) +assert_contains "$rendered" 'kind: ServiceAccount' "render must include the primary ServiceAccount" +assert_contains "$rendered" 'kind: ClusterRoleBinding' "render must include the explicit local-demo grant" +assert_contains "$rendered" 'name: agent-os-firstmate-home' "render must include the persistent primary home" +assert_contains "$rendered" 'kind: StatefulSet' "render must include the primary StatefulSet" +assert_contains "$rendered" 'imagePullPolicy: Never' "OrbStack must use the locally built image" +assert_contains "$rendered" 'mountPath: /home/agent' "the primary home must mount at FM_HOME" +assert_not_contains "$rendered" 'hostUsers: false' "OrbStack demo must not request unsupported Pod user namespaces" +assert_contains "$rendered" 'runAsUser: 0' "primary must run as container root" +assert_contains "$rendered" 'name: agent-os-init' "primary must seed persistent tools" +assert_not_contains "$rendered" 'mountPath: /usr/local' "primary must keep image-owned /usr/local immutable" +pass "OrbStack profile renders the canonical persistent primary" + +cat > "$FAKEBIN/kubectl" <<'SH' +#!/usr/bin/env bash +printf 'kubectl' >> "$AGENT_OS_TEST_LOG" +printf ' %s' "$@" >> "$AGENT_OS_TEST_LOG" +printf '\n' >> "$AGENT_OS_TEST_LOG" +filtered_args=() +for argument in "$@"; do + [[ "$argument" = --request-timeout=* ]] || filtered_args+=("$argument") +done +set -- "${filtered_args[@]}" +effective_binding_account() { + local binding=$1 account patch + patch=$(grep -F " patch rolebinding $binding " "$AGENT_OS_TEST_LOG" | tail -n 1 | sed -n 's/.* -p //p') + account=$(printf '%s' "$patch" | jq -r '.subjects[0].name // empty' 2>/dev/null || true) + if [ -z "$account" ]; then + account=$(grep 'akua-input-service-account ' "$AGENT_OS_TEST_LOG" | tail -n 1 | awk '{print $2}') + fi + if [ "${AGENT_OS_TEST_COMMAND:-}" = rollback ] && [ -z "$patch" ]; then + account=${AGENT_OS_TEST_WORKLOAD_SERVICE_ACCOUNT:-agent-os-firstmate} + fi + printf '%s' "${account:-agent-os-firstmate}" +} +stdin_data='' +previous='' +for argument in "$@"; do + if [ "$previous" = --patch-file ]; then + printf '%s\n' "$(cat "$argument")" >> "${AGENT_OS_STDIN_LOG:-$AGENT_OS_TEST_LOG.stdin}" + fi + previous=$argument +done +if [ "${*: -2}" = "-f -" ]; then + stdin_data=$(cat) + printf '%s\n' "$stdin_data" >> "${AGENT_OS_STDIN_LOG:-$AGENT_OS_TEST_LOG.stdin}" + stdin_kind=$(printf '%s\n' "$stdin_data" | awk '$1 == "kind:" { print $2; exit }') + [ -n "$stdin_kind" ] || stdin_kind=$(printf '%s' "$stdin_data" | jq -r '.kind // empty' 2>/dev/null || true) + printf 'stdin-kind %s\n' "$stdin_kind" >> "$AGENT_OS_TEST_LOG" + stdin_name=$(printf '%s' "$stdin_data" | jq -r '.metadata.name // empty' 2>/dev/null || true) + [ -z "$stdin_name" ] || printf 'stdin-resource %s %s\n' "$stdin_kind" "$stdin_name" >> "$AGENT_OS_TEST_LOG" + if [ "$stdin_kind" = Lease ]; then + stdin_name=$(printf '%s\n' "$stdin_data" | awk '$1 == "name:" { print $2; exit }') + printf 'stdin-lease %s\n' "$stdin_name" >> "$AGENT_OS_TEST_LOG" + fi +fi +if [ "${AGENT_OS_TEST_FAIL_APPLY:-0}" = 1 ] && [[ " $* " = *" create -f - "* ]] && \ + [ "$stdin_kind" = Pod ]; then + exit 1 +fi +if { [ "${AGENT_OS_TEST_LOCK_STATE:-free}" != free ] || [ "${AGENT_OS_TEST_PRIMARY_LOCK_STATE:-free}" != free ]; } && \ + [[ " $* " = *" create -f - "* ]] && \ + [ "$stdin_kind" = Lease ]; then + exit 1 +fi +if [ "${AGENT_OS_TEST_FAIL_PVC_PATCH:-0}" = 1 ] && [[ " $* " = *" patch pvc "* ]]; then + exit 1 +fi +if [ "${AGENT_OS_TEST_FAIL_POD_LIST:-0}" = 1 ] && [[ " $* " = *" get pods -o json "* ]]; then + exit 1 +fi +if [ "${AGENT_OS_TEST_FAIL_GENERIC_APPLY:-0}" = 1 ] && \ + { [[ " $* " = *" apply -f "* ]] || [[ " $* " = *" create -f "*statefulset.yaml* ]] || \ + [[ " $* " = *" patch StatefulSet agent-os-firstmate "* ]]; }; then + exit 1 +fi +if [ "${AGENT_OS_TEST_FAIL_WAIT:-0}" = 1 ] && [ "${1:-}" = -n ] && [ "${3:-}" = wait ]; then + exit 1 +fi +if [ -n "${AGENT_OS_TEST_FAIL_WAIT_TARGET:-}" ] && [[ " $* " = *" wait --for=delete $AGENT_OS_TEST_FAIL_WAIT_TARGET "* ]]; then + printf 'error: timed out waiting for the condition on %s\n' "$AGENT_OS_TEST_FAIL_WAIT_TARGET" >&2 + exit 1 +fi +if [ "${AGENT_OS_TEST_FAIL_ROLLOUT:-0}" = 1 ] && [[ " $* " = *" rollout status statefulset/agent-os-firstmate "* ]]; then + exit 1 +fi +if [ "${AGENT_OS_TEST_FAIL_FIRST_ROLLOUT:-0}" = 1 ] && \ + [[ " $* " = *" rollout status statefulset/agent-os-firstmate "* ]] && \ + [ "$(grep -Fc 'rollout status statefulset/agent-os-firstmate' "$AGENT_OS_TEST_LOG")" -eq 1 ]; then + exit 1 +fi +if [ "${AGENT_OS_TEST_LOCK_STATE:-free}" = held ] && [[ " $* " = *" wait --for=delete lease/"* ]]; then + exit 1 +fi +if [ "${AGENT_OS_TEST_FAIL_ANNOTATE:-0}" = 1 ] && \ + { [[ " $* " = *" annotate statefulset agent-os-firstmate "* ]] || \ + { [[ " $* " = *" patch StatefulSet agent-os-firstmate "* ]] && [[ " $* " = *"cluster-rbac-cleanup"* ]]; }; }; then + exit 1 +fi +if [ -n "${AGENT_OS_TEST_FAIL_DELETE_FILE:-}" ] && [[ " $* " = *" delete "* ]] && \ + [[ " $* " = *"$AGENT_OS_TEST_FAIL_DELETE_FILE"* ]]; then + exit 1 +fi +if [ -n "${AGENT_OS_TEST_FAIL_DELETE_TARGET:-}" ] && [[ " $* " = *"$AGENT_OS_TEST_FAIL_DELETE_TARGET"* ]]; then + printf '%s\n' "${AGENT_OS_TEST_DELETE_ERROR:-transport error}" >&2 + exit 1 +fi +if [ -n "${AGENT_OS_TEST_READY_DELAY:-}" ] && [[ " $* " = *" wait --for=condition=Ready pod/"* ]]; then + sleep "$AGENT_OS_TEST_READY_DELAY" +fi +if [ -n "${AGENT_OS_TEST_ROLLOUT_DELAY:-}" ] && [[ " $* " = *" rollout status statefulset/agent-os-firstmate "* ]]; then + sleep "$AGENT_OS_TEST_ROLLOUT_DELAY" +fi +if [[ " $* " = *" get Role agent-os-lifecycle-"*" -o json "* ]] || \ + [[ " $* " = *" get role agent-os-lifecycle-"*" -o json "* ]]; then + control_name=$(printf '%s\n' "$*" | sed -n 's/.* get [Rr]ole \([^ ]*\) .*/\1/p') + if ! grep -Fq "/roles/$control_name" "$AGENT_OS_TEST_LOG"; then + if [ "${AGENT_OS_TEST_CONTROL_RBAC_STATE:-exact}" = bad-rules ]; then + printf '{"metadata":{"name":"%s","uid":"uid-control-role","resourceVersion":"rv-control-role","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}},"rules":[]}\n' "$control_name" + else + printf '{"metadata":{"name":"%s","uid":"uid-control-role","resourceVersion":"rv-control-role","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}},"rules":[{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"resourceNames":["%s"],"verbs":["get","update"]}]}\n' "$control_name" "$control_name" + fi + fi + exit 0 +fi +if [[ " $* " = *" get RoleBinding agent-os-lifecycle-"*" -o json "* ]] || \ + [[ " $* " = *" get rolebinding agent-os-lifecycle-"*" -o json "* ]]; then + control_name=$(printf '%s\n' "$*" | sed -n 's/.* get [Rr]ole[Bb]inding \([^ ]*\) .*/\1/p') + if ! grep -Fq "/rolebindings/$control_name" "$AGENT_OS_TEST_LOG"; then + control_account=$(effective_binding_account "$control_name") + printf '{"metadata":{"name":"%s","uid":"uid-control-binding","resourceVersion":"rv-control-binding","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}},"roleRef":{"apiGroup":"rbac.authorization.k8s.io","kind":"Role","name":"%s"},"subjects":[{"kind":"ServiceAccount","name":"%s","namespace":"portable-agent-os"}]}\n' "$control_name" "$control_name" "$control_account" + fi + exit 0 +fi +case " $* " in + *" get pod agent-os-crewmate-"*" --ignore-not-found -o jsonpath="*) + id=${AGENT_OS_TEST_CREWMATE_ID:-scout-1} + pod_state=${AGENT_OS_TEST_POD_STATE:-absent} + if grep -F 'stdin-kind Pod' "$AGENT_OS_TEST_LOG" >/dev/null; then + pod_state=${AGENT_OS_TEST_POD_AFTER_APPLY:-owned} + fi + last_create=$(grep -Fn 'stdin-kind Pod' "$AGENT_OS_TEST_LOG" | tail -n 1 | cut -d: -f1) + last_delete=$(grep -Fn '/pods/agent-os-crewmate-' "$AGENT_OS_TEST_LOG" | grep 'delete --raw' | tail -n 1 | cut -d: -f1) + if [ -n "$last_delete" ] && { [ -z "$last_create" ] || [ "$last_delete" -gt "$last_create" ]; }; then + pod_state=${AGENT_OS_TEST_POD_AFTER_DELETE:-absent} + fi + case "$pod_state" in + absent) ;; + owned) + printf 'agent-os-crewmate-%s\tagent-os\t%s\tagent-os-firstmate:agent-os-demo' "$id" "$id" + [[ " $* " != *'.metadata.uid'* ]] || printf '\toperation-test\tuid-owned\trv-pod-owned\tagent-os-crewmate-%s-home' "$id" + ;; + replacement) + printf 'agent-os-crewmate-%s\tagent-os\t%s\tagent-os-firstmate:agent-os-demo' "$id" "$id" + [[ " $* " != *'.metadata.uid'* ]] || printf '\tother-operation\tuid-replacement\trv-pod-replacement\tagent-os-crewmate-%s-home' "$id" + ;; + replaced-after-ready) + printf 'agent-os-crewmate-%s\tagent-os\t%s\tagent-os-firstmate:agent-os-demo' "$id" "$id" + if [[ " $* " = *'.metadata.uid'* ]]; then + if grep -F 'wait --for=condition=Ready pod/' "$AGENT_OS_TEST_LOG" >/dev/null; then + printf '\tother-operation\tuid-replacement\trv-pod-replacement\tagent-os-crewmate-%s-home' "$id" + else + printf '\toperation-test\tuid-owned\trv-pod-owned\tagent-os-crewmate-%s-home' "$id" + fi + fi + ;; + foreign) + printf 'agent-os-crewmate-%s\tother\tother\tother-installation' "$id" + [[ " $* " != *'.metadata.uid'* ]] || printf '\tother-operation\tuid-foreign\trv-pod-foreign\tforeign-home' + ;; + esac + ;; + *" get pvc agent-os-crewmate-"*" --ignore-not-found -o jsonpath="*) + id=${AGENT_OS_TEST_CREWMATE_ID:-scout-1} + pvc_state=${AGENT_OS_TEST_PVC_STATE:-absent} + if grep -F "/persistentvolumeclaims/agent-os-crewmate-$id-home" "$AGENT_OS_TEST_LOG" | grep -F 'delete --raw' >/dev/null; then + if [ -n "${AGENT_OS_TEST_PVC_AFTER_DELETE:-}" ]; then + pvc_state=$AGENT_OS_TEST_PVC_AFTER_DELETE + elif [[ "${AGENT_OS_TEST_FAIL_DELETE_TARGET:-}" != *"/persistentvolumeclaims/agent-os-crewmate-$id-home"* ]]; then + pvc_state=absent + fi + fi + if grep -F 'stdin-kind PersistentVolumeClaim' "$AGENT_OS_TEST_LOG" >/dev/null; then + [ "$pvc_state" != absent ] || pvc_state=owned + fi + case "$pvc_state" in + absent) ;; + owned) printf 'agent-os-crewmate-%s-home\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tpending\t\toperation-test\t\tuid-pvc-owned\trv-pvc-owned' "$id" "$id" ;; + clean) printf 'agent-os-crewmate-%s-home\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tclean\t2026-07-13T12:00:00Z\toperation-test\toperation-test\tuid-pvc-owned\trv-pvc-owned' "$id" "$id" ;; + stale-clean) printf 'agent-os-crewmate-%s-home\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tclean\t2026-07-13T12:00:00Z\toperation-test\told-operation\tuid-pvc-owned\trv-pvc-owned' "$id" "$id" ;; + invalid-checkpoint) printf 'agent-os-crewmate-%s-home\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tclean\t2026\toperation-test\toperation-test\tuid-pvc-owned\trv-pvc-owned' "$id" "$id" ;; + foreign) printf 'agent-os-crewmate-%s-home\tother\tother\tother-installation\tclean\t2026-07-13T12:00:00Z\toperation-test\toperation-test\tuid-pvc-foreign\trv-pvc-foreign' "$id" ;; + binder-rv) + pvc_reads=$(grep -Fc ' get pvc agent-os-crewmate-' "$AGENT_OS_TEST_LOG") + printf 'agent-os-crewmate-%s-home\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tpending\t\t\t\tuid-pvc-owned\trv-pvc-%s' "$id" "$id" "$pvc_reads" + ;; + replaced-after-pod) + if grep -F 'stdin-kind Pod' "$AGENT_OS_TEST_LOG" >/dev/null; then + printf 'agent-os-crewmate-%s-home\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tpending\t\t\t\tuid-pvc-replacement\trv-pvc-replacement' "$id" "$id" + else + printf 'agent-os-crewmate-%s-home\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tpending\t\t\t\tuid-pvc-owned\trv-pvc-owned' "$id" "$id" + fi + ;; + replaced-after-ready) + if grep -F 'wait --for=condition=Ready pod/' "$AGENT_OS_TEST_LOG" >/dev/null; then + printf 'agent-os-crewmate-%s-home\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tpending\t\t\t\tuid-pvc-after-ready\trv-pvc-after-ready' "$id" "$id" + else + printf 'agent-os-crewmate-%s-home\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tpending\t\t\t\tuid-pvc-owned\trv-pvc-owned' "$id" "$id" + fi + ;; + esac + ;; + *get\ lease\ agent-os-crewmate-*--ignore-not-found*-o\ jsonpath=*) + id=${AGENT_OS_TEST_CREWMATE_ID:-scout-1} + lease_holder=$(awk '/holderIdentity:/ { holder=$2 } END { print holder }' "${AGENT_OS_STDIN_LOG:-$AGENT_OS_TEST_LOG.stdin}") + case "${AGENT_OS_TEST_LOCK_STATE:-free}" in + held) printf 'agent-os-crewmate-%s-lifecycle\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tother-operation\t2099-01-01T00:00:00Z\t2099-01-01T00:00:00Z\t300\tuid-lock-other\trv-lock-other' "$id" "$id" ;; + foreign) printf 'agent-os-crewmate-%s-lifecycle\tother\tother\tother-installation\tother-operation\t2099-01-01T00:00:00Z\t2099-01-01T00:00:00Z\t300\tuid-lock-foreign\trv-lock-foreign' "$id" ;; + expired) + last_expired_delete=$(grep -Fn "/leases/agent-os-crewmate-$id-lifecycle" "$AGENT_OS_TEST_LOG" | grep 'delete --raw' | tail -n 1 | cut -d: -f1) + if [ -n "$last_expired_delete" ]; then + : + elif grep -F ' replace -f -' "$AGENT_OS_TEST_LOG" >/dev/null; then + acquire_time=$(awk '/acquireTime:/ { value=$2 } END { print value }' "${AGENT_OS_STDIN_LOG:-$AGENT_OS_TEST_LOG.stdin}") + renew_time=$(awk '/renewTime:/ { value=$2 } END { print value }' "${AGENT_OS_STDIN_LOG:-$AGENT_OS_TEST_LOG.stdin}") + printf 'agent-os-crewmate-%s-lifecycle\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\t%s\t%s\t%s\t%s\tuid-lock-expired\trv-lock-taken' \ + "$id" "$id" "$lease_holder" "$acquire_time" "$renew_time" "${AGENT_OS_LOCK_DURATION_SECONDS:-300}" + else + printf 'agent-os-crewmate-%s-lifecycle\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tother-operation\t2000-01-01T00:00:00Z\t2000-01-01T00:00:00Z\t300\tuid-lock-expired\t%s' "$id" "$id" "${AGENT_OS_TEST_LOCK_RV:-rv-lock-expired}" + fi + ;; + *) + last_lock_write=$(grep -Fn 'stdin-kind Lease' "$AGENT_OS_TEST_LOG" | tail -n 1 | cut -d: -f1) + last_lock_delete=$(grep -Fn "/leases/agent-os-crewmate-$id-lifecycle" "$AGENT_OS_TEST_LOG" | grep 'delete --raw' | tail -n 1 | cut -d: -f1) + if [ -n "$last_lock_delete" ] && [ "${AGENT_OS_TEST_LOCK_RELEASE_STATE:-}" = next-owner ]; then + printf 'agent-os-crewmate-%s-lifecycle\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\tnext-operation\t2026-07-13T00:00:00Z\t2026-07-13T00:00:00Z\t300\tuid-lock-next\trv-lock-next' "$id" "$id" + elif [ -n "$last_lock_delete" ] && [ "${AGENT_OS_TEST_LOCK_RELEASE_STATE:-}" = foreign ]; then + printf 'agent-os-crewmate-%s-lifecycle\tother\tother\tother-installation\tother-operation\t2099-01-01T00:00:00Z\t2099-01-01T00:00:00Z\t300\tuid-lock-foreign\trv-lock-foreign' "$id" + elif [ -n "$last_lock_write" ] && { [ -z "$last_lock_delete" ] || [ "$last_lock_write" -gt "$last_lock_delete" ]; }; then + acquire_time=$(awk '/acquireTime:/ { value=$2 } END { print value }' "${AGENT_OS_STDIN_LOG:-$AGENT_OS_TEST_LOG.stdin}") + renew_time=$(awk '/renewTime:/ { value=$2 } END { print value }' "${AGENT_OS_STDIN_LOG:-$AGENT_OS_TEST_LOG.stdin}") + renew_rv=rv-lock + if grep -F ' replace -f -' "$AGENT_OS_TEST_LOG" >/dev/null; then + renew_rv=rv-lock-renewed + [ "${AGENT_OS_TEST_RENEW_READBACK:-exact}" != wrong ] || renew_time=2000-01-01T00:00:00Z + fi + printf 'agent-os-crewmate-%s-lifecycle\tagent-os\t%s\tagent-os-firstmate:agent-os-demo\t%s\t%s\t%s\t%s\tuid-lock\t%s' \ + "$id" "$id" "$lease_holder" "$acquire_time" "$renew_time" "${AGENT_OS_LOCK_DURATION_SECONDS:-300}" "$renew_rv" + fi + ;; + esac + ;; + *get\ lease\ agent-os-firstmate-lifecycle*--ignore-not-found*-o\ jsonpath=*|\ + *get\ lease\ agent-os-lifecycle-*--ignore-not-found*-o\ jsonpath=*) + lock_name=$(printf '%s\n' "$*" | sed -n 's/.* get lease \([^ ]*\) .*/\1/p') + last_lock_write=$(grep -Fn "stdin-lease $lock_name" "$AGENT_OS_TEST_LOG" | tail -n 1 | cut -d: -f1) + last_lock_delete=$(grep -Fn "/leases/$lock_name" "$AGENT_OS_TEST_LOG" | grep 'delete --raw' | tail -n 1 | cut -d: -f1) + lock_installation="agent-os-firstmate:${AGENT_OS_TEST_NAMESPACE:-${AGENT_OS_NAMESPACE:-portable-agent-os}}" + if [[ "$lock_name" = agent-os-lifecycle-* ]]; then + control_digest=$(printf 'agent-os-installation:%s' "${AGENT_OS_TEST_NAMESPACE:-${AGENT_OS_NAMESPACE:-portable-agent-os}}" | shasum -a 256 | awk '{print $1}') + control_uuid="${control_digest:0:8}-${control_digest:8:4}-5${control_digest:13:3}-8${control_digest:17:3}-${control_digest:20:12}" + lock_installation="agent-os-control:$control_uuid" + fi + if [ "${AGENT_OS_TEST_PRIMARY_LOCK_STATE:-free}" = expired ] && ! grep -F ' replace -f -' "$AGENT_OS_TEST_LOG" >/dev/null; then + printf '%s\tagent-os\tprimary\t%s\tother-operation\t2000-01-01T00:00:00Z\t2000-01-01T00:00:00Z\t300\tuid-primary-lock\t%s' "$lock_name" "$lock_installation" "${AGENT_OS_TEST_PRIMARY_LOCK_RV:-rv-primary-lock}" + elif [ -n "$last_lock_write" ] && { [ -z "$last_lock_delete" ] || [ "$last_lock_write" -gt "$last_lock_delete" ]; }; then + lock_stdin=${AGENT_OS_STDIN_LOG:-$AGENT_OS_TEST_LOG.stdin} + lock_holder=$(awk '/holderIdentity:/ { holder=$2 } END { print holder }' "$lock_stdin") + lock_acquired=$(awk '/acquireTime:/ { value=$2 } END { print value }' "$lock_stdin") + lock_renewed=$(awk '/renewTime:/ { value=$2 } END { print value }' "$lock_stdin") + lock_replace_count=$(awk -v name="$lock_name" ' + /kubectl .* replace -f -/ { replacing=1; next } + /^stdin-lease / { if (replacing && $2 == name) count++; replacing=0 } + END { print count+0 } + ' "$AGENT_OS_TEST_LOG") + lock_rv="${AGENT_OS_TEST_PRIMARY_LOCK_RV:-rv-primary-lock}-$lock_replace_count" + printf '%s\tagent-os\tprimary\t%s\t%s\t%s\t%s\t%s\tuid-primary-lock\t%s' \ + "$lock_name" "$lock_installation" "$lock_holder" "$lock_acquired" "$lock_renewed" \ + "${AGENT_OS_LOCK_DURATION_SECONDS:-300}" "$lock_rv" + fi + ;; + *" get namespace "*" --ignore-not-found -o name "*) + namespace_state=${AGENT_OS_TEST_NAMESPACE_STATE:-absent} + if grep -F ' create -f ' "$AGENT_OS_TEST_LOG" | grep -F 'namespace.yaml' >/dev/null || \ + grep -F ' apply -f ' "$AGENT_OS_TEST_LOG" >/dev/null; then + namespace_state=${AGENT_OS_TEST_NAMESPACE_AFTER_APPLY:-owned} + fi + case "$namespace_state" in + absent) ;; + *) printf 'namespace/%s\n' "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" ;; + esac + ;; + *" get namespace "*" -o jsonpath="*|*" get Namespace "*" -o jsonpath="*) + namespace_state=${AGENT_OS_TEST_NAMESPACE_STATE:-absent} + if grep -F ' create -f ' "$AGENT_OS_TEST_LOG" | grep -F 'namespace.yaml' >/dev/null || \ + grep -F ' apply -f ' "$AGENT_OS_TEST_LOG" >/dev/null; then + namespace_state=${AGENT_OS_TEST_NAMESPACE_AFTER_APPLY:-owned} + fi + case "$namespace_state" in + owned) + if [[ " $* " = *'.metadata.resourceVersion'* ]]; then + current_operation=$(grep 'akua-input-operation ' "$AGENT_OS_TEST_LOG" | tail -n 1 | awk '{print $2}') + printf '%s\tagent-os\tagent-os-firstmate:%s\tuid-namespace\trv-namespace\t%s' \ + "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" "$current_operation" + elif [[ " $* " = *'.metadata.uid'* ]]; then + printf '%s\tagent-os\tagent-os-firstmate:%s\tuid-namespace\toperation-test\tTrue\t[]' \ + "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" + else + printf 'agent-os\tagent-os-firstmate:%s' "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" + fi + ;; + foreign) printf 'other\tother-installation' ;; + *) printf '\t' ;; + esac + ;; + *" get statefulset agent-os-firstmate --ignore-not-found -o jsonpath="*) + workload_state=${AGENT_OS_TEST_WORKLOAD_STATE:-absent} + if grep -F ' create -f ' "$AGENT_OS_TEST_LOG" | grep -F 'statefulset.yaml' >/dev/null || \ + grep -F ' patch StatefulSet agent-os-firstmate ' "$AGENT_OS_TEST_LOG" >/dev/null || \ + grep -F ' apply -f ' "$AGENT_OS_TEST_LOG" >/dev/null; then + workload_state=${AGENT_OS_TEST_WORKLOAD_AFTER_APPLY:-namespace} + fi + case "$workload_state" in + absent) ;; + namespace) printf 'agent-os-firstmate\tnamespace\t\tagent-os\tagent-os-firstmate:portable-agent-os' ;; + cluster-admin) printf 'agent-os-firstmate\tcluster-admin\t\tagent-os\tagent-os-firstmate:portable-agent-os' ;; + none) printf 'agent-os-firstmate\tnone\t\tagent-os\tagent-os-firstmate:portable-agent-os' ;; + pending) printf 'agent-os-firstmate\tnamespace\trequired\tagent-os\tagent-os-firstmate:portable-agent-os' ;; + unknown) printf 'agent-os-firstmate\t\t\tagent-os\tagent-os-firstmate:portable-agent-os' ;; + foreign) printf 'agent-os-firstmate\tnamespace\t\tother\tother-installation' ;; + esac + ;; + *" get StatefulSet agent-os-firstmate --ignore-not-found -o jsonpath="*|\ + *" get ServiceAccount agent-os-firstmate"*" --ignore-not-found -o jsonpath="*|\ + *" get PersistentVolumeClaim agent-os-firstmate-home --ignore-not-found -o jsonpath="*|\ + *" get Service agent-os-firstmate --ignore-not-found -o jsonpath="*|\ + *" get Role agent-os-firstmate-runtime --ignore-not-found -o jsonpath="*|\ + *" get RoleBinding agent-os-firstmate-runtime --ignore-not-found -o jsonpath="*) + kind='' + name='' + case " $* " in + *" StatefulSet "*) kind=StatefulSet; name=agent-os-firstmate ;; + *" ServiceAccount "*) kind=ServiceAccount; name=$(printf '%s\n' "$*" | sed -n 's/.* get ServiceAccount \([^ ]*\) .*/\1/p') ;; + *" PersistentVolumeClaim "*) kind=PersistentVolumeClaim; name=agent-os-firstmate-home ;; + *" Service "*) kind=Service; name=agent-os-firstmate ;; + *" RoleBinding "*) kind=RoleBinding; name=agent-os-firstmate-runtime ;; + *" Role "*) kind=Role; name=agent-os-firstmate-runtime ;; + esac + resource_state=${AGENT_OS_TEST_RESOURCE_STATE:-absent} + if { [ "$kind" = Role ] || [ "$kind" = RoleBinding ]; } && [ -n "${AGENT_OS_TEST_STALE_RBAC_STATE:-}" ]; then + resource_state=$AGENT_OS_TEST_STALE_RBAC_STATE + fi + if [ "$kind" = ServiceAccount ] && [ "$name" = "${AGENT_OS_TEST_RESIDUAL_SERVICE_ACCOUNT:-}" ]; then + resource_state=owned + grep -F "/serviceaccounts/$name" "$AGENT_OS_TEST_LOG" | grep -F 'delete --raw' >/dev/null && resource_state=absent + fi + if grep -E ' create -f .+\.yaml' "$AGENT_OS_TEST_LOG" | grep -v 'namespace.yaml' >/dev/null || \ + grep -F ' --patch-file ' "$AGENT_OS_TEST_LOG" >/dev/null || \ + grep -F ' apply -f ' "$AGENT_OS_TEST_LOG" >/dev/null; then + resource_state=${AGENT_OS_TEST_RESOURCE_AFTER_APPLY:-owned} + fi + case "$resource_state" in + absent) ;; + owned) + printf '%s\tagent-os\tagent-os-firstmate:portable-agent-os' "$name" + if [[ " $* " = *'.metadata.resourceVersion'* ]]; then + current_operation=$(grep 'akua-input-operation ' "$AGENT_OS_TEST_LOG" | tail -n 1 | awk '{print $2}') + printf '\tuid-%s\t%s\t%s' "$(printf '%s' "$kind" | tr '[:upper:]' '[:lower:]')" "${AGENT_OS_TEST_RESOURCE_RV:-rv-$(printf '%s' "$kind" | tr '[:upper:]' '[:lower:]')}" "$current_operation" + elif [[ " $* " = *'.spec.replicas'* ]]; then + printf '\tuid-statefulset\toperation-test\t\t[kubernetes.io/pvc-protection]\t1\t1\t0\t0\t0\trev-old\trev-new\t2\t1' + elif [[ " $* " = *'.metadata.uid'* ]]; then + printf '\tuid-%s\toperation-test\tTrue\t[kubernetes.io/pvc-protection]' "$(printf '%s' "$kind" | tr '[:upper:]' '[:lower:]')" + fi + ;; + foreign) printf '%s\tother\tother-installation' "$name" ;; + esac + ;; + *" get statefulset agent-os-firstmate -o json "*) + rollback_current=${AGENT_OS_TEST_ROLLBACK_CURRENT:-agent-os-firstmate-previous} + rollback_update=${AGENT_OS_TEST_ROLLBACK_UPDATE:-agent-os-firstmate-current} + if [ "${AGENT_OS_TEST_FAIL_ROLLOUT:-0}" != 1 ] && \ + grep -F 'rollout status statefulset/agent-os-firstmate' "$AGENT_OS_TEST_LOG" >/dev/null; then + rollback_current=agent-os-firstmate-previous + rollback_update=agent-os-firstmate-previous + fi + if [ "${AGENT_OS_TEST_ROLLBACK_VERIFY_MISMATCH:-0}" = 1 ] && \ + grep -F 'rollout status statefulset/agent-os-firstmate' "$AGENT_OS_TEST_LOG" >/dev/null; then + rollback_current=agent-os-firstmate-current + rollback_update=agent-os-firstmate-current + fi + checkpoint_annotations='' + akua_annotations='' + akua_env='' + akua_mount='' + akua_volume='' + if [ -n "${AGENT_OS_TEST_AKUA_OVERLAY_SECRET:-}" ] && \ + ! grep -F 'agent-os.dev/akua-auth-rejected-record' "$AGENT_OS_TEST_LOG" >/dev/null; then + akua_annotations=$(printf ',"agent-os.dev/akua-auth-secret":"%s"' "$AGENT_OS_TEST_AKUA_OVERLAY_SECRET") + akua_env=',{"name":"AKUA_AUTH_HEADER_FILE","value":"/var/run/secrets/agent-os/akua/authorization"}' + akua_mount=',{"name":"akua-auth","mountPath":"/var/run/secrets/agent-os/akua","readOnly":true}' + akua_volume=$(printf ',{"name":"akua-auth","secret":{"secretName":"%s","defaultMode":256}}' "$AGENT_OS_TEST_AKUA_OVERLAY_SECRET") + fi + if [ -n "${AGENT_OS_TEST_ROLLBACK_CHECKPOINT_DIGEST:-}" ]; then + checkpoint_source_digest=$(printf '%s' '{"metadata":{"labels":{"rollback":"current"}},"spec":{"serviceAccountName":"agent-os-firstmate"}}' | jq -cS . | shasum -a 256 | awk '{print $1}') + checkpoint_annotations=$(printf ',"agent-os.dev/rollback-operation":"checkpoint-operation","agent-os.dev/rollback-target-name":"agent-os-firstmate-previous","agent-os.dev/rollback-target-uid":"uid-revision-previous","agent-os.dev/rollback-target-digest":"%s","agent-os.dev/rollback-source-name":"agent-os-firstmate-current","agent-os.dev/rollback-source-uid":"uid-revision-current","agent-os.dev/rollback-source-digest":"%s"' "$AGENT_OS_TEST_ROLLBACK_CHECKPOINT_DIGEST" "$checkpoint_source_digest") + elif checkpoint_call=$(grep 'patch StatefulSet agent-os-firstmate --type=merge' "$AGENT_OS_TEST_LOG" | grep 'rollback-target-digest' | head -n 1); then + checkpoint_operation=$(printf '%s' "$checkpoint_call" | sed -n 's/.*rollback-operation":"\([^"]*\).*/\1/p') + checkpoint_name=$(printf '%s' "$checkpoint_call" | sed -n 's/.*rollback-target-name":"\([^"]*\).*/\1/p') + checkpoint_uid=$(printf '%s' "$checkpoint_call" | sed -n 's/.*rollback-target-uid":"\([^"]*\).*/\1/p') + checkpoint_digest=$(printf '%s' "$checkpoint_call" | sed -n 's/.*rollback-target-digest":"\([0-9a-f]*\).*/\1/p') + checkpoint_source_name=$(printf '%s' "$checkpoint_call" | sed -n 's/.*rollback-source-name":"\([^"]*\).*/\1/p') + checkpoint_source_uid=$(printf '%s' "$checkpoint_call" | sed -n 's/.*rollback-source-uid":"\([^"]*\).*/\1/p') + checkpoint_source_digest=$(printf '%s' "$checkpoint_call" | sed -n 's/.*rollback-source-digest":"\([0-9a-f]*\).*/\1/p') + checkpoint_annotations=$(printf ',"agent-os.dev/rollback-operation":"%s","agent-os.dev/rollback-target-name":"%s","agent-os.dev/rollback-target-uid":"%s","agent-os.dev/rollback-target-digest":"%s","agent-os.dev/rollback-source-name":"%s","agent-os.dev/rollback-source-uid":"%s","agent-os.dev/rollback-source-digest":"%s"' \ + "$checkpoint_operation" "$checkpoint_name" "$checkpoint_uid" "$checkpoint_digest" "$checkpoint_source_name" "$checkpoint_source_uid" "$checkpoint_source_digest") + fi + if [ "${AGENT_OS_TEST_ROLLBACK_CHECKPOINT_MUTATE:-0}" = 1 ] && \ + grep -F 'rollout status statefulset/agent-os-firstmate' "$AGENT_OS_TEST_LOG" >/dev/null; then + checkpoint_annotations=$(printf '%s' "$checkpoint_annotations" | \ + sed 's/rollback-operation":"[^"]*/rollback-operation":"other-operation/') + fi + if [ "${AGENT_OS_TEST_ROLLBACK_CHECKPOINT_MUTATE_ON_READ:-0}" = 1 ] && [ -n "$checkpoint_call" ] && \ + ! grep -F 'rollout status statefulset/agent-os-firstmate' "$AGENT_OS_TEST_LOG" >/dev/null; then + checkpoint_annotations=$(printf '%s' "$checkpoint_annotations" | \ + sed 's/rollback-operation":"[^"]*/rollback-operation":"other-operation/') + fi + statefulset_uid=uid-statefulset + if [ "${AGENT_OS_TEST_REPLACE_STATEFULSET_AFTER_ROLLOUT:-0}" = 1 ] && \ + grep -F 'rollout status statefulset/agent-os-firstmate' "$AGENT_OS_TEST_LOG" >/dev/null; then + statefulset_uid=uid-statefulset-replacement + fi + printf '{"metadata":{"name":"agent-os-firstmate","uid":"%s","resourceVersion":"%s","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"%s%s}},"spec":{"template":{"spec":{"serviceAccountName":"%s","containers":[{"name":"firstmate","env":[%s],"volumeMounts":[%s]}],"volumes":[%s]}}},"status":{"currentRevision":"%s","updateRevision":"%s"}}\n' \ + "$statefulset_uid" "${AGENT_OS_TEST_RESOURCE_RV:-rv-statefulset}" "$checkpoint_annotations" "$akua_annotations" "${AGENT_OS_TEST_WORKLOAD_SERVICE_ACCOUNT:-agent-os-firstmate}" "${akua_env#,}" "${akua_mount#,}" "${akua_volume#,}" "$rollback_current" "$rollback_update" + ;; + *" get secret "*" --ignore-not-found -o jsonpath="*) + secret_name=$(printf '%s\n' "$*" | sed -n 's/.* get secret \([^ ]*\) .*/\1/p') + if [ "$secret_name" = "${AGENT_OS_TEST_AKUA_OVERLAY_SECRET:-}" ]; then + printf '%s\tuid-secret\trv-secret\tauthorization' "$secret_name" + fi + ;; + *" get controllerrevisions.apps -o json "*) + if grep -Fq '/statefulsets/agent-os-firstmate' "$AGENT_OS_TEST_LOG" && grep -Fq 'delete --raw' "$AGENT_OS_TEST_LOG"; then + printf '%s\n' '{"items":[]}' + elif [ "${AGENT_OS_TEST_ROLLBACK_RENUMBERED:-0}" = 1 ]; then + printf '%s\n' '{"items":[{"metadata":{"name":"agent-os-firstmate-previous","uid":"uid-revision-previous","ownerReferences":[{"apiVersion":"apps/v1","kind":"StatefulSet","name":"agent-os-firstmate","uid":"uid-statefulset","controller":true}]},"revision":1,"data":{"spec":{"template":{"metadata":{"labels":{"rollback":"previous"}},"spec":{"serviceAccountName":"agent-os-firstmate"}}}}},{"metadata":{"name":"agent-os-firstmate-current","uid":"uid-revision-current","ownerReferences":[{"apiVersion":"apps/v1","kind":"StatefulSet","name":"agent-os-firstmate","uid":"uid-statefulset","controller":true}]},"revision":2,"data":{"spec":{"template":{"metadata":{"labels":{"rollback":"current"}},"spec":{"serviceAccountName":"agent-os-firstmate"}}}}},{"metadata":{"name":"agent-os-firstmate-renumbered","uid":"uid-revision-renumbered","ownerReferences":[{"apiVersion":"apps/v1","kind":"StatefulSet","name":"agent-os-firstmate","uid":"uid-statefulset","controller":true}]},"revision":3,"data":{"spec":{"template":{"metadata":{"labels":{"rollback":"previous"}},"spec":{"serviceAccountName":"agent-os-firstmate"}}}}}]}' + else + printf '%s\n' '{"items":[{"metadata":{"name":"agent-os-firstmate-previous","uid":"uid-revision-previous","ownerReferences":[{"apiVersion":"apps/v1","kind":"StatefulSet","name":"agent-os-firstmate","uid":"uid-statefulset","controller":true}]},"revision":1,"data":{"spec":{"template":{"metadata":{"labels":{"rollback":"previous"}},"spec":{"serviceAccountName":"agent-os-firstmate"}}}}},{"metadata":{"name":"agent-os-firstmate-current","uid":"uid-revision-current","ownerReferences":[{"apiVersion":"apps/v1","kind":"StatefulSet","name":"agent-os-firstmate","uid":"uid-statefulset","controller":true}]},"revision":2,"data":{"spec":{"template":{"metadata":{"labels":{"rollback":"current"}},"spec":{"serviceAccountName":"agent-os-firstmate"}}}}}]}' + fi + ;; + *" get Role agent-os-lifecycle-"*" --ignore-not-found -o json "*|\ + *" get RoleBinding agent-os-lifecycle-"*" --ignore-not-found -o json "*|\ + *" get role agent-os-lifecycle-"*" --ignore-not-found -o json "*|\ + *" get rolebinding agent-os-lifecycle-"*" --ignore-not-found -o json "*|\ + *" get rolebinding agent-os-lifecycle-"*" -o json "*) + control_name=$(printf '%s\n' "$*" | sed -n 's/.* get \([Rr]ole\|[Rr]ole[Bb]inding\) \([^ ]*\) .*/\2/p') + control_kind=$(printf '%s\n' "$*" | sed -n 's/.* get \([Rr]ole\|[Rr]ole[Bb]inding\) .*/\1/p') + case "$control_kind" in role) control_kind=Role ;; rolebinding) control_kind=RoleBinding ;; esac + if ! grep -Fq "/$([ "$control_kind" = Role ] && printf roles || printf rolebindings)/$control_name" "$AGENT_OS_TEST_LOG"; then + control_account=$(effective_binding_account "$control_name") + if [ "$control_kind" = Role ]; then + if [ "${AGENT_OS_TEST_CONTROL_RBAC_STATE:-exact}" = bad-rules ]; then + printf '{"metadata":{"name":"%s","uid":"uid-control-role","resourceVersion":"rv-control-role","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}},"rules":[]}\n' "$control_name" + else + printf '{"metadata":{"name":"%s","uid":"uid-control-role","resourceVersion":"rv-control-role","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}},"rules":[{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"resourceNames":["%s"],"verbs":["get","update"]}]}\n' "$control_name" "$control_name" + fi + else + printf '{"metadata":{"name":"%s","uid":"uid-control-binding","resourceVersion":"rv-control-binding","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}},"roleRef":{"apiGroup":"rbac.authorization.k8s.io","kind":"Role","name":"%s"},"subjects":[{"kind":"ServiceAccount","name":"%s","namespace":"portable-agent-os"}]}\n' "$control_name" "$control_name" "$control_account" + fi + fi + ;; + *" get role agent-os-firstmate-runtime -o json "*) + if [ "${AGENT_OS_TEST_RBAC_STATE:-exact}" = bad-rules ]; then + printf '%s\n' '{"metadata":{"name":"agent-os-firstmate-runtime","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}},"rules":[]}' + else + printf '%s\n' '{"metadata":{"name":"agent-os-firstmate-runtime","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}},"rules":[{"apiGroups":[""],"resources":["pods","persistentvolumeclaims"],"verbs":["get","list","watch","create","delete","patch"]},{"apiGroups":[""],"resources":["pods/log","pods/exec"],"verbs":["get","list","watch","create","delete"]},{"apiGroups":["apps"],"resources":["statefulsets"],"verbs":["get","list","watch"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","create","update","delete"]}]}' + fi + ;; + *" get rolebinding agent-os-firstmate-runtime -o json "*) + service_account=$(effective_binding_account agent-os-firstmate-runtime) + if [ "${AGENT_OS_TEST_RBAC_STATE:-exact}" = extra-subject ]; then + printf '{"metadata":{"name":"agent-os-firstmate-runtime","uid":"uid-runtime-binding","resourceVersion":"rv-runtime-binding","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}},"roleRef":{"apiGroup":"rbac.authorization.k8s.io","kind":"Role","name":"agent-os-firstmate-runtime"},"subjects":[{"kind":"ServiceAccount","name":"%s","namespace":"portable-agent-os"},{"kind":"ServiceAccount","name":"foreign","namespace":"portable-agent-os"}]}\n' "$service_account" + else + printf '{"metadata":{"name":"agent-os-firstmate-runtime","uid":"uid-runtime-binding","resourceVersion":"rv-runtime-binding","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}},"roleRef":{"apiGroup":"rbac.authorization.k8s.io","kind":"Role","name":"agent-os-firstmate-runtime"},"subjects":[{"kind":"ServiceAccount","name":"%s","namespace":"portable-agent-os"}]}\n' "$service_account" + fi + ;; + *" get role agent-os-firstmate-runtime -o jsonpath="*) + printf 'agent-os-firstmate-runtime' + ;; + *" get rolebinding agent-os-firstmate-runtime -o jsonpath="*) + printf 'Role\tagent-os-firstmate-runtime\tServiceAccount\tagent-os-firstmate\t%s' \ + "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" + ;; + *" get clusterrolebinding agent-os-firstmate-"*" --ignore-not-found -o jsonpath="*|\ + *" get ClusterRoleBinding agent-os-firstmate-"*" --ignore-not-found -o jsonpath="*) + cluster_state=${AGENT_OS_TEST_CLUSTER_RBAC_STATE:-absent} + if grep -F '/clusterrolebindings/agent-os-firstmate-portable-agent-os' "$AGENT_OS_TEST_LOG" | grep -F 'delete --raw' >/dev/null && \ + [ -n "${AGENT_OS_TEST_DELETE_READBACK_STATE:-}" ]; then + cluster_state=$AGENT_OS_TEST_DELETE_READBACK_STATE + fi + if [ -n "${AGENT_OS_TEST_CLUSTER_RBAC_AFTER_APPLY:-}" ] && \ + { grep -E ' create -f .+\.yaml' "$AGENT_OS_TEST_LOG" >/dev/null || grep -F ' --patch-file ' "$AGENT_OS_TEST_LOG" >/dev/null; }; then + cluster_state=$AGENT_OS_TEST_CLUSTER_RBAC_AFTER_APPLY + elif grep -F ' create -f ' "$AGENT_OS_TEST_LOG" | grep -F 'clusterrolebinding.yaml' >/dev/null || \ + grep -F ' patch ClusterRoleBinding agent-os-firstmate-' "$AGENT_OS_TEST_LOG" >/dev/null || \ + grep -F ' apply -f ' "$AGENT_OS_TEST_LOG" >/dev/null; then + cluster_state=${AGENT_OS_TEST_CLUSTER_RBAC_AFTER_APPLY:-owned} + fi + case "$cluster_state" in + absent) ;; + owned) + printf 'agent-os-firstmate-%s\tagent-os\tagent-os-firstmate:%s' \ + "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" + if [[ " $* " = *'.metadata.resourceVersion'* ]]; then + current_operation=$(grep 'akua-input-operation ' "$AGENT_OS_TEST_LOG" | tail -n 1 | awk '{print $2}') + printf '\tuid-clusterrolebinding\trv-clusterrolebinding\t%s' "$current_operation" + elif [[ " $* " = *'.metadata.uid'* ]]; then + printf '\tuid-clusterrolebinding\toperation-test\tTrue\t[]' + fi + ;; + replacement) + printf 'agent-os-firstmate-%s\tagent-os\tagent-os-firstmate:%s\tuid-clusterrolebinding-replacement\trv-clusterrolebinding-replacement\tother-operation' \ + "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" "${AGENT_OS_TEST_NAMESPACE:-portable-agent-os}" + ;; + foreign) printf 'agent-os-firstmate-portable-agent-os\tother\tother-installation' ;; + esac + ;; + *" get Pod agent-os-firstmate-0 --ignore-not-found -o jsonpath="*) + case "${AGENT_OS_TEST_PRIMARY_POD_STATE:-absent}" in + absent) ;; + owned) + printf 'agent-os-firstmate-0\tagent-os\tagent-os-firstmate:portable-agent-os\tuid-pod\toperation-test\tFalse\t[kubernetes.io/pvc-protection]' + ;; + foreign) printf 'agent-os-firstmate-0\tother\tother-installation\tuid-foreign\tother-operation\tFalse\t[]' ;; + esac + ;; + *" api-resources --verbs=list --namespaced -o name "*) + printf '%s\n' pods serviceaccounts configmaps leases.coordination.k8s.io + ;; + *" get pods -o name "*) + [ -z "${AGENT_OS_TEST_FOREIGN_RESOURCE:-}" ] || printf 'pod/%s\n' "$AGENT_OS_TEST_FOREIGN_RESOURCE" + ;; + *" get pods -o json "*) + if [ "${AGENT_OS_TEST_PVC_ATTACHED:-0}" = 1 ]; then + printf '%s\n' '{"items":[{"spec":{"volumes":[{"persistentVolumeClaim":{"claimName":"agent-os-crewmate-scout-1-home"}}]}}]}' + else + printf '%s\n' '{"items":[]}' + fi + ;; + *" get serviceaccounts -o name "*) printf '%s\n' serviceaccount/default ;; + *" get serviceaccounts -o json "*) + residual=${AGENT_OS_TEST_RESIDUAL_SERVICE_ACCOUNT:-} + if [ -n "$residual" ]; then + printf '{"items":[{"metadata":{"name":"%s","uid":"uid-residual-serviceaccount","resourceVersion":"rv-residual-serviceaccount","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:portable-agent-os"}}}]}\n' "$residual" + else + printf '%s\n' '{"items":[]}' + fi + ;; + *" get configmaps -o name "*) printf '%s\n' configmap/kube-root-ca.crt ;; + *" get leases.coordination.k8s.io -o name "*) + [ -z "${AGENT_OS_TEST_FOREIGN_LEASE:-}" ] || printf 'lease/%s\n' "$AGENT_OS_TEST_FOREIGN_LEASE" + ;; +esac +SH +chmod +x "$FAKEBIN/kubectl" + +run_launcher() { + PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_STDIN_LOG="$STDIN_LOG" \ + AGENT_OS_IN_CLUSTER=1 AGENT_OS_NAMESPACE=agent-os-demo AGENT_OS_IMAGE=agent-os:local-test \ + AGENT_OS_IMAGE_PULL_POLICY=Never AGENT_OS_AI_SECRET=scout-1-ai-auth \ + AGENT_OS_OPERATION_ID=operation-test AGENT_OS_PURGE_EVIDENCE_FILE="$PURGE_EVIDENCE" \ + AGENT_OS_LOCK_ACQUIRE_SECONDS=3 \ + "$LAUNCHER" "$@" +} + +: > "$CALLS" +run_launcher create scout-1 +if grep -F ' apply -f -' "$CALLS" >/dev/null; then + fail "crewmate create must never adopt a resource through apply" +fi +grep -Fqx 'kubectl -n agent-os-demo create -f -' "$CALLS" || \ + fail "crewmate resources must use create-only semantics when absent" +[ "$(grep -Fc 'kind: PersistentVolumeClaim' "$STDIN_LOG")" -eq 1 ] || fail "create must emit one PVC" +[ "$(grep -Fc 'kind: Pod' "$STDIN_LOG")" -eq 1 ] || fail "create must emit one Pod" +assert_grep 'agent-os.dev/crewmate: scout-1' "$STDIN_LOG" "child resources need the stable crewmate label" +assert_grep 'app.kubernetes.io/managed-by: agent-os' "$STDIN_LOG" \ + "child resources need the exact Agent OS ownership label" +assert_grep 'agent-os.dev/installation-id: agent-os-firstmate:agent-os-demo' "$STDIN_LOG" \ + "child resources need the exact installation identity" +assert_grep 'agent-os.dev/operation-id: operation-test' "$STDIN_LOG" \ + "each crewmate creation attempt must carry its unique operation identity" +assert_grep 'acquireTime:' "$STDIN_LOG" \ + "lifecycle Leases must record their acquisition time" +assert_grep 'renewTime:' "$STDIN_LOG" \ + "lifecycle Leases must record renewable expiry evidence" +fleet_lock_line=$(grep -Fn 'name: agent-os-firstmate-lifecycle' "$STDIN_LOG" | head -n 1 | cut -d: -f1) +crewmate_lock_line=$(grep -Fn 'name: agent-os-crewmate-scout-1-lifecycle' "$STDIN_LOG" | head -n 1 | cut -d: -f1) +[ -n "$fleet_lock_line" ] && [ -n "$crewmate_lock_line" ] && [ "$fleet_lock_line" -lt "$crewmate_lock_line" ] || \ + fail "crewmate mutation must enter the installation-wide barrier before its resource lock" +lease_holder=$(awk '/holderIdentity:/ { print $2; exit }' "$STDIN_LOG") +case "$lease_holder" in operation-test.*) ;; *) fail "lifecycle Lease identity must add an internal per-invocation nonce" ;; esac +pass "crewmate mutations share the installation-wide lifecycle barrier" +assert_grep 'automountServiceAccountToken: false' "$STDIN_LOG" "children must not receive Kubernetes credentials" +assert_grep 'claimName: agent-os-crewmate-scout-1-home' "$STDIN_LOG" "child work must use its own PVC" +assert_no_grep 'hostUsers: false' "$STDIN_LOG" "OrbStack children must not request unsupported Pod user namespaces" +assert_grep 'runAsUser: 0' "$STDIN_LOG" "children must run as container root" +assert_grep 'name: agent-os-init' "$STDIN_LOG" "children must seed persistent tools" +assert_no_grep 'mountPath: /usr/local' "$STDIN_LOG" "children must keep image-owned /usr/local immutable" +assert_grep 'mountPath: /var/run/secrets/agent-os/pi' "$STDIN_LOG" \ + "children must mount AI authorization outside writable Pi state" +assert_grep 'name: AGENT_OS_PI_AUTH_FILE' "$STDIN_LOG" \ + "children must expose the projected authorization path to the entrypoint" +assert_grep 'value: /var/run/secrets/agent-os/pi/auth.json' "$STDIN_LOG" \ + "the entrypoint must receive only the projected auth.json path" +assert_no_grep 'mountPath: /home/agent/.pi/agent' "$STDIN_LOG" \ + "the read-only Secret projection must not shadow writable Pi state" +assert_no_grep 'subPath: auth.json' "$STDIN_LOG" \ + "projected AI authorization must support Secret rotation without a subPath mount" +assert_grep 'path: auth.json' "$STDIN_LOG" \ + "the projected authorization directory must expose only the approved auth.json key" +assert_grep 'optional: false' "$STDIN_LOG" \ + "a missing Secret or auth.json key must keep the crewmate Pod unready" +assert_grep 'name: scout-1-ai-auth' "$STDIN_LOG" \ + "children must reference the explicitly selected namespace-local Secret" +assert_grep 'readOnly: true' "$STDIN_LOG" "child AI authorization must be read-only" +assert_grep 'readinessProbe:' "$STDIN_LOG" "child readiness must wait for Herdr health" +assert_grep 'herdr' "$STDIN_LOG" "child readiness must invoke Herdr" +assert_grep 'status' "$STDIN_LOG" "child readiness must inspect Herdr status" +grep -Fqx 'kubectl -n agent-os-demo wait --for=condition=Ready pod/agent-os-crewmate-scout-1 --timeout=180s' "$CALLS" || \ + fail "create must fail when the authorized Secret cannot produce a ready Pod" +pass "crewmate create emits one isolated Pod and PVC" + +: > "$CALLS" +if AGENT_OS_TEST_POD_STATE=foreign run_launcher create scout-1 >/dev/null 2>&1; then + fail "crewmate create must reject a same-name foreign Pod" +fi +if grep -F 'stdin-kind Pod' "$CALLS" >/dev/null; then + fail "crewmate create must reject foreign ownership before Pod creation" +fi +pass "crewmate create refuses foreign deterministic-name resources" + +: > "$CALLS" +if PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_STDIN_LOG="$STDIN_LOG" \ + AGENT_OS_IN_CLUSTER=1 AGENT_OS_NAMESPACE=agent-os-demo AGENT_OS_IMAGE=agent-os:local-test \ + "$LAUNCHER" create scout-1 >/dev/null 2>&1; then + fail "crewmate create must require an explicit AI Secret reference" +fi +[ ! -s "$CALLS" ] || fail "missing AI Secret reference must fail before kubectl" +pass "crewmate create requires an explicit AI Secret grant" + +: > "$CALLS" +if AGENT_OS_TEST_FAIL_WAIT=1 AGENT_OS_TEST_POD_AFTER_APPLY=owned run_launcher create scout-1 >/dev/null 2>&1; then + fail "crewmate create must fail when its authorized Secret cannot produce a ready Pod" +fi +grep -Fqx 'kubectl -n agent-os-demo --request-timeout=5s delete --raw /api/v1/namespaces/agent-os-demo/pods/agent-os-crewmate-scout-1 -f -' "$CALLS" || \ + fail "failed create must use an atomic UID-preconditioned delete" +assert_grep '"uid":"uid-owned"' "$STDIN_LOG" \ + "failed create must precondition deletion on the observed Pod UID" +if grep -F 'delete pvc agent-os-crewmate-scout-1-home' "$CALLS" >/dev/null; then + fail "failed create must retain the crewmate PVC for an authorized retry" +fi +pass "crewmate create fails closed while retaining its persistent home" + +: > "$CALLS" +pod_replacement_out='' +pod_replacement_rc=0 +pod_replacement_out=$(AGENT_OS_TEST_POD_AFTER_APPLY=replaced-after-ready \ + run_launcher create scout-1 2>&1) || pod_replacement_rc=$? +[ "$pod_replacement_rc" -eq 3 ] || \ + fail "post-ready Pod replacement must exit incomplete: $pod_replacement_out" +assert_contains "$pod_replacement_out" 'captured uid=uid-owned observed uid=uid-replacement' \ + "post-ready continuity failure must report both Pod identities" +if grep -F '/pods/agent-os-crewmate-scout-1' "$CALLS" | grep -F 'delete --raw' >/dev/null; then + fail "post-ready continuity failure must retain a same-name replacement Pod" +fi +pass "crewmate readiness verifies the original Pod identity" + +: > "$CALLS" +partial_out='' +partial_rc=0 +partial_out=$(AGENT_OS_TEST_FAIL_APPLY=1 AGENT_OS_TEST_POD_AFTER_APPLY=owned \ + run_launcher create scout-1 2>&1) || partial_rc=$? +[ "$partial_rc" -eq 1 ] || fail "partial apply must fail after cleanup: $partial_out" +assert_contains "$partial_out" 'uid-owned' \ + "partial apply cleanup must report the exact newly created Pod UID" +assert_grep 'metadata.uid' "$CALLS" \ + "partial apply cleanup must collect exact Pod UID evidence" +grep -Fqx 'kubectl -n agent-os-demo --request-timeout=5s delete --raw /api/v1/namespaces/agent-os-demo/pods/agent-os-crewmate-scout-1 -f -' "$CALLS" || \ + fail "partial apply cleanup must use an atomic UID-preconditioned delete" +assert_grep '"uid":"uid-owned"' "$STDIN_LOG" \ + "partial apply cleanup must bind deletion to the observed Pod UID" +assert_no_grep 'delete pvc agent-os-crewmate-scout-1-home' "$CALLS" \ + "partial apply cleanup must retain the persistent home" +pass "crewmate partial create cleans only a newly created owned Pod" + +: > "$CALLS" +AGENT_OS_TEST_PVC_STATE=clean run_launcher create scout-1 +writer_patch_line=$(grep -Fn 'patch pvc agent-os-crewmate-scout-1-home --type=merge' "$CALLS" | head -n 1 | cut -d: -f1) +writer_create_line=$(grep -Fn 'stdin-kind Pod' "$CALLS" | head -n 1 | cut -d: -f1) +[ -n "$writer_patch_line" ] && [ -n "$writer_create_line" ] && [ "$writer_patch_line" -lt "$writer_create_line" ] || \ + fail "every Pod start must invalidate clean checkpoint evidence before creation" +assert_grep '"agent-os.dev/checkpoint-state":"pending"' "$CALLS" \ + "writer activation must CAS the PVC to pending" +assert_grep '"agent-os.dev/writer-state":"active"' "$CALLS" \ + "writer activation must mark the retained PVC as active" +pass "crewmate starts invalidate quiesced checkpoint evidence" + +: > "$CALLS" +AGENT_OS_TEST_PVC_STATE=binder-rv run_launcher create scout-1 || \ + fail "normal PVC resourceVersion movement must not change stable UID identity" +pass "crewmate creation tracks PVC UID instead of binder resourceVersion" + +: > "$CALLS" +pvc_replaced_rc=0 +AGENT_OS_TEST_PVC_STATE=replaced-after-pod run_launcher create scout-1 >/dev/null 2>&1 || pvc_replaced_rc=$? +[ "$pvc_replaced_rc" -ne 0 ] || fail "PVC replacement after Pod creation must fail closed" +grep -F '/pods/agent-os-crewmate-scout-1' "$CALLS" | grep -F 'delete --raw' >/dev/null || \ + fail "PVC replacement must UID-delete only the newly created owned Pod" +pass "crewmate creation retains replacement claims and removes its Pod" + +: > "$CALLS" +after_ready_out='' +after_ready_rc=0 +after_ready_out=$(AGENT_OS_TEST_PVC_STATE=replaced-after-ready run_launcher create scout-1 2>&1) || after_ready_rc=$? +[ "$after_ready_rc" -eq 3 ] || fail "PVC replacement after Ready must exit incomplete: $after_ready_out" +assert_contains "$after_ready_out" 'uid-pvc-owned' \ + "post-Ready PVC mismatch must report the captured claim UID" +assert_contains "$after_ready_out" 'uid-pvc-after-ready' \ + "post-Ready PVC mismatch must report the observed replacement UID" +grep -F '/pods/agent-os-crewmate-scout-1' "$CALLS" | grep -F 'delete --raw' >/dev/null || \ + fail "post-Ready PVC mismatch must UID-delete only the operation Pod" +assert_grep '"agent-os.dev/checkpoint-state":"pending"' "$CALLS" \ + "post-Ready PVC mismatch must invalidate checkpoint evidence" +pass "crewmate revalidates mounted PVC identity after readiness" + +: > "$CALLS" +replacement_out='' +replacement_rc=0 +replacement_out=$(AGENT_OS_TEST_FAIL_APPLY=1 AGENT_OS_TEST_POD_AFTER_APPLY=replacement \ + run_launcher create scout-1 2>&1) || replacement_rc=$? +[ "$replacement_rc" -eq 1 ] || fail "replacement partial apply must remain failed: $replacement_out" +assert_contains "$replacement_out" 'replacement or ownership mismatch retained' \ + "partial apply must report a replacement instead of deleting it" +if grep -F '/pods/agent-os-crewmate-scout-1' "$CALLS" | grep -F 'delete --raw' >/dev/null; then + fail "partial apply must retain a same-name Pod from another operation" +fi + +: > "$CALLS" +AGENT_OS_TEST_POD_STATE=owned AGENT_OS_TEST_PVC_STATE=owned run_launcher stop scout-1 +grep -Fqx 'kubectl -n agent-os-demo --request-timeout=5s delete --raw /api/v1/namespaces/agent-os-demo/pods/agent-os-crewmate-scout-1 -f -' "$CALLS" || \ + fail "stop must UID-precondition deletion of the exactly owned crewmate Pod" +grep -F 'wait --for=delete pod/agent-os-crewmate-scout-1' "$CALLS" | grep -F -- '--request-timeout=' >/dev/null || \ + fail "stop must prove Pod absence within its total deletion deadline" +if grep -F 'wait --for=delete pod/agent-os-crewmate-scout-1' "$CALLS" | grep -F -- '--timeout=5s' >/dev/null; then + fail "stop must use the remaining operation deadline rather than the request ceiling for deletion polling" +fi +delete_line=$(grep -Fn '/pods/agent-os-crewmate-scout-1' "$CALLS" | grep 'delete --raw' | head -n 1 | cut -d: -f1) +invalidate_line=$(grep -Fn 'patch pvc agent-os-crewmate-scout-1-home --type=merge' "$CALLS" | head -n 1 | cut -d: -f1) +quiesced_line=$(grep -Fn 'patch pvc agent-os-crewmate-scout-1-home --type=merge' "$CALLS" | tail -n 1 | cut -d: -f1) +[ -n "$delete_line" ] && [ -n "$invalidate_line" ] && [ -n "$quiesced_line" ] && \ + [ "$invalidate_line" -lt "$delete_line" ] && [ "$delete_line" -lt "$quiesced_line" ] || \ + fail "stop must invalidate before deletion and record quiescence only after Pod absence" +if grep -F 'delete pvc agent-os-crewmate-scout-1-home' "$CALLS" >/dev/null; then + fail "stop must preserve the crewmate persistent home" +fi +pass "crewmate stop preserves its persistent home" + +: > "$CALLS" +ambiguous_stop_out='' +ambiguous_stop_rc=0 +ambiguous_stop_out=$(AGENT_OS_TEST_POD_STATE=owned AGENT_OS_TEST_PVC_STATE=owned \ + AGENT_OS_TEST_FAIL_DELETE_TARGET=/pods/agent-os-crewmate-scout-1 \ + AGENT_OS_TEST_DELETE_ERROR='context deadline exceeded' run_launcher stop scout-1 2>&1) || ambiguous_stop_rc=$? +[ "$ambiguous_stop_rc" -eq 0 ] || fail "accepted ambiguous Pod delete must reconcile absence: $ambiguous_stop_out" +assert_contains "$ambiguous_stop_out" 'confirmed absent: pod/agent-os-crewmate-scout-1 captured uid=uid-owned' \ + "ambiguous stop must report terminal absence evidence" +assert_grep 'agent-os.dev/quiesced-operation' "$CALLS" \ + "stop may record quiescence only after ambiguous deletion is reconciled as absent" +pass "crewmate stop reconciles ambiguous Pod deletion" + +: > "$CALLS" +replacement_stop_out='' +replacement_stop_rc=0 +replacement_stop_out=$(AGENT_OS_TEST_POD_STATE=owned AGENT_OS_TEST_POD_AFTER_DELETE=replacement \ + AGENT_OS_TEST_PVC_STATE=owned AGENT_OS_TEST_FAIL_DELETE_TARGET=/pods/agent-os-crewmate-scout-1 \ + AGENT_OS_TEST_DELETE_ERROR='context deadline exceeded' run_launcher stop scout-1 2>&1) || replacement_stop_rc=$? +[ "$replacement_stop_rc" -eq 3 ] || fail "ambiguous Pod replacement must exit incomplete: $replacement_stop_out" +assert_contains "$replacement_stop_out" 'replacement uid=uid-replacement retained; captured uid=uid-owned' \ + "ambiguous stop must retain and report a same-name replacement" +assert_no_grep 'agent-os.dev/writer-state":"quiesced' "$CALLS" \ + "stop must not record quiescence for a replacement Pod" +pass "crewmate stop retains ambiguous same-name replacements" + +: > "$CALLS" +if AGENT_OS_TEST_POD_STATE=owned AGENT_OS_TEST_PVC_STATE=clean AGENT_OS_TEST_FAIL_PVC_PATCH=1 \ + run_launcher stop scout-1 >/dev/null 2>&1; then + fail "stop must fail when checkpoint invalidation cannot be recorded" +fi +if grep -F '/pods/agent-os-crewmate-scout-1' "$CALLS" | grep -F 'delete --raw' >/dev/null; then + fail "stop must leave the Pod running when checkpoint invalidation fails" +fi +pass "crewmate stop invalidates checkpoint evidence before Pod deletion" + +: > "$CALLS" +AGENT_OS_TEST_POD_STATE=owned AGENT_OS_TEST_PVC_STATE=owned run_launcher restart scout-1 +delete_line=$(grep -Fn '/pods/agent-os-crewmate-scout-1' "$CALLS" | grep 'delete --raw' | head -n 1 | cut -d: -f1) +create_line=$(grep -Fn 'stdin-kind Pod' "$CALLS" | tail -n 1 | cut -d: -f1) +[ -n "$delete_line" ] && [ -n "$create_line" ] && [ "$delete_line" -lt "$create_line" ] || \ + fail "restart must replace the owned Pod on its retained PVC" +if grep -F 'delete pvc agent-os-crewmate-scout-1-home' "$CALLS" >/dev/null; then + fail "restart must preserve the crewmate persistent home" +fi +pass "crewmate restart replaces only its Pod" + +: > "$CALLS" +delete_out='' +delete_rc=0 +delete_out=$(run_launcher delete scout-1 2>&1) || delete_rc=$? +[ "$delete_rc" -eq 2 ] || fail "legacy delete must refuse destructive ambiguity: $delete_out" +[ ! -s "$CALLS" ] || fail "legacy delete must not mutate Pod or PVC state" +pass "legacy delete never silently destroys persistent work" + +: > "$CALLS" +purge_out='' +purge_rc=0 +purge_out=$(run_launcher purge scout-1 2>&1) || purge_rc=$? +[ "$purge_rc" -eq 2 ] || fail "purge without --yes must exit 2: $purge_out" +assert_contains "$purge_out" 'agent-os-crewmate-scout-1-home' \ + "unconfirmed purge must display the exact persistent target" +[ ! -s "$CALLS" ] || fail "unconfirmed purge must not query or mutate cluster state" + +: > "$CALLS" +if AGENT_OS_TEST_POD_STATE=owned AGENT_OS_TEST_PVC_STATE=clean run_launcher purge scout-1 --yes >/dev/null 2>&1; then + fail "purge must refuse a clean checkpoint while the owned Pod can still write" +fi +assert_no_grep 'delete pvc agent-os-crewmate-scout-1-home' "$CALLS" \ + "purge must not delete a home while its Pod still exists" + +: > "$CALLS" +if AGENT_OS_TEST_POD_STATE=owned AGENT_OS_TEST_PVC_STATE=owned run_launcher purge scout-1 --yes >/dev/null 2>&1; then + fail "purge must reject a persistent home without a clean checkpoint" +fi +if grep -F 'delete pvc' "$CALLS" >/dev/null; then + fail "purge must not delete a home without a clean checkpoint" +fi + +: > "$CALLS" +if AGENT_OS_TEST_POD_STATE=owned AGENT_OS_TEST_PVC_STATE=invalid-checkpoint run_launcher purge scout-1 --yes >/dev/null 2>&1; then + fail "purge must reject a malformed checkpoint timestamp" +fi +assert_no_grep 'delete pvc agent-os-crewmate-scout-1-home' "$CALLS" \ + "purge must not delete a home with malformed checkpoint evidence" + +: > "$CALLS" +if AGENT_OS_TEST_POD_STATE=absent AGENT_OS_TEST_PVC_STATE=stale-clean run_launcher purge scout-1 --yes >/dev/null 2>&1; then + fail "purge must reject checkpoint evidence from before the latest stop" +fi +assert_no_grep 'delete pvc agent-os-crewmate-scout-1-home' "$CALLS" \ + "purge must not delete a home using stale clean checkpoint evidence" + +: > "$CALLS" +: > "$PURGE_EVIDENCE" +AGENT_OS_TEST_POD_STATE=absent AGENT_OS_TEST_PVC_STATE=clean run_launcher purge scout-1 --yes +assert_no_grep 'delete pod agent-os-crewmate-scout-1' "$CALLS" \ + "purge must accept checkpoint evidence only after the Pod is absent" +grep -Fqx 'kubectl -n agent-os-demo --request-timeout=5s delete --raw /api/v1/namespaces/agent-os-demo/persistentvolumeclaims/agent-os-crewmate-scout-1-home -f -' "$CALLS" || \ + fail "purge must atomically delete the exactly owned persistent home" +assert_grep '"uid":"uid-pvc-owned","resourceVersion":"rv-pvc-owned"' "$STDIN_LOG" \ + "purge must precondition deletion on the captured PVC UID and resourceVersion" +assert_grep 'purge-complete' "$PURGE_EVIDENCE" "purge must record non-secret completion evidence" +assert_no_grep 'scout-1-ai-auth' "$PURGE_EVIDENCE" "purge evidence must never contain credential references" +pass "crewmate purge requires confirmation and a clean checkpoint" + +: > "$CALLS" +: > "$PURGE_EVIDENCE" +ambiguous_purge_out='' +ambiguous_purge_rc=0 +ambiguous_purge_out=$(AGENT_OS_TEST_POD_STATE=absent AGENT_OS_TEST_PVC_STATE=clean \ + AGENT_OS_TEST_PVC_AFTER_DELETE=absent \ + AGENT_OS_TEST_FAIL_DELETE_TARGET=/persistentvolumeclaims/agent-os-crewmate-scout-1-home \ + AGENT_OS_TEST_DELETE_ERROR='context deadline exceeded' run_launcher purge scout-1 --yes 2>&1) || ambiguous_purge_rc=$? +[ "$ambiguous_purge_rc" -eq 0 ] || fail "accepted ambiguous PVC delete must reconcile absence: $ambiguous_purge_out" +assert_grep 'purge-complete' "$PURGE_EVIDENCE" \ + "ambiguous purge accepted by the API must record terminal completion evidence" +assert_grep 'outcome=absent' "$PURGE_EVIDENCE" \ + "completed purge evidence must classify the captured PVC as absent" +assert_grep 'captured-uid=uid-pvc-owned' "$PURGE_EVIDENCE" \ + "completed purge evidence must retain the captured PVC UID" +assert_grep 'observed-uid=absent' "$PURGE_EVIDENCE" \ + "completed purge evidence must classify the captured PVC as absent" +assert_contains "$ambiguous_purge_out" 'confirmed absent: pvc/agent-os-crewmate-scout-1-home captured uid=uid-pvc-owned' \ + "ambiguous purge must report the captured PVC identity as absent" +pass "crewmate purge reconciles ambiguous PVC deletion" + +: > "$CALLS" +: > "$PURGE_EVIDENCE" +retained_purge_out='' +retained_purge_rc=0 +retained_purge_out=$(AGENT_OS_TEST_POD_STATE=absent AGENT_OS_TEST_PVC_STATE=clean \ + AGENT_OS_TEST_FAIL_DELETE_TARGET=/persistentvolumeclaims/agent-os-crewmate-scout-1-home \ + AGENT_OS_TEST_DELETE_ERROR='context deadline exceeded' run_launcher purge scout-1 --yes 2>&1) || retained_purge_rc=$? +[ "$retained_purge_rc" -eq 3 ] || fail "ambiguous retained PVC must exit incomplete: $retained_purge_out" +assert_contains "$retained_purge_out" 'original uid=uid-pvc-owned remains' \ + "ambiguous purge must report the retained original PVC identity" +assert_grep 'purge-incomplete-original-retained' "$PURGE_EVIDENCE" \ + "ambiguous retained PVC must record terminal incomplete evidence" +assert_grep 'outcome=original-retained' "$PURGE_EVIDENCE" \ + "ambiguous retained PVC must classify the original identity" +assert_grep 'captured-uid=uid-pvc-owned' "$PURGE_EVIDENCE" \ + "ambiguous retained PVC must retain its captured identity" +assert_grep 'observed-uid=uid-pvc-owned' "$PURGE_EVIDENCE" \ + "ambiguous retained PVC must record terminal incomplete evidence" +pass "crewmate purge records retained original PVC evidence" + +: > "$CALLS" +: > "$PURGE_EVIDENCE" +replacement_purge_out='' +replacement_purge_rc=0 +replacement_purge_out=$(AGENT_OS_TEST_POD_STATE=absent AGENT_OS_TEST_PVC_STATE=clean \ + AGENT_OS_TEST_PVC_AFTER_DELETE=foreign \ + AGENT_OS_TEST_FAIL_DELETE_TARGET=/persistentvolumeclaims/agent-os-crewmate-scout-1-home \ + AGENT_OS_TEST_DELETE_ERROR='context deadline exceeded' run_launcher purge scout-1 --yes 2>&1) || replacement_purge_rc=$? +[ "$replacement_purge_rc" -eq 3 ] || fail "ambiguous PVC replacement must exit incomplete: $replacement_purge_out" +assert_contains "$replacement_purge_out" 'replacement uid=uid-pvc-foreign retained; captured uid=uid-pvc-owned' \ + "ambiguous purge must retain and report a same-name replacement PVC" +assert_grep 'purge-incomplete-replacement-retained' "$PURGE_EVIDENCE" \ + "replacement PVC must record terminal incomplete evidence" +assert_grep 'outcome=replacement-retained' "$PURGE_EVIDENCE" \ + "replacement PVC must classify its terminal outcome" +assert_grep 'captured-uid=uid-pvc-owned' "$PURGE_EVIDENCE" \ + "replacement PVC must retain the captured UID" +assert_grep 'observed-uid=uid-pvc-foreign' "$PURGE_EVIDENCE" \ + "replacement PVC must record terminal incomplete evidence" +pass "crewmate purge retains ambiguous same-name replacements" + +: > "$CALLS" +if AGENT_OS_TEST_POD_STATE=absent AGENT_OS_TEST_PVC_STATE=clean AGENT_OS_TEST_PVC_ATTACHED=1 \ + run_launcher purge scout-1 --yes >/dev/null 2>&1; then + fail "purge must refuse a PVC still referenced by any Pod" +fi +if grep -F '/persistentvolumeclaims/agent-os-crewmate-scout-1-home' "$CALLS" | grep -F 'delete --raw' >/dev/null; then + fail "purge must retain an attached persistent home" +fi +pass "crewmate purge rejects attached persistent homes" + +: > "$CALLS" +if AGENT_OS_TEST_POD_STATE=absent AGENT_OS_TEST_PVC_STATE=clean AGENT_OS_TEST_FAIL_POD_LIST=1 \ + run_launcher purge scout-1 --yes >/dev/null 2>&1; then + fail "purge must fail closed when Pod attachments cannot be inventoried" +fi +if grep -F '/persistentvolumeclaims/agent-os-crewmate-scout-1-home' "$CALLS" | grep -F 'delete --raw' >/dev/null; then + fail "purge must retain the PVC after an attachment inventory failure" +fi +pass "crewmate purge fails closed on attachment inventory errors" + +: > "$CALLS" +lock_out='' +lock_rc=0 +lock_out=$(AGENT_OS_TEST_LOCK_STATE=held run_launcher stop scout-1 2>&1) || lock_rc=$? +[ "$lock_rc" -eq 3 ] || fail "bounded lifecycle lock contention must exit incomplete: $lock_out" +assert_contains "$lock_out" "still holds Lease 'agent-os-crewmate-scout-1-lifecycle' after 3s" \ + "lifecycle contention must report the exact holder and bounded timeout" +pass "crewmate lifecycle operations use a bounded coordination lock" + +: > "$CALLS" +expired_lock_rc=0 +expired_lock_out=$(AGENT_OS_TEST_LOCK_STATE=expired AGENT_OS_TEST_PVC_STATE=owned run_launcher stop scout-1 2>&1) || expired_lock_rc=$? +[ "$expired_lock_rc" -eq 0 ] || fail "expired exact-owned lifecycle Lease takeover must complete cleanly: $expired_lock_out" +grep -F 'replace -f -' "$CALLS" >/dev/null || \ + fail "expired exact-owned lifecycle Leases must use resourceVersion-CAS takeover" +pass "crewmate lifecycle can recover exact-owned expired Leases" + +: > "$CALLS" +ambiguous_lock_out='' +ambiguous_lock_rc=0 +ambiguous_lock_out=$(AGENT_OS_TEST_LOCK_STATE=ambiguous-create AGENT_OS_TEST_PVC_STATE=owned \ + run_launcher stop scout-1 2>&1) || ambiguous_lock_rc=$? +[ "$ambiguous_lock_rc" -eq 0 ] || \ + fail "ambiguous Lease create must accept verified own-holder read-back: $ambiguous_lock_out" +pass "crewmate Lease acquisition reconciles ambiguous create success" + +: > "$CALLS" +: > "$STDIN_LOG" +AGENT_OS_TEST_LOCK_STATE=expired AGENT_OS_TEST_LOCK_RV=12345 AGENT_OS_TEST_PVC_STATE=owned \ + run_launcher stop scout-1 +assert_grep 'resourceVersion: "12345"' "$STDIN_LOG" \ + "Lease takeover must serialize decimal resourceVersion as a string" +pass "crewmate Lease CAS preserves opaque resourceVersion typing" + +: > "$CALLS" +AGENT_OS_LOCK_DURATION_SECONDS=10 AGENT_OS_TEST_READY_DELAY=4 \ + AGENT_OS_TEST_PVC_STATE=owned run_launcher create scout-1 +grep -F 'replace -f -' "$CALLS" >/dev/null || \ + fail "active lifecycle operations must renew their exact-owned Lease" +pass "crewmate lifecycle renews its Lease while active" + +: > "$CALLS" +renewal_rc=0 +AGENT_OS_LOCK_DURATION_SECONDS=10 AGENT_OS_TEST_READY_DELAY=4 \ + AGENT_OS_TEST_RENEW_READBACK=wrong AGENT_OS_TEST_PVC_STATE=owned \ + run_launcher create scout-1 >/dev/null 2>&1 || renewal_rc=$? +[ "$renewal_rc" -eq 3 ] || fail "renewal must fail closed when exact renewTime read-back is not committed" +pass "crewmate Lease renewal verifies the committed renewTime" + +: > "$CALLS" +AGENT_OS_TEST_POD_STATE=absent AGENT_OS_TEST_PVC_STATE=owned run_launcher stop scout-1 +lease_create_call=$(grep 'create -f -' "$CALLS" | head -n 1) +assert_contains "$lease_create_call" '--request-timeout=2s' \ + "Lease create must reserve acquisition time for mandatory result reconciliation" +pass "crewmate Lease mutations reserve read-back budget" + +lease_call_without_timeout=$(awk ' + /^kubectl / { call=$0 } + / get lease | delete --raw .*\/leases\// { if ($0 !~ /--request-timeout=/) print } + /^stdin-kind Lease$/ { if (call !~ /--request-timeout=/) print call } +' "$CALLS") +[ -z "$lease_call_without_timeout" ] || \ + fail "every Lease request must carry a bounded request timeout: $lease_call_without_timeout" +pass "crewmate Lease requests are bounded within lock validity" + +: > "$CALLS" +AGENT_OS_TEST_POD_STATE=absent AGENT_OS_TEST_PVC_STATE=owned \ + AGENT_OS_TEST_LOCK_RELEASE_STATE=foreign run_launcher stop scout-1 +pass "crewmate Lease release accepts an independently replaced Lease" + +: > "$CALLS" +AGENT_OS_TEST_POD_STATE=absent AGENT_OS_TEST_PVC_STATE=owned \ + AGENT_OS_TEST_LOCK_RELEASE_STATE=next-owner run_launcher stop scout-1 +pass "crewmate Lease release accepts a subsequent legitimate holder" + +if run_launcher create 'Bad_ID' >/dev/null 2>&1; then + fail "invalid Kubernetes crewmate IDs must be rejected" +fi +pass "crewmate IDs are validated before kubectl" + +: > "$CALLS" +if run_launcher create 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' >/dev/null 2>&1; then + fail "crewmate IDs that overflow a derived Lease name must be rejected" +fi +[ ! -s "$CALLS" ] || fail "derived Kubernetes names must be validated before kubectl" +pass "all crewmate-derived Kubernetes names are validated before cluster calls" + +if PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_STDIN_LOG="$STDIN_LOG" \ + AGENT_OS_NAMESPACE=agent-os-demo AGENT_OS_IMAGE=agent-os:local-test "$LAUNCHER" status scout-1 >/dev/null 2>&1; then + fail "host execution without an explicit context must be rejected" +fi +pass "launcher refuses an ambient host Kubernetes context" + +: > "$CALLS" +PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_STDIN_LOG="$STDIN_LOG" \ + AGENT_OS_CONTEXT=orbstack AGENT_OS_NAMESPACE=agent-os-demo AGENT_OS_IMAGE=agent-os:local-test \ + AGENT_OS_TEST_POD_STATE=owned AGENT_OS_TEST_PVC_STATE=owned "$LAUNCHER" status scout-1 +grep -Fqx 'kubectl --context orbstack -n agent-os-demo get pod agent-os-crewmate-scout-1' "$CALLS" || \ + fail "host status must pin the selected context" +pass "host launcher calls require and pin an explicit context" + +GENERIC="$ROOT/bin/agent-os-kubernetes.sh" +GENERIC_INPUTS="$TMP/portable-inputs.yaml" + +cat > "$GENERIC_INPUTS" <<'YAML' +namespace: portable-agent-os +image: ghcr.io/akua-dev/agent-os@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +imagePullPolicy: IfNotPresent +rbac: namespace +storage: 20Gi +YAML + +cat > "$FAKEBIN/akua" <<'SH' +#!/usr/bin/env bash +printf 'akua' >> "$AGENT_OS_TEST_LOG" +printf ' %s' "$@" >> "$AGENT_OS_TEST_LOG" +printf '\n' >> "$AGENT_OS_TEST_LOG" +inputs='' +out='' +while [ "$#" -gt 0 ]; do + case "$1" in + --inputs) inputs=$2; shift 2 ;; + --out) out=$2; shift 2 ;; + *) shift ;; + esac +done +mkdir -p "$out" +rbac=$(awk '/^rbac:/{print $2}' "$inputs") +namespace=$(awk '/^namespace:/{print $2}' "$inputs") +operation=$(awk '/^operationId:/{print $2}' "$inputs") +service_account=$(awk '/^serviceAccountName:/{print $2}' "$inputs") +[ -n "$service_account" ] || service_account=agent-os-firstmate +printf 'akua-input-operation %s\n' "$operation" >> "$AGENT_OS_TEST_LOG" +printf 'akua-input-service-account %s\n' "$service_account" >> "$AGENT_OS_TEST_LOG" +create_namespace=$(awk '/^createNamespace:/{print $2}' "$inputs") +[ -n "$create_namespace" ] || create_namespace=true +cat > "$out/00-pvc.yaml" < "$out/statefulset.yaml" < "$out/$file.yaml" < "$out/namespace.yaml" < "$out/$file.yaml" < "$out/clusterrolebinding.yaml" < "$CALLS" +run_generic install +grep -Fq -- "akua render --no-agent-mode --package $ROOT/tools/agent-os/packages/firstmate/package.k --inputs " "$CALLS" || \ + fail "generic install must render the canonical package before applying it" +if grep -F 'kubectl --context kind-agent-os apply -f ' "$CALLS" >/dev/null; then + fail "generic install must not adopt same-name resources through apply" +fi +grep -F 'kubectl --context kind-agent-os create -f ' "$CALLS" >/dev/null || \ + fail "generic install must create absent rendered resources atomically" +grep -F 'agent-os-firstmate-lifecycle' "$STDIN_LOG" >/dev/null || \ + fail "primary mutations must hold an exact-owned Kubernetes Lease" +grep -Fqx 'akua-input-operation operation-test' "$CALLS" || \ + fail "generic install must label every resource with its unique operation identity" +generated_service_account=$(grep 'akua-input-service-account ' "$CALLS" | tail -n 1 | awk '{print $2}') +case "$generated_service_account" in agent-os-firstmate-[a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9]) ;; \ + *) fail "install must use a fresh non-legacy ServiceAccount identity" ;; esac +grep -Fqx 'kubectl --context kind-agent-os -n portable-agent-os rollout status statefulset/agent-os-firstmate --timeout=180s' "$CALLS" || \ + fail "generic install must wait for the rendered Firstmate StatefulSet: $(tail -n 20 "$CALLS" | tr '\n' ';')" +if grep -F 'delete clusterrolebinding' "$CALLS" >/dev/null; then + fail "fresh namespace-scoped install must not require cluster RBAC deletion authority" +fi +pass "generic install serializes create-only canonical package mutations" + +: > "$CALLS" +stale_install_out='' +stale_install_rc=0 +stale_install_out=$(AGENT_OS_TEST_CLUSTER_RBAC_STATE=owned run_generic install 2>&1) || stale_install_rc=$? +[ "$stale_install_rc" -eq 3 ] || fail "stale cluster authority must block a namespace reinstall: $stale_install_out" +assert_contains "$stale_install_out" 'must be removed through separately authorized cleanup' \ + "namespace reinstall must not reactivate a deterministic legacy cluster grant" +assert_no_grep 'create -f .*serviceaccount.yaml' "$CALLS" \ + "legacy cluster authority must fail before a new ServiceAccount is created" +pass "fresh installs preflight deterministic legacy cluster authority" + +PARTIAL_CLUSTER_INPUTS="$TMP/partial-cluster-inputs.yaml" +sed 's/rbac: namespace/rbac: cluster-admin/' "$GENERIC_INPUTS" > "$PARTIAL_CLUSTER_INPUTS" +: > "$CALLS" +partial_primary_out='' +partial_primary_rc=0 +partial_primary_out=$(PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" \ + AGENT_OS_INPUTS="$PARTIAL_CLUSTER_INPUTS" AGENT_OS_TEST_NAMESPACE=portable-agent-os \ + AGENT_OS_TEST_NAMESPACE_STATE=absent AGENT_OS_TEST_WORKLOAD_STATE=absent \ + AGENT_OS_TEST_NAMESPACE_AFTER_APPLY=owned \ + AGENT_OS_TEST_WORKLOAD_AFTER_APPLY=cluster-admin \ + AGENT_OS_TEST_RESOURCE_AFTER_APPLY=owned AGENT_OS_TEST_CLUSTER_RBAC_AFTER_APPLY=owned \ + AGENT_OS_TEST_FAIL_ROLLOUT=1 AGENT_OS_OPERATION_ID=operation-test \ + AGENT_OS_CONTEXT=kind-agent-os AGENT_OS_NAMESPACE=portable-agent-os \ + "$GENERIC" install 2>&1) || partial_primary_rc=$? +[ "$partial_primary_rc" -eq 3 ] || \ + fail "partial primary apply must exit incomplete with 3: $partial_primary_out" +assert_contains "$partial_primary_out" 'partial apply: StatefulSet/agent-os-firstmate' \ + "failed primary apply must inventory expected namespaced resources" +assert_contains "$partial_primary_out" 'uid=uid-statefulset operation=operation-test desired=1 current=1 ready=0' \ + "failed primary apply must report UID, operation identity, and StatefulSet readiness" +assert_contains "$partial_primary_out" 'residual-authority: ClusterRoleBinding/agent-os-firstmate-portable-agent-os' \ + "failed cluster-admin apply must report the exact residual grant" +assert_contains "$partial_primary_out" 'safe recovery:' \ + "failed primary apply must print a bounded exact recovery command" +assert_contains "$partial_primary_out" 'agent-os-kubernetes.sh upgrade' \ + "failed install with an exact-owned StatefulSet must recover through upgrade" +assert_not_contains "$partial_primary_out" 'cleanup-cluster-rbac --yes' \ + "active cluster-admin authority must not advertise an inapplicable cleanup command" +assert_contains "$partial_primary_out" 'desired=1 current=1 ready=0 updated=0 available=0' \ + "failed StatefulSet rollout must report replica readiness" +assert_contains "$partial_primary_out" 'current-revision=rev-old update-revision=rev-new generation=2 observed-generation=1' \ + "failed StatefulSet rollout must report revision and generation progress" +pass "primary partial apply reports residual resources and authority" + +: > "$CALLS" +namespace_partial_out='' +namespace_partial_rc=0 +namespace_partial_out=$(AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_NAMESPACE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_AFTER_APPLY=pending \ + AGENT_OS_TEST_RESOURCE_AFTER_APPLY=owned \ + AGENT_OS_TEST_CLUSTER_RBAC_AFTER_APPLY=owned AGENT_OS_TEST_FAIL_ROLLOUT=1 \ + run_generic upgrade 2>&1) || namespace_partial_rc=$? +[ "$namespace_partial_rc" -eq 3 ] || \ + fail "namespace partial upgrade must exit incomplete with 3: $namespace_partial_out" +assert_contains "$namespace_partial_out" \ + 'residual-authority: ClusterRoleBinding/agent-os-firstmate-portable-agent-os' \ + "every failed primary mutation must inspect the deterministic cluster grant" +assert_contains "$namespace_partial_out" 'cleanup-cluster-rbac --yes' \ + "a failed namespaced mutation with residual authority must print exact cleanup" +pass "namespace partial apply reports stale cluster authority" + +: > "$CALLS" +conflicted_recovery_out='' +conflicted_recovery_rc=0 +conflicted_recovery_out=$(AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_NAMESPACE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_AFTER_APPLY=namespace AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_RESOURCE_AFTER_APPLY=foreign AGENT_OS_TEST_FAIL_ROLLOUT=1 \ + run_generic upgrade 2>&1) || conflicted_recovery_rc=$? +[ "$conflicted_recovery_rc" -eq 3 ] || \ + fail "conflicted partial recovery must remain incomplete: $conflicted_recovery_out" +assert_contains "$conflicted_recovery_out" 'safe recovery unavailable:' \ + "partial recovery must reject conflicts across the complete rendered resource set" +assert_not_contains "$conflicted_recovery_out" 'agent-os-kubernetes.sh upgrade' \ + "partial recovery must not advertise an upgrade that its preflight rejects" +pass "primary recovery reuses complete rendered ownership predicates" + +: > "$CALLS" +owned_namespace_out='' +owned_namespace_rc=0 +owned_namespace_out=$(AGENT_OS_TEST_NAMESPACE_STATE=foreign run_generic install 2>&1) || owned_namespace_rc=$? +[ "$owned_namespace_rc" -eq 2 ] || \ + fail "install into an existing unowned namespace must exit 2, got $owned_namespace_rc: $owned_namespace_out" +if grep -F 'kubectl --context kind-agent-os apply' "$CALLS" >/dev/null; then + fail "install must reject an unowned existing namespace before apply" +fi +pass "createNamespace install refuses implicit namespace adoption" + +UNOWNED_INPUTS="$TMP/unowned-namespace-inputs.yaml" +awk '{ print; if ($1 == "namespace:") print "createNamespace: false" }' "$GENERIC_INPUTS" > "$UNOWNED_INPUTS" +: > "$CALLS" +PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_INPUTS="$UNOWNED_INPUTS" \ + AGENT_OS_TEST_NAMESPACE=portable-agent-os AGENT_OS_TEST_NAMESPACE_STATE=unowned \ + AGENT_OS_TEST_WORKLOAD_STATE=absent AGENT_OS_CONTEXT=kind-agent-os \ + AGENT_OS_NAMESPACE=portable-agent-os "$GENERIC" install +grep -E 'kubectl --context kind-agent-os (create -f|.* patch )' "$CALLS" >/dev/null || \ + fail "createNamespace=false must install into a pre-existing unowned namespace" +pass "createNamespace=false requires and preserves an unowned namespace" + +: > "$CALLS" +foreign_resource_install_rc=0 +AGENT_OS_TEST_NAMESPACE_STATE=unowned AGENT_OS_TEST_RESOURCE_STATE=foreign \ + PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_INPUTS="$UNOWNED_INPUTS" \ + AGENT_OS_TEST_NAMESPACE=portable-agent-os AGENT_OS_TEST_WORKLOAD_STATE=absent \ + AGENT_OS_CONTEXT=kind-agent-os AGENT_OS_NAMESPACE=portable-agent-os \ + "$GENERIC" install >/dev/null 2>&1 || foreign_resource_install_rc=$? +[ "$foreign_resource_install_rc" -eq 2 ] || \ + fail "install must reject same-name foreign namespaced resources" +if grep -F ' apply -f ' "$CALLS" >/dev/null; then + fail "foreign namespaced ownership must fail before package apply" +fi +pass "install refuses foreign deterministic-name resources" + +: > "$CALLS" +owned_unmanaged_out='' +owned_unmanaged_rc=0 +owned_unmanaged_out=$(PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_INPUTS="$UNOWNED_INPUTS" \ + AGENT_OS_TEST_NAMESPACE=portable-agent-os AGENT_OS_TEST_NAMESPACE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=absent AGENT_OS_CONTEXT=kind-agent-os \ + AGENT_OS_NAMESPACE=portable-agent-os "$GENERIC" install 2>&1) || owned_unmanaged_rc=$? +[ "$owned_unmanaged_rc" -eq 2 ] || \ + fail "createNamespace=false must reject an Agent-OS-owned namespace: $owned_unmanaged_out" +pass "createNamespace=false refuses an owned namespace" + +: > "$CALLS" +mismatch_out='' +mismatch_rc=0 +mismatch_out=$(PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_INPUTS="$GENERIC_INPUTS" \ + AGENT_OS_TEST_NAMESPACE=portable-agent-os AGENT_OS_CONTEXT=kind-agent-os \ + AGENT_OS_NAMESPACE=wrong-namespace "$GENERIC" install 2>&1) || mismatch_rc=$? +[ "$mismatch_rc" -eq 2 ] || fail "namespace mismatch must exit 2, got $mismatch_rc: $mismatch_out" +if grep -F '^kubectl ' "$CALLS" >/dev/null; then + fail "namespace mismatch must fail before any kubectl mutation" +fi +pass "rendered namespace is authoritative over an inconsistent environment" + +missing_akua_out='' +missing_akua_rc=0 +missing_akua_out=$(PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_INPUTS="$GENERIC_INPUTS" \ + AGENT_OS_AKUA=missing-agent-os-akua AGENT_OS_CONTEXT=kind-agent-os AGENT_OS_NAMESPACE=portable-agent-os \ + "$GENERIC" install 2>&1) || missing_akua_rc=$? +[ "$missing_akua_rc" -eq 2 ] || fail "Kubernetes package operations must reject a missing renderer with exit 2" +assert_contains "$missing_akua_out" "error: Akua renderer 'missing-agent-os-akua' is required for Kubernetes package operations" \ + "Kubernetes package operations must explain their optional renderer dependency" +pass "Kubernetes package operations require the optional Akua renderer explicitly" + +: > "$CALLS" +cleanup_out='' +cleanup_rc=0 +cleanup_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=cluster-admin \ + run_generic upgrade 2>&1) || cleanup_rc=$? +[ "$cleanup_rc" -eq 3 ] || \ + fail "cluster-admin downgrade must stop for privileged cleanup with exit 3, got $cleanup_rc: $cleanup_out" +apply_line=$(grep -Fn -- '--patch-file ' "$CALLS" | head -n 1 | cut -d: -f1) +marker_line=$(grep -Fn 'patch StatefulSet agent-os-firstmate' "$CALLS" | grep 'cluster-rbac-cleanup.*required' | head -n 1 | cut -d: -f1) +rollout_line=$(grep -Fn 'kubectl --context kind-agent-os -n portable-agent-os rollout status' "$CALLS" | head -n 1 | cut -d: -f1) +verify_line=$(grep -Fn 'kubectl --context kind-agent-os -n portable-agent-os get role agent-os-firstmate-runtime' "$CALLS" | head -n 1 | cut -d: -f1) +[ -n "$marker_line" ] && [ -n "$apply_line" ] && [ -n "$rollout_line" ] && [ -n "$verify_line" ] && \ + [ "$marker_line" -lt "$apply_line" ] && [ "$apply_line" -lt "$rollout_line" ] && \ + [ "$rollout_line" -lt "$verify_line" ] || \ + fail "downgrade must apply and roll out desired namespaced RBAC before privileged cleanup is requested" +assert_grep 'get clusterrolebinding agent-os-firstmate-portable-agent-os --ignore-not-found' "$CALLS" \ + "namespace upgrades must perform the separately authorized deterministic legacy-grant preflight" +assert_no_grep 'delete clusterrolebinding' "$CALLS" \ + "routine namespace upgrades must not delete cluster-scoped authority" +assert_contains "$cleanup_out" 'cleanup-cluster-rbac --yes' \ + "downgrade must print the exact separately confirmed privileged cleanup command" +assert_contains "$cleanup_out" 'clusterrolebinding/agent-os-firstmate-portable-agent-os absent' \ + "downgrade must print the required cleanup evidence" +pass "cluster-admin downgrade applies desired RBAC before requiring privileged cleanup" + +: > "$CALLS" +marker_failure_rc=0 +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=cluster-admin \ + AGENT_OS_TEST_FAIL_ANNOTATE=1 run_generic upgrade >/dev/null 2>&1 || marker_failure_rc=$? +[ "$marker_failure_rc" -ne 0 ] || fail "downgrade must fail if its durable cleanup marker cannot be recorded" +if grep -F -- '--patch-file ' "$CALLS" >/dev/null || grep -E ' create -f .+\.yaml' "$CALLS" >/dev/null; then + fail "downgrade must record its durable cleanup marker before changing the workload RBAC mode" +fi +pass "cluster-admin downgrade records cleanup state before mutation" + +: > "$CALLS" +tainted_rbac_rc=0 +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=cluster-admin \ + AGENT_OS_TEST_RBAC_STATE=extra-subject run_generic upgrade >/dev/null 2>&1 || tainted_rbac_rc=$? +[ "$tainted_rbac_rc" -eq 2 ] || \ + fail "downgrade must reject a RoleBinding with extra subjects, got $tainted_rbac_rc" +pass "replacement RBAC verification requires exact subject cardinality" + +: > "$CALLS" +bad_rules_rc=0 +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=cluster-admin \ + AGENT_OS_TEST_RBAC_STATE=bad-rules run_generic upgrade >/dev/null 2>&1 || bad_rules_rc=$? +[ "$bad_rules_rc" -eq 2 ] || \ + fail "downgrade must reject a Role without the exact runtime rules, got $bad_rules_rc" +pass "replacement RBAC verification requires exact rules" + +: > "$CALLS" +if AGENT_OS_TEST_CLUSTER_RBAC_STATE=owned run_generic cleanup-cluster-rbac >/dev/null 2>&1; then + fail "privileged cluster RBAC cleanup must require --yes" +fi +[ ! -s "$CALLS" ] || fail "unconfirmed cluster RBAC cleanup invoked an external command" + +: > "$CALLS" +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=pending \ + AGENT_OS_TEST_CLUSTER_RBAC_STATE=owned run_generic cleanup-cluster-rbac --yes +grep -Fq 'kubectl --context kind-agent-os get clusterrolebinding agent-os-firstmate-portable-agent-os --ignore-not-found' "$CALLS" || \ + fail "privileged cleanup must inspect only the exact stale ClusterRoleBinding" +grep -F '/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/agent-os-firstmate-portable-agent-os' "$CALLS" | grep -F 'delete --raw' >/dev/null || \ + fail "privileged cleanup must UID-delete only the exact owned ClusterRoleBinding" +assert_grep '"uid":"uid-clusterrolebinding","resourceVersion":"rv-clusterrolebinding"' "$STDIN_LOG" \ + "privileged cleanup must bind deletion to the observed grant identity" +grep -F 'wait --for=delete ClusterRoleBinding/agent-os-firstmate-portable-agent-os --timeout=' "$CALLS" >/dev/null || \ + fail "privileged cleanup must produce deletion evidence for the exact binding" +grep -F 'wait --for=delete ClusterRoleBinding/agent-os-firstmate-portable-agent-os --timeout=' "$CALLS" | \ + grep -F -- '--request-timeout=' >/dev/null || \ + fail "delete wait must reserve five seconds for bounded identity reconciliation" +pass "privileged cleanup verifies ownership and deletes one exact binding" + +cleanup_delete_call=$(grep -F '/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/agent-os-firstmate-portable-agent-os' "$CALLS" | \ + grep -F 'delete --raw' | tail -n 1) +assert_contains "$cleanup_delete_call" '--request-timeout=' \ + "raw delete requests must be bounded before the operation wait begins" +cleanup_observe_call=$(grep 'get ClusterRoleBinding agent-os-firstmate-portable-agent-os --ignore-not-found' "$CALLS" | \ + grep 'metadata.resourceVersion' | tail -n 1) +assert_contains "$cleanup_observe_call" '--request-timeout=' \ + "cluster cleanup deletion deadline must start before the ownership observation" + +: > "$CALLS" +cleanup_stale_out='' +cleanup_stale_rc=0 +cleanup_stale_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=pending \ + AGENT_OS_TEST_CLUSTER_RBAC_STATE=owned AGENT_OS_TEST_STALE_RBAC_STATE=foreign \ + run_generic cleanup-cluster-rbac --yes 2>&1) || cleanup_stale_rc=$? +[ "$cleanup_stale_rc" -eq 2 ] || fail "privileged cleanup must preflight foreign stale RBAC: $cleanup_stale_out" +assert_no_grep '/clusterrolebindings/agent-os-firstmate-portable-agent-os' "$CALLS" \ + "privileged cleanup must reject foreign namespaced RBAC before cluster mutation" +pass "privileged cleanup preflights deterministic namespaced RBAC" + +: > "$CALLS" +cleanup_delete_out='' +cleanup_delete_rc=0 +cleanup_delete_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=pending \ + AGENT_OS_TEST_CLUSTER_RBAC_STATE=owned \ + AGENT_OS_TEST_FAIL_DELETE_TARGET=clusterrolebindings/agent-os-firstmate-portable-agent-os \ + AGENT_OS_TEST_DELETE_ERROR='Error from server (Conflict): object changed' \ + run_generic cleanup-cluster-rbac --yes 2>&1) || cleanup_delete_rc=$? +[ "$cleanup_delete_rc" -eq 1 ] || fail "privileged cleanup delete conflict must exit failed: $cleanup_delete_out" +assert_contains "$cleanup_delete_out" 'delete-request-failure=Conflict' \ + "privileged cleanup must preserve immediate delete failure class" +assert_contains "$cleanup_delete_out" 'timeout=60s' \ + "privileged cleanup must report its actual deletion bound" +assert_contains "$cleanup_delete_out" 'retained: ClusterRoleBinding/agent-os-firstmate-portable-agent-os uid=uid-clusterrolebinding' \ + "privileged cleanup failure must inventory the retained exact grant" +assert_contains "$cleanup_delete_out" 'safe retry:' \ + "privileged cleanup failure must print exact retry evidence" +pass "privileged cleanup failures report retained grant evidence" + +: > "$CALLS" +not_found_out='' +not_found_rc=0 +not_found_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=pending \ + AGENT_OS_TEST_CLUSTER_RBAC_STATE=owned AGENT_OS_TEST_DELETE_READBACK_STATE=absent \ + AGENT_OS_TEST_FAIL_DELETE_TARGET=clusterrolebindings/agent-os-firstmate-portable-agent-os \ + AGENT_OS_TEST_DELETE_ERROR='Error from server (NotFound): object disappeared' \ + run_generic cleanup-cluster-rbac --yes 2>&1) || not_found_rc=$? +[ "$not_found_rc" -eq 0 ] || fail "NotFound with confirmed absence must complete: $not_found_out" +assert_contains "$not_found_out" 'clusterrolebinding/agent-os-firstmate-portable-agent-os absent' \ + "NotFound reconciliation must emit confirmed absence evidence" +pass "delete NotFound races reconcile confirmed absence" + +: > "$CALLS" +replacement_delete_out='' +replacement_delete_rc=0 +replacement_delete_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=pending \ + AGENT_OS_TEST_CLUSTER_RBAC_STATE=owned AGENT_OS_TEST_DELETE_READBACK_STATE=replacement \ + AGENT_OS_TEST_FAIL_DELETE_TARGET=clusterrolebindings/agent-os-firstmate-portable-agent-os \ + AGENT_OS_TEST_DELETE_ERROR='transport timeout' \ + run_generic cleanup-cluster-rbac --yes 2>&1) || replacement_delete_rc=$? +[ "$replacement_delete_rc" -eq 1 ] || \ + fail "ambiguous delete with replacement must remain incomplete: $replacement_delete_out" +assert_contains "$replacement_delete_out" 'replacement uid=uid-clusterrolebinding-replacement retained' \ + "ambiguous delete reconciliation must report and retain a replacement UID" +pass "ambiguous deletes retain same-name replacements" + +: > "$CALLS" +active_cleanup_rc=0 +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=cluster-admin \ + AGENT_OS_TEST_CLUSTER_RBAC_STATE=owned run_generic cleanup-cluster-rbac --yes >/dev/null 2>&1 || \ + active_cleanup_rc=$? +[ "$active_cleanup_rc" -eq 2 ] || \ + fail "privileged cleanup must refuse an active cluster-admin grant, got $active_cleanup_rc" +if grep -F 'delete clusterrolebinding' "$CALLS" >/dev/null; then + fail "cleanup must not revoke an active cluster-admin installation" +fi +pass "privileged cleanup refuses active cluster-admin authority" + +: > "$CALLS" +absent_binding_out=$(AGENT_OS_TEST_NAMESPACE_STATE=absent AGENT_OS_TEST_WORKLOAD_STATE=absent \ + AGENT_OS_TEST_CLUSTER_RBAC_STATE=absent run_generic cleanup-cluster-rbac --yes) +assert_contains "$absent_binding_out" 'clusterrolebinding/agent-os-firstmate-portable-agent-os absent' \ + "privileged cleanup must accept exact absence as completion evidence" +if grep -F 'delete clusterrolebinding' "$CALLS" >/dev/null; then + fail "privileged cleanup must not delete an already absent ClusterRoleBinding" +fi +pass "privileged cleanup records absence after namespace deletion" + +: > "$CALLS" +foreign_binding_out='' +foreign_binding_rc=0 +foreign_binding_out=$(AGENT_OS_TEST_CLUSTER_RBAC_STATE=foreign run_generic cleanup-cluster-rbac --yes 2>&1) || \ + foreign_binding_rc=$? +[ "$foreign_binding_rc" -eq 2 ] || fail "foreign ClusterRoleBinding cleanup must exit 2: $foreign_binding_out" +if grep -F 'delete clusterrolebinding' "$CALLS" >/dev/null; then + fail "privileged cleanup must never delete a binding with mismatched ownership" +fi +pass "privileged cleanup refuses a foreign exact-name binding" + +CLUSTER_ADMIN_INPUTS="$TMP/cluster-admin-inputs.yaml" +sed 's/rbac: namespace/rbac: cluster-admin/' "$GENERIC_INPUTS" > "$CLUSTER_ADMIN_INPUTS" +: > "$CALLS" +PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_INPUTS="$CLUSTER_ADMIN_INPUTS" \ + AGENT_OS_TEST_NAMESPACE=portable-agent-os AGENT_OS_TEST_NAMESPACE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_RESOURCE_STATE=owned AGENT_OS_CONTEXT=kind-agent-os \ + AGENT_OS_NAMESPACE=portable-agent-os "$GENERIC" upgrade +grep -F '/apis/rbac.authorization.k8s.io/v1/namespaces/portable-agent-os/rolebindings/agent-os-firstmate-runtime' "$CALLS" | grep -F 'delete --raw' >/dev/null || \ + fail "cluster-admin upgrade must UID-delete the stale namespace RoleBinding" +grep -F '/apis/rbac.authorization.k8s.io/v1/namespaces/portable-agent-os/roles/agent-os-firstmate-runtime' "$CALLS" | grep -F 'delete --raw' >/dev/null || \ + fail "cluster-admin upgrade must UID-delete the stale namespace Role" +apply_line=$(grep -Fn -- '--patch-file ' "$CALLS" | head -n 1 | cut -d: -f1) +delete_line=$(grep -Fn '/rolebindings/agent-os-firstmate-runtime' "$CALLS" | grep 'delete --raw' | head -n 1 | cut -d: -f1) +[ -n "$apply_line" ] && [ -n "$delete_line" ] && [ "$apply_line" -lt "$delete_line" ] || \ + fail "cluster-admin upgrade must apply replacement authority before removing namespace RBAC" +if grep -F 'delete clusterrolebinding agent-os-firstmate-portable-agent-os' "$CALLS" >/dev/null; then + fail "routine cluster-admin upgrade must retain its rendered ClusterRoleBinding" +fi +pass "cluster-admin upgrade reconciles namespaced authority after apply" + +: > "$CALLS" +: > "$STDIN_LOG" +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_RESOURCE_RV=67890 AGENT_OS_TEST_PRIMARY_LOCK_RV=12345 \ + AGENT_OS_TEST_PRIMARY_LOCK_STATE=expired \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace run_generic upgrade +assert_grep 'resourceVersion: "67890"' "$STDIN_LOG" \ + "primary resource mutation must serialize decimal resourceVersion as a string" +assert_grep 'resourceVersion: "12345"' "$STDIN_LOG" \ + "primary Lease CAS must serialize decimal resourceVersion as a string" +pass "primary CAS preserves opaque resourceVersion typing" + +: > "$CALLS" +stale_rbac_out='' +stale_rbac_rc=0 +stale_rbac_out=$(PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_INPUTS="$CLUSTER_ADMIN_INPUTS" \ + AGENT_OS_TEST_NAMESPACE=portable-agent-os AGENT_OS_TEST_NAMESPACE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_STALE_RBAC_STATE=foreign AGENT_OS_CONTEXT=kind-agent-os \ + AGENT_OS_OPERATION_ID=operation-test AGENT_OS_NAMESPACE=portable-agent-os \ + "$GENERIC" upgrade 2>&1) || stale_rbac_rc=$? +[ "$stale_rbac_rc" -eq 2 ] || fail "foreign stale namespaced RBAC must fail preflight: $stale_rbac_out" +assert_contains "$stale_rbac_out" "Role 'agent-os-firstmate-runtime' does not have the exact Agent OS installation identity" \ + "every RBAC mode must preflight deterministic stale names" +assert_no_grep 'patch StatefulSet agent-os-firstmate' "$CALLS" \ + "foreign stale RBAC must fail before desired resources mutate" +pass "all RBAC modes preflight deterministic stale resources" + +: > "$CALLS" +active_checkpoint_out='' +active_checkpoint_rc=0 +active_checkpoint_digest=$(printf '%s' '{"metadata":{"labels":{"rollback":"previous"}},"spec":{"serviceAccountName":"agent-os-firstmate"}}' | jq -cS . | shasum -a 256 | awk '{print $1}') +active_checkpoint_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_ROLLBACK_CHECKPOINT_DIGEST="$active_checkpoint_digest" \ + run_generic upgrade 2>&1) || active_checkpoint_rc=$? +[ "$active_checkpoint_rc" -eq 3 ] || fail "active rollback checkpoint must block upgrade: $active_checkpoint_out" +assert_contains "$active_checkpoint_out" 'active rollback checkpoint blocks upgrade' \ + "upgrade must require exact rollback recovery and checkpoint finalization" +assert_no_grep 'patch StatefulSet agent-os-firstmate --type=strategic --patch-file' "$CALLS" \ + "blocked upgrade must not mutate the StatefulSet template" +pass "upgrade refuses stale rollback checkpoints" + +: > "$CALLS" +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_AKUA_OVERLAY_SECRET=akua-auth \ + run_generic upgrade +assert_grep 'patch StatefulSet agent-os-firstmate --type=strategic --patch-file' "$CALLS" \ + "upgrade must preserve the verified authorization overlay in its StatefulSet CAS" +assert_grep 'get secret akua-auth --ignore-not-found' "$CALLS" \ + "upgrade must verify the namespace-local Secret reference without reading Secret bytes" +assert_no_grep 'authorization.*Bearer\|auth.json' "$STDIN_LOG" \ + "upgrade evidence must never contain Secret bytes" +pass "upgrade preserves verified Akua Secret-reference overlays" + +statefulset_patch_line=$(grep -Fn 'patch StatefulSet agent-os-firstmate --type=strategic --patch-file' "$CALLS" | head -n 1 | cut -d: -f1) +secret_reads_before_patch=$(sed -n "1,${statefulset_patch_line}p" "$CALLS" | grep -Fc 'get secret akua-auth --ignore-not-found') +[ -n "$statefulset_patch_line" ] && [ "$secret_reads_before_patch" -ge 2 ] || \ + fail "upgrade must revalidate the exact Secret identity immediately before StatefulSet CAS" +pass "upgrade revalidates authorization immediately before CAS" + +: > "$CALLS" +upgrade_reconcile_out='' +upgrade_reconcile_rc=0 +upgrade_reconcile_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_AKUA_OVERLAY_SECRET=akua-auth \ + AGENT_OS_TEST_FAIL_FIRST_ROLLOUT=1 run_generic upgrade 2>&1) || upgrade_reconcile_rc=$? +[ "$upgrade_reconcile_rc" -eq 3 ] || fail "post-CAS rollout failure must remain incomplete: $upgrade_reconcile_out" +assert_grep 'agent-os.dev/akua-auth-rejected-record' "$CALLS" \ + "post-CAS upgrade failure must record fail-closed authorization evidence" +assert_grep '"AKUA_AUTH_HEADER_FILE","$patch":"delete"' "$CALLS" \ + "post-CAS upgrade failure must remove the mounted authorization overlay" +pass "upgrade post-CAS failures reconcile authorization fail closed" + +: > "$CALLS" +replacement_reconcile_out='' +replacement_reconcile_rc=0 +replacement_reconcile_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_AKUA_OVERLAY_SECRET=akua-auth \ + AGENT_OS_TEST_FAIL_FIRST_ROLLOUT=1 AGENT_OS_TEST_REPLACE_STATEFULSET_AFTER_ROLLOUT=1 \ + run_generic upgrade 2>&1) || replacement_reconcile_rc=$? +[ "$replacement_reconcile_rc" -eq 3 ] || fail "replacement workload reconciliation must fail closed: $replacement_reconcile_out" +assert_not_contains "$replacement_reconcile_out" 'authorization overlay removed' \ + "replacement workload must never be reported as reconciled" +assert_no_grep 'agent-os.dev/akua-auth-rejected-record' "$CALLS" \ + "upgrade compensation must not mutate a replacement StatefulSet" +pass "upgrade compensation leaves replacement workloads untouched" + +: > "$CALLS" +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace \ + AGENT_OS_TEST_WORKLOAD_SERVICE_ACCOUNT=agent-os-firstmate-bbbbbbbbbbbb run_generic upgrade +[ "$(grep 'akua-input-service-account ' "$CALLS" | tail -n 1 | awk '{print $2}')" = agent-os-firstmate-bbbbbbbbbbbb ] || \ + fail "upgrade retries must reuse the verified installation ServiceAccount identity" +assert_no_grep '/serviceaccounts/agent-os-firstmate-bbbbbbbbbbbb' "$CALLS" \ + "upgrade retries must not delete their active installation ServiceAccount" +pass "upgrade reuses fresh installation ServiceAccount identity" + +: > "$CALLS" +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace run_generic upgrade +assert_no_grep '/serviceaccounts/agent-os-firstmate ' "$CALLS" \ + "upgrade must retain a legacy ServiceAccount referenced by rollback history" +pass "upgrade retains rollback ServiceAccount dependencies" + +: > "$CALLS" +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace run_generic rollback +checkpoint_line=$(grep -Fn 'agent-os.dev/rollback-target-digest' "$CALLS" | head -n 1 | cut -d: -f1) +template_patch_line=$(grep -Fn '"rollback":"previous"' "$CALLS" | head -n 1 | cut -d: -f1) +[ -n "$checkpoint_line" ] && [ -n "$template_patch_line" ] && [ "$checkpoint_line" -lt "$template_patch_line" ] || \ + fail "rollback must persist its immutable target checkpoint before patching the template" +assert_grep 'agent-os.dev/rollback-target-uid' "$CALLS" \ + "rollback checkpoint must persist exact ControllerRevision identity" +checkpoint_read_call=$(awk '/rollback-target-digest/ { checkpoint=1; next } checkpoint && / get statefulset agent-os-firstmate -o json/ { print; exit }' "$CALLS") +assert_contains "$checkpoint_read_call" '--request-timeout=' \ + "rollback checkpoint persistence read-back must be request-bounded" +grep -F 'kubectl --context kind-agent-os -n portable-agent-os patch StatefulSet agent-os-firstmate --type=strategic' "$CALLS" >/dev/null || \ + fail "generic rollback must update only the captured Firstmate StatefulSet" +assert_grep '"uid":"uid-statefulset"' "$CALLS" \ + "rollback mutation must carry StatefulSet UID CAS" +assert_grep '"resourceVersion":"rv-statefulset"' "$CALLS" \ + "rollback mutation must carry StatefulSet resourceVersion CAS" +assert_grep '"rollback":"previous"' "$CALLS" \ + "rollback must return a failed updateRevision to the exact-owned currentRevision" +assert_no_grep 'rollout undo' "$CALLS" \ + "rollback must not use a non-CAS rollout undo mutation" +grep -Fq 'akua render --no-agent-mode' "$CALLS" || \ + fail "rollback must derive its namespace and identity from the current package render" +assert_grep 'get controllerrevisions.apps -o json' "$CALLS" \ + "rollback must resolve the exact-owned revision history" +pass "generic rollback applies a revision-derived StatefulSet CAS update" + +: > "$CALLS" +rollback_rules_out='' +rollback_rules_rc=0 +rollback_rules_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_RBAC_STATE=bad-rules \ + run_generic rollback 2>&1) || rollback_rules_rc=$? +[ "$rollback_rules_rc" -eq 3 ] || fail "rollback must reject drifted runtime Role rules: $rollback_rules_out" +assert_no_grep '"rollback":"previous"' "$CALLS" \ + "drifted runtime Role rules must block rollback template mutation" +pass "rollback verifies exact runtime and control Role rules" + +: > "$CALLS" +rollback_control_rules_out='' +rollback_control_rules_rc=0 +rollback_control_rules_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_CONTROL_RBAC_STATE=bad-rules \ + run_generic rollback 2>&1) || rollback_control_rules_rc=$? +[ "$rollback_control_rules_rc" -eq 3 ] || fail "rollback must reject drifted control Role rules: $rollback_control_rules_out" +assert_no_grep '"rollback":"previous"' "$CALLS" \ + "drifted control Role rules must block rollback template mutation" +pass "rollback rejects drifted control Role rules" + +: > "$CALLS" +checkpoint_read_race_out='' +checkpoint_read_race_rc=0 +checkpoint_read_race_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_ROLLBACK_CHECKPOINT_MUTATE_ON_READ=1 \ + run_generic rollback 2>&1) || checkpoint_read_race_rc=$? +[ "$checkpoint_read_race_rc" -eq 3 ] || fail "rollback must fail closed when checkpoint identity changes before template mutation: $checkpoint_read_race_out" +assert_no_grep 'patch StatefulSet agent-os-firstmate --type=strategic' "$CALLS" \ + "checkpoint identity change must prevent strategic template mutation" +assert_contains "$checkpoint_read_race_out" 'rollback checkpoint did not persist exactly' \ + "checkpoint persistence failure must preserve an explicit retained-state error" +pass "rollback checkpoint read-back verifies immutable identity" + +previous_template_digest=$(printf '%s' '{"metadata":{"labels":{"rollback":"previous"}},"spec":{"serviceAccountName":"agent-os-firstmate"}}' | jq -cS . | shasum -a 256 | awk '{print $1}') +: > "$CALLS" +renumbered_out='' +renumbered_rc=0 +renumbered_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_PRIMARY_POD_STATE=owned \ + AGENT_OS_TEST_ROLLBACK_CURRENT=agent-os-firstmate-current \ + AGENT_OS_TEST_ROLLBACK_UPDATE=agent-os-firstmate-renumbered \ + AGENT_OS_TEST_ROLLBACK_RENUMBERED=1 \ + AGENT_OS_TEST_ROLLBACK_CHECKPOINT_DIGEST="$previous_template_digest" \ + AGENT_OS_TEST_FAIL_ROLLOUT=1 run_generic rollback 2>&1) || renumbered_rc=$? +[ "$renumbered_rc" -eq 3 ] || fail "renumbered rollback retry must remain incomplete: $renumbered_out" +assert_grep '"rollback":"current"' "$CALLS" \ + "rollback retry must compensate to its immutable checkpoint source template" +assert_contains "$renumbered_out" 'target-digest=' \ + "rollback retry must preserve the immutable checkpoint digest" +assert_contains "$renumbered_out" 'target=agent-os-firstmate-renumbered' \ + "rollback retry must adopt content-equivalent renumbered ControllerRevision" +assert_contains "$renumbered_out" 'checkpoint-target=agent-os-firstmate-previous checkpoint-uid=uid-revision-previous' \ + "rollback retry evidence must preserve the original immutable checkpoint identity" +pass "rollback retry adopts content-equivalent renumbered revisions" + +: > "$CALLS" +verification_out='' +verification_rc=0 +verification_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_ROLLBACK_VERIFY_MISMATCH=1 \ + run_generic rollback 2>&1) || verification_rc=$? +[ "$verification_rc" -eq 3 ] || fail "rollback must fail closed when completed revisions do not match its checkpoint: $verification_out" +assert_contains "$verification_out" "rollback revision 'agent-os-firstmate-current' content differs" \ + "rollback must retain explicit evidence when another revision completes" +pass "rollback success verifies both revision targets by content" + +: > "$CALLS" +checkpoint_mutation_out='' +checkpoint_mutation_rc=0 +checkpoint_mutation_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_ROLLBACK_CHECKPOINT_MUTATE=1 \ + run_generic rollback 2>&1) || checkpoint_mutation_rc=$? +[ "$checkpoint_mutation_rc" -eq 3 ] || fail "rollback must fail closed when checkpoint identity mutates: $checkpoint_mutation_out" +assert_contains "$checkpoint_mutation_out" 'rollback verification mismatch' \ + "rollback verification must require the complete immutable checkpoint tuple" +pass "rollback success preserves immutable checkpoint identity" + +: > "$CALLS" +rollback_failure_out='' +rollback_failure_rc=0 +rollback_failure_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_PRIMARY_POD_STATE=owned \ + AGENT_OS_TEST_FAIL_ROLLOUT=1 run_generic rollback 2>&1) || rollback_failure_rc=$? +[ "$rollback_failure_rc" -eq 3 ] || fail "failed rollback rollout must exit incomplete: $rollback_failure_out" +assert_contains "$rollback_failure_out" 'rollback target=agent-os-firstmate-previous revision=1' \ + "rollback failure must preserve its selected revision" +assert_contains "$rollback_failure_out" 'current-revision=agent-os-firstmate-previous update-revision=agent-os-firstmate-current' \ + "rollback failure must report observed revision state" +assert_contains "$rollback_failure_out" 'Pod/agent-os-firstmate-0 uid=uid-pod' \ + "rollback failure must report Pod UID and readiness evidence" +assert_contains "$rollback_failure_out" 'lifecycle-lease=agent-os-firstmate-lifecycle uid=uid-primary-lock holder=operation-test' \ + "rollback failure must preserve exact lifecycle Lease evidence" +assert_contains "$rollback_failure_out" 'safe recovery:' \ + "rollback failure must print an exact non-reversing recovery command" +rollback_evidence_calls=$(awk '/rollout status statefulset\/agent-os-firstmate/ { after=1; next } after && / get (statefulset agent-os-firstmate -o json|Pod agent-os-firstmate-0 .*jsonpath=)/ { print }' "$CALLS") +if printf '%s\n' "$rollback_evidence_calls" | grep -v -- '--request-timeout=' >/dev/null; then + fail "rollback failure evidence reads must each carry an independent request timeout: $rollback_evidence_calls" +fi +pass "failed rollback rollout preserves recovery evidence" + +: > "$CALLS" +rollback_resume_out='' +rollback_resume_rc=0 +rollback_resume_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=namespace AGENT_OS_TEST_PRIMARY_POD_STATE=owned \ + AGENT_OS_TEST_ROLLBACK_CURRENT=agent-os-firstmate-current \ + AGENT_OS_TEST_ROLLBACK_UPDATE=agent-os-firstmate-previous \ + AGENT_OS_TEST_ROLLBACK_CHECKPOINT_DIGEST="$previous_template_digest" \ + AGENT_OS_TEST_FAIL_ROLLOUT=1 run_generic rollback 2>&1) || rollback_resume_rc=$? +[ "$rollback_resume_rc" -eq 3 ] || fail "in-progress rollback retry must remain incomplete: $rollback_resume_out" +assert_no_grep 'patch StatefulSet agent-os-firstmate' "$CALLS" \ + "in-progress rollback retry must not reverse the selected target" +assert_contains "$rollback_resume_out" 'rollback target=agent-os-firstmate-previous revision=1' \ + "in-progress rollback retry must preserve the lower selected revision" +assert_contains "$rollback_resume_out" "persisted target digest remains" \ + "in-progress rollback retry must provide non-reversing recovery guidance" +pass "rollback retry resumes an existing lower-revision target" + +: > "$CALLS" +foreign_rollback_rc=0 +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_RESOURCE_STATE=foreign AGENT_OS_TEST_WORKLOAD_STATE=foreign \ + run_generic rollback >/dev/null 2>&1 || foreign_rollback_rc=$? +[ "$foreign_rollback_rc" -eq 2 ] || fail "rollback must refuse a foreign same-name StatefulSet" +if grep -F 'rollout undo' "$CALLS" >/dev/null; then + fail "rollback must verify ownership before mutation" +fi +pass "rollback refuses foreign workload ownership" + +: > "$CALLS" +if run_generic uninstall >/dev/null 2>&1; then + fail "generic uninstall must require --yes" +fi +: > "$CALLS" +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=namespace \ + AGENT_OS_TEST_RESOURCE_STATE=owned run_generic uninstall --yes +if grep -E 'kubectl .* (get|delete) clusterrolebinding' "$CALLS" >/dev/null; then + fail "routine namespace uninstall must never request cluster-wide RBAC authority" +fi +if grep -F 'delete namespace portable-agent-os' "$CALLS" >/dev/null; then + fail "bounded uninstall must retain its namespace by default" +fi +grep -F '/rolebindings/agent-os-firstmate-runtime' "$CALLS" | grep -F 'delete --raw' >/dev/null || \ + fail "uninstall must remove namespace runtime binding regardless of current inputs" +grep -F '/roles/agent-os-firstmate-runtime' "$CALLS" | grep -F 'delete --raw' >/dev/null || \ + fail "uninstall must remove namespace runtime Role regardless of current inputs" +stateful_delete_line=$(grep -Fn '/statefulsets/agent-os-firstmate' "$CALLS" | grep 'delete --raw' | head -n 1 | cut -d: -f1) +pvc_delete_line=$(grep -Fn '/persistentvolumeclaims/agent-os-firstmate-home' "$CALLS" | grep 'delete --raw' | head -n 1 | cut -d: -f1) +[ -n "$stateful_delete_line" ] && [ -n "$pvc_delete_line" ] && \ + [ "$stateful_delete_line" -lt "$pvc_delete_line" ] || \ + fail "uninstall must delete the StatefulSet before waiting on PVC deletion" +pass "routine uninstall removes namespaced resources without cluster-wide authority" + +: > "$CALLS" +NONE_INPUTS="$TMP/none-rbac-inputs.yaml" +sed 's/rbac: namespace/rbac: none/' "$GENERIC_INPUTS" > "$NONE_INPUTS" +bounded_out='' +bounded_rc=0 +bounded_out=$(PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_INPUTS="$NONE_INPUTS" \ + AGENT_OS_TEST_NAMESPACE=portable-agent-os AGENT_OS_TEST_NAMESPACE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=none AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_PRIMARY_POD_STATE=owned AGENT_OS_TEST_FAIL_DELETE_TARGET=persistentvolumeclaims/agent-os-firstmate-home \ + AGENT_OS_TEST_DELETE_ERROR='Error from server (Forbidden): persistentvolumeclaims is forbidden' \ + AGENT_OS_OPERATION_ID=operation-test AGENT_OS_CONTEXT=kind-agent-os \ + AGENT_OS_NAMESPACE=portable-agent-os "$GENERIC" uninstall --yes 2>&1) || bounded_rc=$? +[ "$bounded_rc" -eq 3 ] || fail "timed-out uninstall must exit incomplete: $bounded_out" +assert_contains "$bounded_out" 'delete-request-failure=Forbidden' \ + "immediate deletion authorization failures must not be mislabeled as timeouts" +assert_contains "$bounded_out" 'timeout=180s' \ + "deletion evidence must report the configured operation timeout" +assert_contains "$bounded_out" 'uid=uid-persistentvolumeclaim' \ + "deletion evidence must report the captured target UID" +assert_not_contains "$bounded_out" 'timed out deleting PersistentVolumeClaim/agent-os-firstmate-home' \ + "immediate deletion failures must remain distinct from timeout evidence" +assert_contains "$bounded_out" 'failed-target: PersistentVolumeClaim/agent-os-firstmate-home' \ + "timed-out uninstall must report the actual failed target" +assert_contains "$bounded_out" 'retained: PersistentVolumeClaim/agent-os-firstmate-home uid=uid-persistentvolumeclaim' \ + "timed-out uninstall must report the retained owned resource" +assert_contains "$bounded_out" 'finalizers=[kubernetes.io/pvc-protection]' \ + "timed-out uninstall must report retained finalizers" +assert_contains "$bounded_out" 'retained: Pod/agent-os-firstmate-0 uid=uid-pod' \ + "timed-out uninstall must report the StatefulSet Pod outside the fresh render" +assert_contains "$bounded_out" 'retained: Role/agent-os-firstmate-runtime uid=uid-role' \ + "timed-out rbac:none uninstall must report stale deterministic Role residue" +assert_contains "$bounded_out" 'retained: RoleBinding/agent-os-firstmate-runtime uid=uid-rolebinding' \ + "timed-out rbac:none uninstall must report stale deterministic RoleBinding residue" +assert_contains "$bounded_out" 'safe retry:' \ + "timed-out uninstall must print exact safe retry evidence" +assert_contains "$bounded_out" 'cluster cleanup if required:' \ + "timed-out uninstall must print exact privileged cleanup evidence" +grep -F '/persistentvolumeclaims/agent-os-firstmate-home' "$CALLS" | grep -F 'delete --raw' >/dev/null || \ + fail "uninstall deletion must target the captured PVC identity" +pass "uninstall delete-request failures preserve class and retained evidence" + +: > "$CALLS" +timeout_out='' +timeout_rc=0 +timeout_out=$(PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$CALLS" AGENT_OS_INPUTS="$NONE_INPUTS" \ + AGENT_OS_TEST_NAMESPACE=portable-agent-os AGENT_OS_TEST_NAMESPACE_STATE=owned \ + AGENT_OS_TEST_WORKLOAD_STATE=none AGENT_OS_TEST_RESOURCE_STATE=owned \ + AGENT_OS_TEST_FAIL_WAIT_TARGET=StatefulSet/agent-os-firstmate \ + AGENT_OS_OPERATION_ID=operation-test AGENT_OS_CONTEXT=kind-agent-os \ + AGENT_OS_NAMESPACE=portable-agent-os "$GENERIC" uninstall --yes 2>&1) || timeout_rc=$? +[ "$timeout_rc" -eq 3 ] || fail "true deletion timeout must exit incomplete rc=$timeout_rc: $timeout_out" +assert_contains "$timeout_out" 'delete-wait-failure=timeout' \ + "wait timeout evidence must preserve its failure class" +assert_contains "$timeout_out" 'timeout=180s' \ + "wait timeout evidence must report the actual configured timeout" +assert_contains "$timeout_out" 'uid=uid-statefulset' \ + "wait timeout evidence must report the captured target UID" +pass "uninstall wait timeouts report actual bounds and retained state" + +: > "$CALLS" +foreign_uninstall_rc=0 +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=namespace \ + AGENT_OS_TEST_RESOURCE_STATE=foreign run_generic uninstall --yes >/dev/null 2>&1 || foreign_uninstall_rc=$? +[ "$foreign_uninstall_rc" -eq 2 ] || fail "uninstall must reject same-name foreign namespaced resources" +if grep -F ' delete --ignore-not-found -f ' "$CALLS" >/dev/null; then + fail "uninstall must preflight all namespaced ownership before deletion" +fi +pass "uninstall refuses foreign deterministic-name resources" + +: > "$CALLS" +uninstall_residue_out='' +uninstall_residue_rc=0 +uninstall_residue_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=cluster-admin \ + run_generic uninstall --yes 2>&1) || uninstall_residue_rc=$? +[ "$uninstall_residue_rc" -eq 3 ] || \ + fail "cluster-admin uninstall must report residue with exit 3: $uninstall_residue_out" +if grep -E 'kubectl .* (get|delete) clusterrolebinding' "$CALLS" >/dev/null; then + fail "routine uninstall must report cluster residue without inspecting or deleting it" +fi +assert_contains "$uninstall_residue_out" 'cleanup-cluster-rbac --yes' \ + "cluster-admin uninstall must print the exact privileged cleanup command" +grep -F '/apis/rbac.authorization.k8s.io/v1/namespaces/kube-system/rolebindings/agent-os-lifecycle-' "$CALLS" | \ + grep -F 'delete --raw' >/dev/null || fail "cluster-admin uninstall must delete exact-owned control RoleBinding authority" +grep -F '/apis/rbac.authorization.k8s.io/v1/namespaces/kube-system/roles/agent-os-lifecycle-' "$CALLS" | \ + grep -F 'delete --raw' >/dev/null || fail "cluster-admin uninstall must delete exact-owned control Role authority" +pass "routine uninstall reports cluster-scoped residue for separate cleanup" + +: > "$CALLS" +retry_residue_out='' +retry_residue_rc=0 +retry_residue_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=absent \ + run_generic uninstall --yes 2>&1) || retry_residue_rc=$? +[ "$retry_residue_rc" -eq 3 ] || \ + fail "uninstall retry without workload history must require cluster absence evidence: $retry_residue_out" +assert_contains "$retry_residue_out" 'cleanup-cluster-rbac --yes' \ + "history-free uninstall retry must print the privileged absence-evidence command" +pass "uninstall retry cannot lose cluster-RBAC residue state" + +: > "$CALLS" +retry_service_account=agent-os-firstmate-cccccccccccc +retry_sa_out='' +retry_sa_rc=0 +retry_sa_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=absent \ + AGENT_OS_TEST_RESOURCE_STATE=absent AGENT_OS_TEST_RESIDUAL_SERVICE_ACCOUNT="$retry_service_account" \ + run_generic uninstall --yes 2>&1) || retry_sa_rc=$? +[ "$retry_sa_rc" -eq 3 ] || fail "history-free uninstall retry must retain cluster cleanup status: $retry_sa_out" +assert_grep "/serviceaccounts/$retry_service_account" "$CALLS" \ + "uninstall retry must independently recover and delete exact-owned historical ServiceAccounts" +pass "uninstall retry recovers historical ServiceAccounts independently" + +: > "$CALLS" +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=namespace \ + run_generic uninstall --yes --delete-namespace +grep -F '/api/v1/namespaces/portable-agent-os' "$CALLS" | grep -F 'delete --raw' >/dev/null || \ + fail "optional namespace deletion must UID-delete only the exactly owned namespace" +assert_grep '"uid":"uid-namespace","resourceVersion":"rv-namespace"' "$STDIN_LOG" \ + "namespace deletion must bind to the final observed namespace identity" +grep -Fq 'kubectl --context kind-agent-os api-resources --verbs=list --namespaced -o name' "$CALLS" || \ + fail "optional namespace deletion must inventory every listable namespaced resource type" +namespace_lock_line=$(grep -Fn 'get lease agent-os-firstmate-lifecycle' "$CALLS" | head -n 1 | cut -d: -f1) +namespace_inventory_line=$(grep -Fn 'api-resources --verbs=list --namespaced -o name' "$CALLS" | head -n 1 | cut -d: -f1) +[ -n "$namespace_lock_line" ] && [ -n "$namespace_inventory_line" ] && \ + [ "$namespace_lock_line" -lt "$namespace_inventory_line" ] || \ + fail "namespace deletion must inventory only after holding the installation-wide barrier" +awk ' + /namespaces\/kube-system\/rolebindings\/agent-os-lifecycle-/ && /delete --raw/ && !control { control=NR } + /\/api\/v1\/namespaces\/portable-agent-os -f -/ && /delete --raw/ && !namespace { namespace=NR } + END { exit !(control && namespace && control < namespace) } +' "$CALLS" || fail "uninstall must delete and verify control RBAC before namespace finalization: $(grep -E 'rolebindings/agent-os-lifecycle-|/api/v1/namespaces/portable-agent-os' "$CALLS" | tr '\n' ';')" +pass "optional namespace deletion proves ownership and no foreign resources" + +: > "$CALLS" +foreign_resource_out='' +foreign_resource_rc=0 +foreign_resource_out=$(AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=namespace \ + AGENT_OS_TEST_FOREIGN_RESOURCE=foreign-workload run_generic uninstall --yes --delete-namespace 2>&1) || \ + foreign_resource_rc=$? +[ "$foreign_resource_rc" -eq 2 ] || \ + fail "foreign namespace resources must block namespace deletion: $foreign_resource_out" +if grep -F 'delete namespace portable-agent-os' "$CALLS" >/dev/null; then + fail "namespace deletion must fail closed when foreign resources remain" +fi +pass "optional namespace deletion refuses foreign resources" + +: > "$CALLS" +foreign_lease_rc=0 +AGENT_OS_TEST_NAMESPACE_STATE=owned AGENT_OS_TEST_WORKLOAD_STATE=namespace \ + AGENT_OS_TEST_FOREIGN_LEASE=foreign-leader run_generic uninstall --yes --delete-namespace >/dev/null 2>&1 || \ + foreign_lease_rc=$? +[ "$foreign_lease_rc" -eq 2 ] || fail "foreign Lease must block namespace deletion" +if grep -F 'delete namespace portable-agent-os' "$CALLS" >/dev/null; then + fail "namespace deletion must include Leases in its foreign-resource proof" +fi +pass "optional namespace deletion refuses foreign Leases" diff --git a/tests/agent-os-local.test.sh b/tests/agent-os-local.test.sh new file mode 100755 index 000000000..32d7fa168 --- /dev/null +++ b/tests/agent-os-local.test.sh @@ -0,0 +1,378 @@ +#!/usr/bin/env bash +# Safety and command-contract tests for the local OrbStack Agent OS CLI. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +CLI="$ROOT/bin/agent-os-local.sh" +TMP=$(fm_test_tmproot agent-os-local) +FAKEBIN=$(fm_fakebin "$TMP") +LOG="$TMP/calls.log" +BUILD_ROOT="$TMP/source" + +test_git() { + env -i HOME=/nonexistent PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + /usr/bin/git -c credential.helper= -c core.hooksPath=/dev/null \ + -c http.proxy= -c https.proxy= "$@" +} + +SOURCE_COMMIT=$(test_git -C "$ROOT" rev-parse --verify HEAD) +SOURCE_TREE=$(test_git -C "$ROOT" rev-parse --verify 'HEAD^{tree}') +test_git clone --no-hardlinks --no-checkout "$ROOT" "$BUILD_ROOT" >/dev/null || \ + fail "local build fixture must clone the exact source checkout" +test_git -C "$BUILD_ROOT" checkout --detach "$SOURCE_COMMIT" >/dev/null || \ + fail "local build fixture must bind the exact source commit" +while IFS= read -r key; do + test_git -C "$BUILD_ROOT" config --local --unset-all "$key" +done < <(test_git -C "$BUILD_ROOT" config --local --name-only --get-regexp '^branch\.' || true) +test_git -C "$BUILD_ROOT" remote set-url origin https://github.com/akua-dev/agent-os.git +[ -z "$(test_git -C "$BUILD_ROOT" status --porcelain --untracked-files=all)" ] || \ + fail "local build fixture must use a clean isolated checkout" + +make_fake() { + local name=$1 + cat > "$FAKEBIN/$name" <<'SH' +#!/usr/bin/env bash +printf '%s' "$(basename "$0")" >> "$AGENT_OS_TEST_LOG" +printf ' %s' "$@" >> "$AGENT_OS_TEST_LOG" +printf '\n' >> "$AGENT_OS_TEST_LOG" +SH + chmod +x "$FAKEBIN/$name" +} + +make_fake docker +cat > "$FAKEBIN/docker" <<'SH' +#!/usr/bin/env bash +printf '%s' "$(basename "$0")" >> "$AGENT_OS_TEST_LOG" +printf ' %s' "$@" >> "$AGENT_OS_TEST_LOG" +printf '\n' >> "$AGENT_OS_TEST_LOG" +if [ "${1:-}" = image ] && [ "${2:-}" = inspect ]; then + printf '%s\n' "${AGENT_OS_TEST_IMAGE_ID:?AGENT_OS_TEST_IMAGE_ID is required for image inspection}" +fi +SH +chmod +x "$FAKEBIN/docker" +cat > "$FAKEBIN/kubectl" <<'SH' +#!/usr/bin/env bash +printf 'kubectl' >> "$AGENT_OS_TEST_LOG" +printf ' %s' "$@" >> "$AGENT_OS_TEST_LOG" +printf '\n' >> "$AGENT_OS_TEST_LOG" +stdin_kind='' +if [ "${*: -2}" = '-f -' ]; then + stdin_data=$(cat) + stdin_kind=$(printf '%s\n' "$stdin_data" | awk '$1 == "kind:" { print $2; exit }') + printf 'stdin-kind %s\n' "$stdin_kind" >> "$AGENT_OS_TEST_LOG" + if [ "$stdin_kind" = Lease ]; then + printf 'stdin-lease %s\n' "$(printf '%s\n' "$stdin_data" | awk '$1 == "name:" { print $2; exit }')" >> "$AGENT_OS_TEST_LOG" + printf 'stdin-holder %s\n' "$(printf '%s\n' "$stdin_data" | awk '$1 == "holderIdentity:" { print $2; exit }')" >> "$AGENT_OS_TEST_LOG" + printf 'stdin-acquired %s\n' "$(printf '%s\n' "$stdin_data" | awk '$1 == "acquireTime:" { print $2; exit }')" >> "$AGENT_OS_TEST_LOG" + printf 'stdin-renewed %s\n' "$(printf '%s\n' "$stdin_data" | awk '$1 == "renewTime:" { print $2; exit }')" >> "$AGENT_OS_TEST_LOG" + fi +fi +namespace=${AGENT_OS_NAMESPACE:-agent-os-demo} +operation=$(grep 'akua-input-operation ' "$AGENT_OS_TEST_LOG" | tail -n 1 | awk '{print $2}') +namespace_present=0 +if [ "${AGENT_OS_TEST_LOCAL_WORKLOAD:-absent}" = present ] || grep -F ' create -f ' "$AGENT_OS_TEST_LOG" | grep -F 'namespace.yaml' >/dev/null; then namespace_present=1; fi +stateful_present=0 +if [ "${AGENT_OS_TEST_LOCAL_WORKLOAD:-absent}" = present ] || grep -F 'statefulset.yaml' "$AGENT_OS_TEST_LOG" | grep -E ' create | patch ' >/dev/null; then stateful_present=1; fi +if [[ " $* " = *" get statefulset agent-os-firstmate --ignore-not-found -o name "* ]] && [ "$stateful_present" -eq 1 ]; then + printf 'statefulset.apps/agent-os-firstmate\n' +fi +if { [[ " $* " = *" get namespace "*" --ignore-not-found -o name "* ]] || + [[ " $* " = *" get Namespace "*" --ignore-not-found -o name "* ]]; } && + [ "$namespace_present" -eq 1 ]; then + printf 'namespace/%s\n' "$namespace" +fi +if { [[ " $* " = *" get namespace "*" -o jsonpath="* ]] || + [[ " $* " = *" get Namespace "*" -o jsonpath="* ]]; } && + [ "$namespace_present" -eq 1 ]; then + if [[ " $* " = *'.metadata.resourceVersion'* ]]; then + printf '%s\tagent-os\tagent-os-firstmate:%s\tuid-namespace\trv-namespace\t%s' "$namespace" "$namespace" "$operation" + else + printf 'agent-os\tagent-os-firstmate:%s' "$namespace" + fi +fi +if { [[ " $* " = *" get statefulset agent-os-firstmate --ignore-not-found -o jsonpath="* ]] || + [[ " $* " = *" get StatefulSet agent-os-firstmate --ignore-not-found -o jsonpath="* ]]; } && + [ "$stateful_present" -eq 1 ]; then + if [[ " $* " = *'.metadata.resourceVersion'* ]]; then + printf 'agent-os-firstmate\tagent-os\tagent-os-firstmate:%s\tuid-statefulset\trv-statefulset\t%s' "$namespace" "$operation" + elif [[ " $* " = *'.metadata.annotations.agent-os\.dev/rbac-mode'* ]]; then + printf 'agent-os-firstmate\tcluster-admin\t\tagent-os\tagent-os-firstmate:%s' "$namespace" + else + printf 'agent-os-firstmate\tagent-os\tagent-os-firstmate:%s' "$namespace" + fi +fi +if [[ " $* " = *" get statefulset agent-os-firstmate -o json "* ]] && [ "$stateful_present" -eq 1 ]; then + printf '{"metadata":{"name":"agent-os-firstmate","uid":"uid-statefulset","resourceVersion":"rv-statefulset","labels":{"app.kubernetes.io/managed-by":"agent-os"},"annotations":{"agent-os.dev/installation-id":"agent-os-firstmate:%s"}},"spec":{"template":{"spec":{"serviceAccountName":"agent-os-firstmate","containers":[{"name":"firstmate","env":[],"volumeMounts":[]}],"volumes":[]}}},"status":{"currentRevision":"revision-current","updateRevision":"revision-current"}}\n' "$namespace" +fi +if [[ " $* " = *" get controllerrevisions.apps -o json "* ]]; then + printf '%s\n' '{"items":[]}' +fi +if [[ " $* " = *" get serviceaccounts -o json "* ]]; then + printf '%s\n' '{"items":[]}' +fi +if [[ " $* " = *" get ServiceAccount agent-os-firstmate --ignore-not-found -o jsonpath="* ]] && [ "$stateful_present" -eq 1 ]; then + printf 'agent-os-firstmate\tagent-os\tagent-os-firstmate:%s' "$namespace" + [[ " $* " != *'.metadata.resourceVersion'* ]] || printf '\tuid-serviceaccount\trv-serviceaccount\t%s' "$operation" +fi +if { [[ " $* " = *" get clusterrolebinding agent-os-firstmate-"*" --ignore-not-found -o jsonpath="* ]] || + [[ " $* " = *" get ClusterRoleBinding agent-os-firstmate-"*" --ignore-not-found -o jsonpath="* ]]; }; then + if [ "${AGENT_OS_TEST_LOCAL_WORKLOAD:-absent}" = present ] || grep -F 'clusterrolebinding.yaml' "$AGENT_OS_TEST_LOG" | grep -E ' create | patch ' >/dev/null; then + printf 'agent-os-firstmate-%s\tagent-os\tagent-os-firstmate:%s' "$namespace" "$namespace" + [[ " $* " != *'.metadata.resourceVersion'* ]] || printf '\tuid-clusterrolebinding\trv-clusterrolebinding\t%s' "$operation" + fi +fi +if [[ " $* " = *" get lease agent-os-firstmate-lifecycle --ignore-not-found -o jsonpath="* ]] || \ + [[ " $* " = *" get lease agent-os-lifecycle-"*" --ignore-not-found -o jsonpath="* ]]; then + lock_name=$(printf '%s\n' "$*" | sed -n 's/.* get lease \([^ ]*\) .*/\1/p') + last_write=$(grep -Fn "stdin-lease $lock_name" "$AGENT_OS_TEST_LOG" | tail -n 1 | cut -d: -f1) + last_delete=$(grep -Fn "/leases/$lock_name" "$AGENT_OS_TEST_LOG" | grep 'delete --raw' | tail -n 1 | cut -d: -f1) + if [ -n "$last_write" ] && { [ -z "$last_delete" ] || [ "$last_write" -gt "$last_delete" ]; }; then + lock_installation="agent-os-firstmate:$namespace" + if [[ "$lock_name" = agent-os-lifecycle-* ]]; then + digest=$(printf 'agent-os-installation:%s' "$namespace" | shasum -a 256 | awk '{print $1}') + uuid="${digest:0:8}-${digest:8:4}-5${digest:13:3}-8${digest:17:3}-${digest:20:12}" + lock_installation="agent-os-control:$uuid" + fi + lock_holder=$(grep '^stdin-holder ' "$AGENT_OS_TEST_LOG" | tail -n 1 | cut -d' ' -f2-) + lock_acquired=$(grep '^stdin-acquired ' "$AGENT_OS_TEST_LOG" | tail -n 1 | cut -d' ' -f2-) + lock_renewed=$(grep '^stdin-renewed ' "$AGENT_OS_TEST_LOG" | tail -n 1 | cut -d' ' -f2-) + lock_rv=rv-lock + grep -Fq ' replace -f -' "$AGENT_OS_TEST_LOG" && lock_rv=rv-lock-renewed + printf '%s\tagent-os\tprimary\t%s\t%s\t%s\t%s\t300\tuid-lock\t%s' "$lock_name" "$lock_installation" "$lock_holder" "$lock_acquired" "$lock_renewed" "$lock_rv" + fi +fi +SH +chmod +x "$FAKEBIN/kubectl" +make_fake orbctl +cat > "$FAKEBIN/akua" <<'SH' +#!/usr/bin/env bash +printf '%s' "$(basename "$0")" >> "$AGENT_OS_TEST_LOG" +printf ' %s' "$@" >> "$AGENT_OS_TEST_LOG" +printf '\n' >> "$AGENT_OS_TEST_LOG" +inputs='' +out='' +while [ "$#" -gt 0 ]; do + case "$1" in + --inputs) inputs=$2; shift 2 ;; + --out) out=$2; shift 2 ;; + *) shift ;; + esac +done +printf 'akua-input-image %s\n' "$(awk '/^image:/{print $2}' "$inputs")" >> "$AGENT_OS_TEST_LOG" +namespace=$(awk '/^namespace:/{print $2}' "$inputs") +operation=$(awk '/^operationId:/{print $2}' "$inputs") +printf 'akua-input-namespace %s\n' "$namespace" >> "$AGENT_OS_TEST_LOG" +printf 'akua-input-operation %s\n' "$operation" >> "$AGENT_OS_TEST_LOG" +mkdir -p "$out" +cat > "$out/statefulset.yaml" < "$out/namespace.yaml" < "$out/clusterrolebinding.yaml" < "$LOG" + run_cli status + assert_call 'kubectl --context orbstack -n agent-os-demo get statefulset agent-os-firstmate' \ + "status must target only the OrbStack Agent OS StatefulSet" + pass "status pins the OrbStack context and Agent OS namespace" +} + +test_deploy_starts_local_kubernetes_and_renders_the_orbstack_profile() { + : > "$LOG" + run_cli deploy + assert_call 'orbctl start k8s' "deploy must start OrbStack Kubernetes" + assert_call 'kubectl --context orbstack wait --for=condition=Ready node/orbstack --timeout=120s' \ + "deploy must wait for the explicit OrbStack node" + grep -Fq -- "akua render --no-agent-mode --package $ROOT/tools/agent-os/packages/firstmate/package.k --inputs " "$LOG" || \ + fail "deploy must render the canonical portable package" + assert_call 'akua-input-image agent-os:local-default' \ + "OrbStack must derive its manifest from the rebuilt local image" + grep -Eq 'kubectl --context orbstack (create -f|.* patch )' "$LOG" || \ + fail "deploy must atomically create the freshly rendered OrbStack profile" + if grep -F 'kubectl --context orbstack apply -k' "$LOG" >/dev/null; then + fail "OrbStack must not maintain a second static installer" + fi + pass "deploy starts OrbStack and applies the canonical package profile" +} + +test_rebuild_deploy_uses_a_new_immutable_local_tag() { + : > "$LOG" + AGENT_OS_TEST_IMAGE_ID=sha256:stale run_build_cli + AGENT_OS_TEST_IMAGE_ID=sha256:rebuilt run_build_cli + AGENT_OS_TEST_IMAGE_ID=sha256:rebuilt run_cli deploy + + grep -F 'docker build ' "$LOG" | grep -F -- "--build-arg AGENT_OS_SOURCE_COMMIT=$SOURCE_COMMIT" | \ + grep -F -- "--build-arg AGENT_OS_SOURCE_TREE=$SOURCE_TREE" | \ + grep -F -- '--build-arg AGENT_OS_SOURCE_BRANCH=main' | \ + grep -F -- '--build-arg AGENT_OS_SOURCE_ORIGIN=https://github.com/akua-dev/agent-os.git' | \ + grep -F -- '--build-arg AGENT_OS_SOURCE_MODE=event' | \ + grep -F -- "--build-arg AGENT_OS_SOURCE_REF=event:$SOURCE_COMMIT" | \ + grep -F -- '-t agent-os:dev .' >/dev/null || \ + fail "build must pass verified exact-source arguments" + assert_call 'docker tag agent-os:dev agent-os:local-rebuilt' \ + "build must assign the rebuilt image a unique local tag" + assert_call 'akua-input-image agent-os:local-rebuilt' \ + "the rendered OrbStack profile must select the rebuilt local image" + pass "rebuild deploy renders the rebuilt local image instead of a stale mutable tag" +} + +test_redeploy_upgrades_an_existing_local_installation() { + : > "$LOG" + AGENT_OS_TEST_LOCAL_WORKLOAD=present AGENT_OS_TEST_IMAGE_ID=sha256:redeploy run_cli deploy + + assert_call 'kubectl --context orbstack -n agent-os-demo get statefulset agent-os-firstmate --ignore-not-found -o name' \ + "redeploy must detect the existing local installation" + grep -Fq 'kubectl --context orbstack -n agent-os-demo patch ' "$LOG" || \ + fail "redeploy must atomically upgrade the existing local installation" + pass "redeploy upgrades the content-addressed local installation" +} + +test_namespace_override_updates_the_rendered_profile() { + : > "$LOG" + AGENT_OS_NAMESPACE=agent-os-custom run_cli deploy + + assert_call 'akua-input-namespace agent-os-custom' \ + "namespace override must update the canonical profile inputs" + grep -Fq 'kubectl --context orbstack -n agent-os-custom' "$LOG" || \ + fail "namespace override must target the same rendered namespace" + pass "namespace override stays consistent with package rendering" +} + +test_explicit_image_override_is_used_without_retagging() { + : > "$LOG" + AGENT_OS_IMAGE=example.test/agent-os:custom run_build_cli + AGENT_OS_IMAGE=example.test/agent-os:custom run_cli deploy + + grep -F 'docker build ' "$LOG" | grep -F -- '-t example.test/agent-os:custom .' >/dev/null || \ + fail "build must preserve an explicit image override" + assert_call 'akua-input-image example.test/agent-os:custom' \ + "the rendered OrbStack profile must preserve an explicit image override" + if grep -F 'docker tag example.test/agent-os:custom' "$LOG" >/dev/null; then + fail "explicit image overrides must not be retagged" + fi + pass "explicit image override remains intact" +} + +test_empty_image_override_uses_content_addressed_default() { + : > "$LOG" + AGENT_OS_IMAGE='' AGENT_OS_TEST_IMAGE_ID=sha256:empty-default run_build_cli + AGENT_OS_IMAGE='' AGENT_OS_TEST_IMAGE_ID=sha256:empty-default run_cli deploy + + grep -F 'docker build ' "$LOG" | grep -F -- '-t agent-os:dev .' >/dev/null || \ + fail "an empty image override must use the local demo default" + assert_call 'docker tag agent-os:dev agent-os:local-empty-default' \ + "an empty image override must still receive a content-addressed tag" + assert_call 'akua-input-image agent-os:local-empty-default' \ + "an empty image override must deploy the content-addressed local image" + pass "empty image override uses the content-addressed local default" +} + +test_destroy_requires_exact_confirmation() { + local out rc=0 cleanup_out cleanup_rc=0 + : > "$LOG" + out=$(run_cli destroy 2>&1) || rc=$? + [ "$rc" -eq 2 ] || fail "destroy without --yes must exit 2, got $rc: $out" + [ ! -s "$LOG" ] || fail "destroy without --yes invoked an external command" + + cleanup_out=$(AGENT_OS_TEST_LOCAL_WORKLOAD=present run_cli destroy --yes 2>&1) || cleanup_rc=$? + [ "$cleanup_rc" -eq 3 ] || \ + fail "cluster-admin demo destroy must stop for separate privileged cleanup, got $cleanup_rc: $cleanup_out" + grep -F 'delete --raw /apis/apps/v1/namespaces/agent-os-demo/statefulsets/agent-os-firstmate -f -' "$LOG" >/dev/null || \ + fail "confirmed destroy must UID-delete the rendered OrbStack workload" + if grep -E 'kubectl .* (get|delete) clusterrolebinding' "$LOG" >/dev/null; then + fail "routine demo destroy must not inspect or delete cluster-scoped RBAC" + fi + assert_contains "$cleanup_out" 'cleanup-cluster-rbac --yes' \ + "demo destroy must print the separately confirmed privileged cleanup command" + pass "destroy requires confirmation and reports privileged RBAC cleanup" +} + +test_destroy_uses_the_namespace_adjusted_profile() { + local out rc=0 + : > "$LOG" + out=$(AGENT_OS_NAMESPACE=agent-os-custom AGENT_OS_TEST_LOCAL_WORKLOAD=present \ + run_cli destroy --yes 2>&1) || rc=$? + [ "$rc" -eq 3 ] || fail "custom namespace destroy must reach privileged cleanup, got $rc: $out" + assert_call 'akua-input-namespace agent-os-custom' \ + "destroy must render the same namespace-adjusted profile as deploy" + grep -Fq 'kubectl --context orbstack -n agent-os-custom' "$LOG" || \ + fail "destroy must target the custom rendered namespace" + pass "destroy stays consistent with namespace-adjusted rendering" +} + +test_non_orbstack_context_is_fail_closed() { + local out rc=0 + : > "$LOG" + out=$(PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$LOG" AGENT_OS_CONTEXT=minekube-prod "$CLI" status 2>&1) || rc=$? + [ "$rc" -eq 2 ] || fail "non-OrbStack context must exit 2, got $rc: $out" + [ ! -s "$LOG" ] || fail "rejected non-OrbStack context invoked an external command" + + PATH="$FAKEBIN:$PATH" AGENT_OS_TEST_LOG="$LOG" AGENT_OS_CONTEXT=kind-agent-os \ + AGENT_OS_ALLOW_NON_ORBSTACK=1 "$CLI" status + assert_call 'kubectl --context kind-agent-os -n agent-os-demo get statefulset agent-os-firstmate' \ + "explicit non-OrbStack opt-in must still pin the chosen context" + pass "non-OrbStack contexts require an explicit opt-in" +} + +test_status_pins_context_and_namespace +test_deploy_starts_local_kubernetes_and_renders_the_orbstack_profile +test_rebuild_deploy_uses_a_new_immutable_local_tag +test_redeploy_upgrades_an_existing_local_installation +test_namespace_override_updates_the_rendered_profile +test_explicit_image_override_is_used_without_retagging +test_empty_image_override_uses_content_addressed_default +test_destroy_requires_exact_confirmation +test_destroy_uses_the_namespace_adjusted_profile +test_non_orbstack_context_is_fail_closed diff --git a/tests/agent-os-packages.test.sh b/tests/agent-os-packages.test.sh new file mode 100755 index 000000000..776bcf7c5 --- /dev/null +++ b/tests/agent-os-packages.test.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# Render contracts for the one portable Agent OS package. +# Source assertions intentionally use single quotes for literal variable references. +# shellcheck disable=SC2016 +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +FIRSTMATE="$ROOT/tools/agent-os/packages/firstmate" +MATE="$ROOT/tools/agent-os/packages/mate" +TMP=$(fm_test_tmproot agent-os-packages) +INPUTS="$TMP/inputs.yaml" +OUT="$TMP/rendered" +MUTABLE_INPUTS="$TMP/mutable-image.yaml" +CLUSTER_ADMIN_INPUTS="$TMP/cluster-admin.yaml" + +mkdir -p "$TMP" + +[ -f "$FIRSTMATE/package.k" ] || fail "firstmate package must exist" +[ -f "$FIRSTMATE/inputs.example.yaml" ] || fail "firstmate example inputs must exist" + +assert_grep 'kind = "StatefulSet"' "$FIRSTMATE/package.k" \ + "firstmate package must create a restartable controller" +assert_grep 'kind = "PersistentVolumeClaim"' "$FIRSTMATE/package.k" \ + "firstmate package must persist its home" +assert_grep 'kind = "ServiceAccount"' "$FIRSTMATE/package.k" \ + "firstmate package must create its cluster identity" +assert_grep 'herdr", "status", "--json"' "$FIRSTMATE/package.k" \ + "Firstmate readiness must prove the Herdr server is responsive" +assert_no_grep 'mountPath = "/usr/local"' "$FIRSTMATE/package.k" \ + "image-owned Firstmate tools must remain immutable" + +assert_no_grep 'image: str = ' "$FIRSTMATE/package.k" \ + "the portable package must require an explicit immutable image digest" +assert_grep 'allowMutableImage: bool = False' "$FIRSTMATE/package.k" \ + "only an explicit local profile may allow a mutable image" +assert_grep 'rbac: "namespace" | "cluster-admin" | "none" = "namespace"' "$FIRSTMATE/package.k" \ + "the portable package must default to namespace-scoped runtime RBAC" +assert_grep 'kind = "Role"' "$FIRSTMATE/package.k" \ + "the portable package must render the namespace runtime Role" +assert_grep 'kind = "RoleBinding"' "$FIRSTMATE/package.k" \ + "the portable package must bind its explicit ServiceAccount to that Role" +assert_grep '"app.kubernetes.io/managed-by" = "agent-os"' "$FIRSTMATE/package.k" \ + "package resources must carry the Agent OS ownership label" +assert_grep '"agent-os.dev/installation-id"' "$FIRSTMATE/package.k" \ + "cluster and namespace resources must carry the installation identity" +assert_grep '"agent-os.dev/rbac-mode" = input.rbac' "$FIRSTMATE/package.k" \ + "the workload must record the applied RBAC mode for safe reconciliation" +assert_grep 'resources = ["pods", "persistentvolumeclaims"]' "$FIRSTMATE/package.k" \ + "runtime apply authority must exclude Pod subresources from patch access" +assert_grep 'verbs = ["get", "list", "watch", "create", "delete", "patch"]' "$FIRSTMATE/package.k" \ + "runtime RBAC must allow checkpoint updates on retained crewmate PVCs" +assert_grep 'resources = ["leases"]' "$FIRSTMATE/package.k" \ + "runtime RBAC must permit serialized crewmate lifecycle operations" +assert_grep 'verbs = ["get", "create", "update", "delete"]' "$FIRSTMATE/package.k" \ + "runtime Lease authority must be exact and permit CAS renewal" +assert_no_grep 'akuaAuthSecret' "$FIRSTMATE/package.k" \ + "the portable package must not require Akua authorization" +assert_no_grep 'agent-os.akua.dev' "$FIRSTMATE/package.k" \ + "the portable package must not depend on Akua-owned resource annotations" +[ -f "$ROOT/deploy/akua/firstmate-auth-grant.yaml" ] || \ + fail "Akua authorization must use a separate integration grant overlay" +[ -f "$ROOT/deploy/akua/firstmate-auth-revoke.yaml" ] || \ + fail "Akua authorization overlay must own explicit mount cleanup" +assert_grep 'name: AKUA_AUTH_HEADER_FILE' "$ROOT/deploy/akua/firstmate-auth-grant.yaml" \ + "Akua overlay must set the authorization header file path" +assert_grep 'mountPath: /var/run/secrets/agent-os/akua' "$ROOT/deploy/akua/firstmate-auth-grant.yaml" \ + "Akua overlay must mount only the Akua authorization path" +assert_grep 'secretName: __AKUA_AUTH_SECRET__' "$ROOT/deploy/akua/firstmate-auth-grant.yaml" \ + "Akua overlay must reference a namespace-local Secret by name" +assert_grep "\$patch: delete" "$ROOT/deploy/akua/firstmate-auth-revoke.yaml" \ + "Akua overlay must define explicit authorization mount cleanup" +assert_grep 'mountPath: /var/run/secrets/agent-os/akua' "$ROOT/deploy/akua/firstmate-auth-revoke.yaml" \ + "Akua overlay cleanup must use the strategic merge key for volume mounts" +assert_grep 'bin/agent-os-akua-auth.sh grant "$secret_name"' \ + "$ROOT/.agents/skills/akua-intelligence-bootstrap/SKILL.md" \ + "Akua authorization grant must use serialized exact-target integration" +assert_grep 'bin/agent-os-akua-auth.sh revoke "$secret_name"' \ + "$ROOT/.agents/skills/akua-intelligence-bootstrap/SKILL.md" \ + "Akua authorization revocation must use the same serialized integration" +assert_grep 'agent-os.dev/akua-auth-secret' "$ROOT/deploy/akua/firstmate-auth-grant.yaml" \ + "Akua authorization overlays must carry a verifiable non-secret reference marker" +[ -x "$ROOT/bin/agent-os-akua-auth.sh" ] || \ + fail "Akua authorization mutations must use the shipped serialized helper" +assert_grep 'resourceVersion:' "$ROOT/bin/agent-os-akua-auth.sh" \ + "Akua authorization mutations must carry StatefulSet CAS evidence" +assert_grep 'get pod agent-os-firstmate-0 -o json' "$ROOT/bin/agent-os-akua-auth.sh" \ + "Akua authorization mutations must verify the exact-owned rollout Pod" +assert_grep 'a missing credential grant is rejected before any mate resource is created' "$ROOT/docs/agent-evals.md" \ + "the eval contract must match fail-closed runtime authorization" +assert_grep '@sha256:' "$FIRSTMATE/inputs.example.yaml" \ + "the example must use an immutable image digest" +[ ! -f "$MATE/package.k" ] || fail "mate creation must not remain a separately installable package" + +cat > "$INPUTS" <<'YAML' +namespace: portable-agent-os +image: ghcr.io/akua-dev/agent-os@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +imagePullPolicy: IfNotPresent +rbac: namespace +storage: 20Gi +YAML + +akua render --no-agent-mode --package "$FIRSTMATE/package.k" --inputs "$INPUTS" --out "$OUT" >/dev/null || \ + fail "the portable package must render without an Akua account or credential" + +rendered=$(cat "$OUT"/*.yaml) +statefulset_rendered=$(cat "$(grep -Rl '^kind: StatefulSet$' "$OUT")") +assert_contains "$rendered" 'kind: ServiceAccount' "rendered topology must create a ServiceAccount" +assert_contains "$rendered" 'kind: Role' "rendered topology must create namespace-scoped runtime RBAC" +assert_contains "$rendered" 'kind: RoleBinding' "rendered topology must bind namespace runtime RBAC" +assert_contains "$rendered" 'kind: PersistentVolumeClaim' "rendered topology must persist Firstmate home" +assert_contains "$rendered" 'kind: StatefulSet' "rendered topology must run Firstmate as a StatefulSet" +assert_contains "$rendered" 'ghcr.io/akua-dev/agent-os@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + "rendered topology must retain the immutable image digest" +assert_contains "$rendered" 'app.kubernetes.io/managed-by: agent-os' \ + "rendered resources must carry the Agent OS ownership label" +assert_contains "$rendered" 'agent-os.dev/installation-id: agent-os-firstmate:portable-agent-os' \ + "rendered resources must carry the exact installation identity" +for resource_file in "$OUT"/*.yaml; do + assert_grep 'app.kubernetes.io/managed-by: agent-os' "$resource_file" \ + "every rendered resource must carry the Agent OS ownership label" + assert_grep 'agent-os.dev/installation-id: agent-os-firstmate:portable-agent-os' "$resource_file" \ + "every rendered resource must carry the exact installation identity" +done +assert_contains "$statefulset_rendered" 'agent-os.dev/rbac-mode: namespace' \ + "the rendered StatefulSet must record its RBAC mode" +assert_not_contains "$rendered" 'kind: ClusterRoleBinding' \ + "the default portable topology must not grant cluster-admin" +assert_not_contains "$rendered" 'AKUA_' \ + "the portable rendered topology must not require Akua configuration" +assert_not_contains "$rendered" 'agent-os.akua.dev' \ + "the portable rendered topology must not use Akua-owned annotations" + +sed 's/^rbac: namespace/rbac: cluster-admin/' "$INPUTS" > "$CLUSTER_ADMIN_INPUTS" +akua render --no-agent-mode --package "$FIRSTMATE/package.k" --inputs "$CLUSTER_ADMIN_INPUTS" \ + --out "$TMP/cluster-admin-rendered" >/dev/null || \ + fail "the reviewed cluster-admin package profile must render" +cluster_binding=$(cat "$(grep -Rl '^kind: ClusterRoleBinding$' "$TMP/cluster-admin-rendered")") +assert_contains "$cluster_binding" 'name: agent-os-firstmate-portable-agent-os' \ + "cluster-admin RBAC must use the deterministic installation binding name" +assert_contains "$cluster_binding" 'app.kubernetes.io/managed-by: agent-os' \ + "cluster-admin RBAC must carry the exact ownership label" +assert_contains "$cluster_binding" 'agent-os.dev/installation-id: agent-os-firstmate:portable-agent-os' \ + "cluster-admin RBAC must carry the exact installation annotation" + +cat > "$MUTABLE_INPUTS" <<'YAML' +namespace: portable-agent-os +image: ghcr.io/akua-dev/agent-os:latest +imagePullPolicy: IfNotPresent +rbac: namespace +storage: 20Gi +YAML + +if akua render --no-agent-mode --package "$FIRSTMATE/package.k" --inputs "$MUTABLE_INPUTS" --out "$TMP/mutable-rendered" >/dev/null 2>&1; then + fail "the portable package must reject a mutable image tag" +fi +pass "the portable package rejects mutable image tags" + +for invalid_image in \ + 'ghcr.io/akua-dev/agent-os@sha256:abc' \ + 'ghcr.io/akua-dev/agent-os@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaextra' \ + 'invalid@@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + 'registry.example/org:123/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + '[2001:db8::1]:5000/org:123/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + '[2001:db8::g]:5000/org/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + '[1]:5000/org/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + '[::::]:5000/org/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + '[1::2::3]:5000/org/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + '[::ffff:999.0.2.1]:5000/org/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + 'prefix@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:suffix'; do + invalid_inputs="$TMP/invalid-$(printf '%s' "$invalid_image" | tr '/:@' '---').yaml" + sed "s|^image: .*|image: \"$invalid_image\"|" "$INPUTS" > "$invalid_inputs" + if akua render --no-agent-mode --package "$FIRSTMATE/package.k" --inputs "$invalid_inputs" \ + --out "$TMP/invalid-rendered" >/dev/null 2>&1; then + fail "the portable package accepted malformed digest reference '$invalid_image'" + fi +done +pass "the portable package requires one complete 64-hex SHA-256 digest" + +PORT_INPUTS="$TMP/registry-port.yaml" +sed 's|^image: .*|image: registry.example:5000/org/agent-os@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa|' \ + "$INPUTS" > "$PORT_INPUTS" +akua render --no-agent-mode --package "$FIRSTMATE/package.k" --inputs "$PORT_INPUTS" \ + --out "$TMP/registry-port-rendered" >/dev/null || \ + fail "the portable package must allow a registry authority port" +pass "OCI reference ports are confined to registry authority" + +IPV6_INPUTS="$TMP/registry-ipv6.yaml" +sed 's|^image: .*|image: "[2001:db8::1]:5000/org/agent-os@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"|' \ + "$INPUTS" > "$IPV6_INPUTS" +akua render --no-agent-mode --package "$FIRSTMATE/package.k" --inputs "$IPV6_INPUTS" \ + --out "$TMP/registry-ipv6-rendered" >/dev/null || \ + fail "the portable package must allow a bracketed IPv6 registry authority" +for ipv6_registry in '::' '::1' '1::' '1:2:3:4:5:6:7:8' '::ffff:192.0.2.1'; do + ipv6_inputs="$TMP/registry-ipv6-$(printf '%s' "$ipv6_registry" | tr ':' '-').yaml" + sed "s|^image: .*|image: \"[$ipv6_registry]:5000/org/agent-os@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"|" \ + "$INPUTS" > "$ipv6_inputs" + akua render --no-agent-mode --package "$FIRSTMATE/package.k" --inputs "$ipv6_inputs" \ + --out "$TMP/registry-ipv6-$(printf '%s' "$ipv6_registry" | tr ':' '-')-rendered" >/dev/null || \ + fail "the portable package rejected valid IPv6 registry '$ipv6_registry'" +done +pass "OCI references accept bracketed IPv6 registry authorities" + +assert_grep 'bin/agent-os-kubernetes.sh install' "$ROOT/docs/kubernetes.md" \ + "Kubernetes docs must make the generic installer the default quickstart" +assert_grep 'bin/agent-os-kubernetes.sh rollback' "$ROOT/docs/kubernetes.md" \ + "Kubernetes docs must document bounded rollback" +assert_grep 'bin/agent-os-kubernetes.sh uninstall --yes' "$ROOT/docs/kubernetes.md" \ + "Kubernetes docs must document confirmed bounded uninstall" +assert_grep 'No Kubernetes Secret is required' "$ROOT/docs/kubernetes.md" \ + "Kubernetes docs must state the portable secret requirement" +assert_grep 'OrbStack profile' "$ROOT/docs/kubernetes.md" \ + "Kubernetes docs must describe OrbStack as a profile of the canonical package" +assert_grep '[Agent OS Kubernetes quickstart](docs/kubernetes.md)' "$ROOT/README.md" \ + "README must point to the portable Kubernetes quickstart" +assert_no_grep 'Firstmate Akua package' "$ROOT/README.md" \ + "README must not present a separate Akua package as the portable install path" +assert_grep 'Install Akua renderer' "$ROOT/.github/workflows/ci.yml" \ + "CI behavior tests must install the renderer used by package contracts" + +pass "the portable Agent OS package renders a digest-pinned topology without Akua" diff --git a/tests/agent-os-runtime-secondmates.test.sh b/tests/agent-os-runtime-secondmates.test.sh new file mode 100755 index 000000000..3ee7b25bc --- /dev/null +++ b/tests/agent-os-runtime-secondmates.test.sh @@ -0,0 +1,461 @@ +#!/usr/bin/env bash +# The generated flock fixture intentionally preserves literal shell variables. +# shellcheck disable=SC2016 +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SYNC="$ROOT/bin/agent-os-runtime-secondmates.sh" +TEST_ROOT=$(fm_test_tmproot agent-os-runtime-secondmates) +TREEHOUSE_POOL="$TEST_ROOT/pool" +TMP="$TREEHOUSE_POOL/0" +FM_HOME_DIR="$TMP/home" +WORK="$TMP/work" +LEASE= +TEST_FLOCK="$TEST_ROOT/flock" +TEST_LOCK_ROOT="$TEST_ROOT/locks" +PRESERVE_TREEHOUSE_STATE=false + +fm_git_identity fmtest fmtest@example.com + +mkdir -p "$FM_HOME_DIR/state" "$FM_HOME_DIR/data" "$TREEHOUSE_POOL" "$TEST_LOCK_ROOT" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -eu' \ + 'fd=${2:?lock file descriptor is required}' \ + 'target=${AGENT_OS_TEST_LOCK_FILE_HINT:?lock file identity is required}' \ + 'key=$(printf "%s" "$target" | /usr/bin/cksum | /usr/bin/awk "{print \$1}")' \ + 'lock="$AGENT_OS_TEST_LOCK_ROOT/$key"' \ + 'if ! /bin/mkdir "$lock" 2>/dev/null; then' \ + ' owner=$(/bin/cat "$lock/owner" 2>/dev/null || true)' \ + ' if [ -n "$owner" ] && /bin/kill -0 "$owner" 2>/dev/null; then exit 1; fi' \ + ' /bin/rm -rf "$lock"' \ + ' /bin/mkdir "$lock"' \ + 'fi' \ + 'printf "%s\n" "$PPID" > "$lock/owner"' > "$TEST_FLOCK" +chmod +x "$TEST_FLOCK" +touch "$TREEHOUSE_POOL/treehouse-state.lock" +git init -q -b main "$WORK" +printf 'data/\nstate/\nconfig/\nprojects/\n.fm-secondmate-home\n' > "$WORK/.gitignore" +printf 'A\n' > "$WORK/version" +printf 'agents\n' > "$WORK/AGENTS.md" +mkdir "$WORK/bin" +printf 'tool\n' > "$WORK/bin/tool" +git -C "$WORK" add -A +git -C "$WORK" commit -qm A +A_COMMIT=$(git -C "$WORK" rev-parse HEAD) +A_TREE=$(git -C "$WORK" rev-parse 'HEAD^{tree}') +printf 'B\n' > "$WORK/version" +git -C "$WORK" add version +git -C "$WORK" commit -qm B +B_COMMIT=$(git -C "$WORK" rev-parse HEAD) +B_TREE=$(git -C "$WORK" rev-parse 'HEAD^{tree}') +B_SHA=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +A_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + +LEASE="$FM_HOME_DIR/runtime-sources/$A_COMMIT-$A_SHA" +mkdir -p "$FM_HOME_DIR/runtime-sources" +git clone -q "$WORK" "$LEASE" +git -C "$LEASE" remote set-url origin https://github.com/akua-dev/agent-os.git +git -C "$LEASE" checkout -q --detach "$A_COMMIT" +printf 'mode=release\ncommit=%s\nsource_sha256=%s\n' "$A_COMMIT" "$A_SHA" \ + > "$LEASE/.git/agent-os-runtime-source" +git -C "$LEASE" worktree add -q --detach "$TMP/linked" "$A_COMMIT" +git clone -q "$LEASE" "$TMP/standalone" +git -C "$TMP/standalone" checkout -q --detach "$A_COMMIT" +for id in linked standalone; do + printf '%s\n' "$id" > "$TMP/$id/.fm-secondmate-home" + mkdir -p "$TMP/$id/data" "$TMP/$id/state" "$TMP/$id/config" "$TMP/$id/projects" + printf 'persistent-%s\n' "$id" > "$TMP/$id/data/state" + { + printf 'window=firstmate:fm-%s\n' "$id" + printf 'kind=secondmate\n' + printf 'home=%s/%s\n' "$TMP" "$id" + } > "$FM_HOME_DIR/state/$id.meta" +done + +make_primary() { + local name=$1 commit=$2 + git clone -q "$WORK" "$TMP/$name" + git -C "$TMP/$name" remote set-url origin https://github.com/akua-dev/agent-os.git + git -C "$TMP/$name" checkout -q main + git -C "$TMP/$name" reset -q --hard "$commit" +} + +write_treehouse_state() { + local meta id home slot=0 + : > "$TEST_ROOT/treehouse-worktrees.jsonl" + for meta in "$FM_HOME_DIR"/state/*.meta; do + [ -f "$meta" ] || continue + [ "$(sed -n 's/^kind=//p' "$meta")" = secondmate ] || continue + id=${meta##*/} + id=${id%.meta} + home=$(sed -n 's/^home=//p' "$meta") + jq -cn --arg id "$id" --arg home "$home" --arg name "$slot" \ + '{name:$name,path:$home,created_at:"2026-01-01T00:00:00Z",destroying:false, + owner_pid:0,owner_started_at:0,leased:true,lease_holder:$id, + leased_at:"2026-01-01T00:00:00Z"}' >> "$TEST_ROOT/treehouse-worktrees.jsonl" + slot=$((slot + 1)) + done + jq -s '{worktrees:.}' "$TEST_ROOT/treehouse-worktrees.jsonl" \ + > "$TREEHOUSE_POOL/treehouse-state.json" +} + +run_sync() { + local root=$1 commit=$2 tree=$3 sha=$4 + [ "$PRESERVE_TREEHOUSE_STATE" = true ] || write_treehouse_state + FM_HOME="$FM_HOME_DIR" FM_ROOT_OVERRIDE="$root" \ + AGENT_OS_SOURCE_COMMIT="$commit" AGENT_OS_SOURCE_TREE="$tree" \ + AGENT_OS_SOURCE_SHA256="$sha" AGENT_OS_SOURCE_BRANCH=main \ + AGENT_OS_SOURCE_ORIGIN=https://github.com/akua-dev/agent-os.git \ + AGENT_OS_SOURCE_MODE=release AGENT_OS_TEST_FLOCK_BIN="$TEST_FLOCK" \ + AGENT_OS_TEST_LOCK_ROOT="$TEST_LOCK_ROOT" AGENT_OS_TEST_BOUND_PATHS=true "$SYNC" +} + +fleet_mutation_signature() { + local id gitdir policy + for id in linked standalone; do + gitdir=$(git -C "$TMP/$id" rev-parse --absolute-git-dir) + printf '%s:%s:%s\n' "$id" "$(git -C "$TMP/$id" rev-parse HEAD)" \ + "$(git -C "$TMP/$id" remote get-url origin)" + for policy in "$gitdir/agent-os-runtime-source" \ + "$TMP/$id/config/agent-os-source-policy" \ + "$TMP/$id/config/agent-os-source-policy.pending" \ + "$TMP/$id/config/agent-os-source-policy.required"; do + if [ -f "$policy" ]; then + printf '%s:%s\n' "$policy" "$(shasum -a 256 "$policy" | awk '{print $1}')" + else + printf '%s:absent\n' "$policy" + fi + done + done + if [ -d "$TMP/foreign-guard/.git" ]; then + printf 'foreign-guard:%s:%s\n' "$(git -C "$TMP/foreign-guard" rev-parse HEAD)" \ + "$(git -C "$TMP/foreign-guard" remote get-url origin)" + if [ -f "$TMP/foreign-guard/.git/agent-os-runtime-source" ]; then + shasum -a 256 "$TMP/foreign-guard/.git/agent-os-runtime-source" + else + printf 'foreign-guard-policy:absent\n' + fi + fi +} + +expect_rejection_without_mutation() { + local label=$1 before after + before=$(fleet_mutation_signature) + if run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null 2>&1; then + fail "$label was accepted" + fi + after=$(fleet_mutation_signature) + [ "$after" = "$before" ] || fail "$label allowed source, remote, marker, or policy mutation" +} + +make_primary primary-b "$B_COMMIT" +make_primary primary-a "$A_COMMIT" +run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null || \ + fail "A to B secondmate selection failed" +for id in linked standalone; do + [ "$(git -C "$TMP/$id" rev-parse HEAD)" = "$B_COMMIT" ] || fail "$id did not select B" + [ "$(cat "$TMP/$id/data/state")" = "persistent-$id" ] || fail "$id persistent state changed" + gitdir=$(git -C "$TMP/$id" rev-parse --absolute-git-dir) + [ -f "$gitdir/agent-os-runtime-source" ] || fail "$id immutable policy was not persisted" + [ -f "$TMP/$id/config/agent-os-source-policy" ] || fail "$id home policy was not persisted" + [ -f "$TMP/$id/config/agent-os-source-policy.required" ] || \ + fail "$id immutable policy requirement was not persisted" + cmp "$gitdir/agent-os-runtime-source" "$TMP/$id/config/agent-os-source-policy" || \ + fail "$id Git and home policies differ" +done + +run_sync "$TMP/primary-a" "$A_COMMIT" "$A_TREE" "$A_SHA" >/dev/null || \ + fail "B to A secondmate rollback selection failed" +for id in linked standalone; do + [ "$(git -C "$TMP/$id" rev-parse HEAD)" = "$A_COMMIT" ] || fail "$id did not reselect A" + [ "$(cat "$TMP/$id/data/state")" = "persistent-$id" ] || fail "$id persistent state changed on rollback" +done +pass "linked and standalone secondmates select exact A to B to A sources" + +gitdir=$(git -C "$TMP/standalone" rev-parse --absolute-git-dir) +printf 'mode=release\ncommit=%s\nsource_sha256=%s\n' "$B_COMMIT" "$B_SHA" \ + > "$TMP/standalone/config/agent-os-source-policy" +if run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null 2>&1; then + fail "mismatched secondmate immutable policies were accepted" +fi +[ "$(git -C "$TMP/standalone" rev-parse HEAD)" = "$A_COMMIT" ] || \ + fail "mismatched secondmate immutable policy allowed source mutation" +cp "$gitdir/agent-os-runtime-source" "$TMP/standalone/config/agent-os-source-policy" +pass "secondmate selection rejects mismatched immutable policies" + +gitdir=$(git -C "$TMP/standalone" rev-parse --absolute-git-dir) +printf 'immutable\n' > "$TMP/standalone/config/agent-os-source-policy.required" +printf 'mode=release\ncommit=%s\nsource_sha256=%s\n' "$B_COMMIT" "$B_SHA" \ + > "$TMP/standalone/config/agent-os-source-policy.pending" +cp "$TMP/standalone/config/agent-os-source-policy.pending" \ + "$TMP/standalone/config/agent-os-source-policy" +printf 'mode=release\ncommit=%s\nsource_sha256=%s\n' "$A_COMMIT" "$A_SHA" \ + > "$gitdir/agent-os-runtime-source" +run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null || \ + fail "journaled secondmate policy transition was not recovered" +[ ! -e "$TMP/standalone/config/agent-os-source-policy.pending" ] || \ + fail "recovered secondmate policy journal was retained" +cmp "$gitdir/agent-os-runtime-source" "$TMP/standalone/config/agent-os-source-policy" || \ + fail "recovered secondmate policies differ" +run_sync "$TMP/primary-a" "$A_COMMIT" "$A_TREE" "$A_SHA" >/dev/null || \ + fail "secondmate did not return to A after journal recovery" +pass "secondmate selection recovers journaled policy transitions" + +gitdir=$(git -C "$TMP/standalone" rev-parse --absolute-git-dir) +printf 'mode=release\ncommit=%s\nsource_sha256=%s\n' "$B_COMMIT" "$B_SHA" \ + > "$TMP/standalone/config/agent-os-source-policy.pending" +git -C "$WORK" show "$B_COMMIT:version" > "$TMP/standalone/version" +run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null || \ + fail "interrupted secondmate checkout was not recovered from its journal" +[ "$(git -C "$TMP/standalone" rev-parse HEAD)" = "$B_COMMIT" ] || \ + fail "interrupted secondmate checkout did not select B" +[ -z "$(git -C "$TMP/standalone" status --porcelain --untracked-files=all)" ] || \ + fail "interrupted secondmate checkout recovery remained dirty" +[ ! -e "$TMP/standalone/config/agent-os-source-policy.pending" ] || \ + fail "interrupted secondmate checkout retained its journal" +run_sync "$TMP/primary-a" "$A_COMMIT" "$A_TREE" "$A_SHA" >/dev/null || \ + fail "secondmate did not return to A after interrupted checkout recovery" +pass "secondmate selection verifies and recovers interrupted journaled checkouts" + +git clone -q "$LEASE" "$TMP/foreign-common" +git -C "$TMP/foreign-common" checkout -q --detach "$A_COMMIT" +git -C "$TMP/foreign-common" worktree add -q --detach "$TMP/foreign-linked" "$A_COMMIT" +printf 'foreign-linked\n' > "$TMP/foreign-linked/.fm-secondmate-home" +mkdir -p "$TMP/foreign-linked/data" "$TMP/foreign-linked/state" \ + "$TMP/foreign-linked/config" "$TMP/foreign-linked/projects" +{ + printf 'window=firstmate:fm-foreign-linked\n' + printf 'kind=secondmate\n' + printf 'home=%s/foreign-linked\n' "$TMP" +} > "$FM_HOME_DIR/state/foreign-linked.meta" +if run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null 2>&1; then + fail "linked secondmate owned by a foreign common directory was accepted" +fi +[ "$(git -C "$TMP/foreign-linked" rev-parse HEAD)" = "$A_COMMIT" ] || \ + fail "foreign linked-worktree metadata was mutated" +rm "$FM_HOME_DIR/state/foreign-linked.meta" +pass "secondmate selection binds linked common metadata to runtime-source ownership" + +git clone -q "$LEASE" "$TMP/redirected" +git -C "$TMP/redirected" checkout -q --detach "$A_COMMIT" +printf 'owner\n' > "$TMP/redirected/.fm-secondmate-home" +mkdir -p "$TMP/redirected/data" "$TMP/redirected/state" "$TMP/redirected/config" "$TMP/redirected/projects" +{ + printf 'window=firstmate:fm-redirected\n' + printf 'kind=secondmate\n' + printf 'home=%s/redirected\n' "$TMP" +} > "$FM_HOME_DIR/state/redirected.meta" +if run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null 2>&1; then + fail "mismatched secondmate ownership marker was accepted" +fi +[ "$(git -C "$TMP/redirected" rev-parse HEAD)" = "$A_COMMIT" ] || \ + fail "mismatched secondmate home was mutated" +rm "$FM_HOME_DIR/state/redirected.meta" +pass "secondmate selection requires exact registered marker ownership" + +git clone -q "$LEASE" "$TMP/git-victim" +git -C "$TMP/git-victim" checkout -q --detach "$A_COMMIT" +git clone -q "$LEASE" "$TMP/git-redirected" +git -C "$TMP/git-redirected" checkout -q --detach "$A_COMMIT" +printf 'git-redirected\n' > "$TMP/git-redirected/.fm-secondmate-home" +mkdir -p "$TMP/git-redirected/data" "$TMP/git-redirected/state" \ + "$TMP/git-redirected/config" "$TMP/git-redirected/projects" +mv "$TMP/git-redirected/.git" "$TMP/git-redirected-own.git" +printf 'gitdir: %s/git-victim/.git\n' "$TMP" > "$TMP/git-redirected/.git" +{ + printf 'window=firstmate:fm-git-redirected\n' + printf 'kind=secondmate\n' + printf 'home=%s/git-redirected\n' "$TMP" +} > "$FM_HOME_DIR/state/git-redirected.meta" +if run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null 2>&1; then + fail "secondmate home redirected to another Git directory was accepted" +fi +[ "$(git -C "$TMP/git-victim" rev-parse HEAD)" = "$A_COMMIT" ] || \ + fail "foreign Git metadata was mutated through a redirected secondmate home" +rm "$FM_HOME_DIR/state/git-redirected.meta" +pass "secondmate selection binds Git metadata to its exact home" + +git clone -q "$LEASE" "$TMP/linked-alias" +git -C "$TMP/linked-alias" checkout -q --detach "$A_COMMIT" +printf 'linked\n' > "$TMP/linked-alias/.fm-secondmate-home" +mkdir -p "$TMP/linked-alias/data" "$TMP/linked-alias/state" "$TMP/linked-alias/config" \ + "$TMP/linked-alias/projects" +printf -- '- linked - standby (home: %s/linked-alias; state: idle)\n' "$TMP" \ + > "$FM_HOME_DIR/data/secondmates.md" +if run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null 2>&1; then + fail "duplicate secondmate identity homes were accepted" +fi +[ "$(git -C "$TMP/linked-alias" rev-parse HEAD)" = "$A_COMMIT" ] || \ + fail "duplicate secondmate identity home was mutated" +: > "$FM_HOME_DIR/data/secondmates.md" +pass "secondmate selection rejects one identity mapped to multiple homes" + +git clone -q "$LEASE" "$TMP/linked/projects/nested" +git -C "$TMP/linked/projects/nested" checkout -q --detach "$A_COMMIT" +printf 'nested\n' > "$TMP/linked/projects/nested/.fm-secondmate-home" +mkdir -p "$TMP/linked/projects/nested/data" "$TMP/linked/projects/nested/state" \ + "$TMP/linked/projects/nested/config" "$TMP/linked/projects/nested/projects" +{ + printf 'window=firstmate:fm-nested\n' + printf 'kind=secondmate\n' + printf 'home=%s/linked/projects/nested\n' "$TMP" +} > "$FM_HOME_DIR/state/nested.meta" +if run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null 2>&1; then + fail "overlapping secondmate homes were accepted" +fi +[ "$(git -C "$TMP/linked/projects/nested" rev-parse HEAD)" = "$A_COMMIT" ] || \ + fail "overlapping secondmate home was mutated" +rm "$FM_HOME_DIR/state/nested.meta" +pass "secondmate selection rejects canonical home overlap" + +git clone -q "$LEASE" "$TMP/foreign-guard" +git -C "$TMP/foreign-guard" checkout -q --detach "$A_COMMIT" + +write_treehouse_state +PRESERVE_TREEHOUSE_STATE=true +jq 'del(.worktrees[] | select(.lease_holder == "standalone"))' \ + "$TREEHOUSE_POOL/treehouse-state.json" > "$TEST_ROOT/state-next.json" +mv "$TEST_ROOT/state-next.json" "$TREEHOUSE_POOL/treehouse-state.json" +run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null || \ + fail "primary-owned standalone assignment required Treehouse self-attestation" +run_sync "$TMP/primary-a" "$A_COMMIT" "$A_TREE" "$A_SHA" >/dev/null || \ + fail "primary-owned standalone assignment could not select its rollback source" +pass "primary-owned standalone assignment needs no child or Treehouse attestation" + +mv "$FM_HOME_DIR/state/standalone.meta" "$TEST_ROOT/standalone.meta" +printf -- '- standalone - standby (home: %s/standalone; state: idle)\n' "$TMP" \ + > "$FM_HOME_DIR/data/secondmates.md" +expect_rejection_without_mutation "registry-only standalone self-attestation" +mv "$TEST_ROOT/standalone.meta" "$FM_HOME_DIR/state/standalone.meta" +: > "$FM_HOME_DIR/data/secondmates.md" +pass "standalone assignment requires primary-owned proof" + +write_treehouse_state +jq 'del(.worktrees[] | select(.lease_holder == "linked"))' \ + "$TREEHOUSE_POOL/treehouse-state.json" > "$TEST_ROOT/state-next.json" +mv "$TEST_ROOT/state-next.json" "$TREEHOUSE_POOL/treehouse-state.json" +expect_rejection_without_mutation "missing Treehouse lease evidence" + +write_treehouse_state +jq '(.worktrees[] | select(.lease_holder == "linked") | .leased) = false' \ + "$TREEHOUSE_POOL/treehouse-state.json" > "$TEST_ROOT/state-next.json" +mv "$TEST_ROOT/state-next.json" "$TREEHOUSE_POOL/treehouse-state.json" +expect_rejection_without_mutation "stale Treehouse lease evidence" + +write_treehouse_state +jq '(.worktrees[] | select(.lease_holder == "linked") | .lease_holder) = "other"' \ + "$TREEHOUSE_POOL/treehouse-state.json" > "$TEST_ROOT/state-next.json" +mv "$TEST_ROOT/state-next.json" "$TREEHOUSE_POOL/treehouse-state.json" +expect_rejection_without_mutation "other-holder Treehouse lease evidence" + +write_treehouse_state +jq '.worktrees += [.worktrees[] | select(.lease_holder == "linked")]' \ + "$TREEHOUSE_POOL/treehouse-state.json" > "$TEST_ROOT/state-next.json" +mv "$TEST_ROOT/state-next.json" "$TREEHOUSE_POOL/treehouse-state.json" +expect_rejection_without_mutation "duplicate Treehouse lease evidence" + +write_treehouse_state +jq '.worktrees += [(.worktrees[] | select(.lease_holder == "linked") | .path += "-planted")]' \ + "$TREEHOUSE_POOL/treehouse-state.json" > "$TEST_ROOT/state-next.json" +mv "$TEST_ROOT/state-next.json" "$TREEHOUSE_POOL/treehouse-state.json" +expect_rejection_without_mutation "ambiguous planted Treehouse holder evidence" + +git -C "$LEASE" worktree add -q --detach "$TMP/leased-decoy" "$A_COMMIT" +write_treehouse_state +jq --arg path "$TMP/leased-decoy" \ + '(.worktrees[] | select(.lease_holder == "linked") | .path) = $path' \ + "$TREEHOUSE_POOL/treehouse-state.json" > "$TEST_ROOT/state-next.json" +mv "$TEST_ROOT/state-next.json" "$TREEHOUSE_POOL/treehouse-state.json" +expect_rejection_without_mutation "Treehouse leased path bound to another same-common worktree" +[ "$(git -C "$TMP/leased-decoy" rev-parse HEAD)" = "$A_COMMIT" ] || \ + fail "same-common decoy worktree was mutated" +git -C "$LEASE" worktree remove "$TMP/leased-decoy" + +printf '{unreadable\n' > "$TREEHOUSE_POOL/treehouse-state.json" +expect_rejection_without_mutation "unreadable Treehouse lease evidence" +PRESERVE_TREEHOUSE_STATE=false +write_treehouse_state +pass "secondmate selection requires one exact active Treehouse lease holder" + +git clone -q "$LEASE" "$TMP/toctou-foreign" +git -C "$TMP/toctou-foreign" checkout -q --detach "$A_COMMIT" +linked_gitdir=$(git -C "$TMP/linked" rev-parse --absolute-git-dir) +linked_commondir=$(cat "$linked_gitdir/commondir") +linked_pointer=$(cat "$TMP/linked/.git") +for barrier_phase in fetch policy checkout remote marker; do + foreign_before=$(find "$TMP/toctou-foreign/.git" -type f -print0 | sort -z | \ + xargs -0 shasum -a 256 | shasum -a 256 | awk '{print $1}') + fleet_before=$(fleet_mutation_signature) + write_treehouse_state + PRESERVE_TREEHOUSE_STATE=true + AGENT_OS_TEST_BARRIERS=true + AGENT_OS_TEST_BARRIER_PHASE=$barrier_phase + AGENT_OS_TEST_BARRIER_READY="$TEST_ROOT/toctou-$barrier_phase-ready" + AGENT_OS_TEST_BARRIER_RELEASE="$TEST_ROOT/toctou-$barrier_phase-release" + export AGENT_OS_TEST_BARRIERS AGENT_OS_TEST_BARRIER_PHASE \ + AGENT_OS_TEST_BARRIER_READY AGENT_OS_TEST_BARRIER_RELEASE + rm -f "$AGENT_OS_TEST_BARRIER_READY" "$AGENT_OS_TEST_BARRIER_RELEASE" + run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" \ + > "$TEST_ROOT/toctou-$barrier_phase-runtime.out" 2>&1 & + runtime_pid=$! + for _ in $(seq 1 500); do + [ -f "$AGENT_OS_TEST_BARRIER_READY" ] && break + sleep 0.01 + done + [ -f "$AGENT_OS_TEST_BARRIER_READY" ] || { + kill "$runtime_pid" 2>/dev/null || true + fail "runtime TOCTOU $barrier_phase barrier was not reached" + } + if [ "$barrier_phase" = fetch ]; then + if [ -x /usr/bin/flock ]; then + exec {probe_fd}<>"$TREEHOUSE_POOL/treehouse-state.lock" + /usr/bin/flock -n -E 75 -x "$probe_fd" + probe_status=$? + exec {probe_fd}>&- + if [ "$probe_status" -ne 75 ]; then + touch "$AGENT_OS_TEST_BARRIER_RELEASE" + wait "$runtime_pid" 2>/dev/null || true + fail "secondmate ownership lock did not exclude a bounded contender" + fi + elif AGENT_OS_TEST_BARRIERS='' AGENT_OS_TEST_BARRIER_PHASE='' \ + run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null 2>&1; then + touch "$AGENT_OS_TEST_BARRIER_RELEASE" + wait "$runtime_pid" 2>/dev/null || true + fail "secondmate ownership lock admitted a concurrent runtime mutation" + fi + fi + printf 'gitdir: %s/toctou-foreign/.git\n' "$TMP" > "$TMP/linked/.git" + printf '%s\n' "$TMP/toctou-foreign/.git" > "$linked_gitdir/commondir" + touch "$AGENT_OS_TEST_BARRIER_RELEASE" + if wait "$runtime_pid"; then + fail "runtime accepted a Git pointer and common-directory swap at $barrier_phase" + fi + printf '%s\n' "$linked_pointer" > "$TMP/linked/.git" + printf '%s\n' "$linked_commondir" > "$linked_gitdir/commondir" + foreign_after=$(find "$TMP/toctou-foreign/.git" -type f -print0 | sort -z | \ + xargs -0 shasum -a 256 | shasum -a 256 | awk '{print $1}') + [ "$foreign_after" = "$foreign_before" ] || \ + fail "runtime TOCTOU $barrier_phase redirected mutation into a foreign repository" + unset AGENT_OS_TEST_BARRIERS AGENT_OS_TEST_BARRIER_PHASE \ + AGENT_OS_TEST_BARRIER_READY AGENT_OS_TEST_BARRIER_RELEASE + PRESERVE_TREEHOUSE_STATE=false + run_sync "$TMP/primary-a" "$A_COMMIT" "$A_TREE" "$A_SHA" >/dev/null || \ + fail "runtime TOCTOU $barrier_phase state could not be reconciled" + [ "$(fleet_mutation_signature)" = "$fleet_before" ] || \ + fail "runtime TOCTOU $barrier_phase reconciliation changed persistent state" +done +write_treehouse_state +pass "runtime lock and bound directories defeat pointer swaps at every mutation phase" + +printf 'tampered\n' >> "$TMP/standalone/AGENTS.md" +if run_sync "$TMP/primary-b" "$B_COMMIT" "$B_TREE" "$B_SHA" >/dev/null 2>&1; then + fail "dirty secondmate source was replaced" +fi +[ "$(git -C "$TMP/standalone" rev-parse HEAD)" = "$A_COMMIT" ] || \ + fail "dirty secondmate source moved" +pass "dirty secondmate source fails closed" + +echo "# all Agent OS runtime secondmate tests passed" diff --git a/tests/agent-os-runtime-source.test.sh b/tests/agent-os-runtime-source.test.sh new file mode 100755 index 000000000..491e7084d --- /dev/null +++ b/tests/agent-os-runtime-source.test.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +MATERIALIZE="$ROOT/bin/agent-os-runtime-source.sh" +TMP=$(fm_test_tmproot agent-os-runtime-source) +HOME_DIR="$TMP/home" + +fm_git_identity fmtest fmtest@example.com + +make_source() { + local name=$1 value=$2 work bare + work="$TMP/$name-work" + bare="$TMP/$name.git" + git init -q "$work" + git -C "$work" checkout -q -b main + printf '%s\n' "$value" > "$work/version" + git -C "$work" add version + git -C "$work" commit -qm "$name" + git clone -q --bare "$work" "$bare" + git -C "$bare" symbolic-ref HEAD refs/heads/main +} + +materialize() { + local source=$1 sha=$2 commit tree + commit=$(git --git-dir="$source" rev-parse refs/heads/main) + tree=$(git --git-dir="$source" rev-parse "$commit^{tree}") + FM_HOME="$HOME_DIR" AGENT_OS_IMAGE_SOURCE="$source" \ + AGENT_OS_SOURCE_COMMIT="$commit" AGENT_OS_SOURCE_TREE="$tree" \ + AGENT_OS_SOURCE_SHA256="$sha" AGENT_OS_SOURCE_BRANCH=main \ + AGENT_OS_SOURCE_ORIGIN=https://github.com/akua-dev/agent-os.git \ + AGENT_OS_SOURCE_MODE=candidate "$MATERIALIZE" +} + +make_source a A +make_source b B +mkdir -p "$HOME_DIR/data" +printf 'persistent\n' > "$HOME_DIR/data/captain-state" + +A_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +B_SHA=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +A_ROOT=$(materialize "$TMP/a.git" "$A_SHA") || fail "release A materialization failed" +B_ROOT=$(materialize "$TMP/b.git" "$B_SHA") || fail "release B materialization failed" +A_AGAIN=$(materialize "$TMP/a.git" "$A_SHA") || fail "release A rollback selection failed" + +[ "$A_ROOT" = "$A_AGAIN" ] || fail "A to B to A did not select the original exact source" +[ "$A_ROOT" != "$B_ROOT" ] || fail "different releases shared one mutable source checkout" +[ "$(cat "$A_ROOT/version")" = A ] || fail "release A source changed after B materialization" +[ "$(cat "$B_ROOT/version")" = B ] || fail "release B source was not selected" +[ "$(cat "$HOME_DIR/data/captain-state")" = persistent ] || fail "persistent home state changed" +pass "content-addressed sources support A to B to A without changing home state" + +PATH_SENTINEL="$TMP/path-sed-executed" +mkdir "$TMP/fake-bin" +printf '#!/usr/bin/env bash\ntouch %q\nexec /usr/bin/sed "$@"\n' "$PATH_SENTINEL" > "$TMP/fake-bin/sed" +chmod +x "$TMP/fake-bin/sed" +PATH="$TMP/fake-bin:$PATH" materialize "$TMP/a.git" "$A_SHA" >/dev/null || \ + fail "source validation failed under an untrusted ambient PATH" +[ ! -e "$PATH_SENTINEL" ] || fail "source validation executed a persistent PATH override" +pass "runtime provenance utilities use a fixed system PATH" + +FSWATCH_SENTINEL="$TMP/fsmonitor-executed" +FSWATCH="$TMP/fsmonitor" +printf '#!/usr/bin/env bash\ntouch %q\n' "$FSWATCH_SENTINEL" > "$FSWATCH" +chmod +x "$FSWATCH" +git -C "$A_ROOT" config core.fsmonitor "$FSWATCH" +if materialize "$TMP/a.git" "$A_SHA" >/dev/null 2>&1; then + fail "executable persistent Git configuration was accepted" +fi +[ ! -e "$FSWATCH_SENTINEL" ] || fail "persistent Git configuration executed before validation" +git -C "$A_ROOT" config --unset core.fsmonitor +pass "persistent Git configuration is rejected before repository access" + +LOCK="$HOME_DIR/runtime-sources/.${B_ROOT##*/}.materializing" +mkdir "$LOCK" +printf 'other-owner\n' > "$LOCK/owner" +B_AGAIN=$(materialize "$TMP/b.git" "$B_SHA") || fail "verified source handoff failed" +[ "$B_AGAIN" = "$B_ROOT" ] || fail "verified source handoff selected the wrong source" +[ "$(cat "$LOCK/owner")" = other-owner ] || fail "source observer removed another materializer lock" +rm "$LOCK/owner" +rmdir "$LOCK" +pass "verified source observers preserve the materializer owner lock" + +PARTIAL_SHA=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc +PARTIAL_COMMIT=$(git --git-dir="$TMP/b.git" rev-parse refs/heads/main) +mkdir -p "$HOME_DIR/runtime-sources/.${PARTIAL_COMMIT}-${PARTIAL_SHA}.materializing" +if materialize "$TMP/b.git" "$PARTIAL_SHA" >/dev/null 2>&1; then + fail "partial immutable source materialization was accepted" +fi +[ "$(cat "$B_ROOT/version")" = B ] || fail "failed B materialization damaged retained release B" +A_AFTER_FAILED_B=$(materialize "$TMP/a.git" "$A_SHA") || fail "failed B compensation could not reselect release A" +[ "$A_AFTER_FAILED_B" = "$A_ROOT" ] || fail "failed B compensation did not preserve release A" +pass "partial materialization fails closed without damaging retained sources" + +printf 'tampered\n' > "$A_ROOT/version" +if materialize "$TMP/a.git" "$A_SHA" >/dev/null 2>&1; then + fail "tampered immutable source was accepted" +fi +pass "tampered immutable source fails closed" + +echo "# all Agent OS runtime source tests passed" diff --git a/tests/fm-backend-zellij-smoke.test.sh b/tests/fm-backend-zellij-smoke.test.sh index ffd5b2ae7..4823a477c 100755 --- a/tests/fm-backend-zellij-smoke.test.sh +++ b/tests/fm-backend-zellij-smoke.test.sh @@ -20,6 +20,19 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } pass() { printf 'ok - %s\n' "$1"; } +wait_for_tmp_current_path() { # + local target=$1 actual="" deadline=$((SECONDS + 15)) + while [ "$SECONDS" -lt "$deadline" ]; do + actual=$(fm_backend_zellij_current_path "$target") || actual="" + case "$actual" in + */tmp) printf '%s' "$actual"; return 0 ;; + esac + sleep 0.2 + done + printf '%s' "$actual" + return 1 +} + command -v zellij >/dev/null 2>&1 || { echo "skip: zellij not found"; exit 0; } command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the zellij adapter)"; exit 0; } @@ -126,8 +139,7 @@ pass "real zellij: capture supports viewport-sized reads and larger full-scrollb # --- current_path ------------------------------------------------------------- fm_backend_zellij_send_text_line "$TARGET" "cd /tmp" -sleep 0.3 -p=$(fm_backend_zellij_current_path "$TARGET") || fail "current_path failed" +p=$(wait_for_tmp_current_path "$TARGET") || fail "current_path failed" case "$p" in */tmp) : ;; *) fail "real zellij: current_path did not report the pane's cwd after cd /tmp, got '$p'" ;; diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 5a9fafcde..c3765cebe 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -110,7 +110,7 @@ BASE_REF=$(resolve_base_ref) \ # tmux-only conformance run the tmux adapter's behavior is what is under test, # and that is unchanged by any later (e.g. non-tmux backend) addition to # fm-backend.sh's own dispatch surface. -OLD_BIN_UNCHANGED_SIBLINGS="fm-guard.sh fm-lock-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-tasks-axi-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-backend.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-guard.sh fm-gate-refuse-lib.sh fm-lock-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-tasks-axi-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-backend.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh" build_old_bin() { # -> echoes root dir (root/bin/