From c5fe2b1ec8ff8b66cfb281c3483076aed8ff48f9 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 15 Sep 2026 04:12:25 +0000 Subject: [PATCH 01/37] docs(adr): propose devenv-based agent kits --- docs/adr/0028-agent-kits-on-devenv-base.md | 216 +++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/adr/0028-agent-kits-on-devenv-base.md diff --git a/docs/adr/0028-agent-kits-on-devenv-base.md b/docs/adr/0028-agent-kits-on-devenv-base.md new file mode 100644 index 00000000..2cbb462f --- /dev/null +++ b/docs/adr/0028-agent-kits-on-devenv-base.md @@ -0,0 +1,216 @@ +--- +title: "Use Agent Kits on a Devenv-Enabled Agent-Less Base Image" +status: proposed +date: 2026-09-15 +decision_makers: ["Bret Mogilefsky", "William Zujkowski", "Basilio Bogado"] +category: architecture +nist_controls: ["AC-6", "CM-2", "CM-3", "CM-6", "CM-7", "SA-8", "SA-15", "SC-7"] +impact_level: low +ato_relevance: no +risk_treatment: mitigate +supersedes: [] +--- + +# ADR-0028: Use Agent Kits on a Devenv-Enabled Agent-Less Base Image + +## Context and Problem Statement + +`acq` currently carries too much knowledge about specific coding agents and the +base images that happen to include them. Recent agent-kit work for additional +harnesses such as Pi, Prime Agent, and Goose exposes the tension: each new agent +should not require another special base image or another bespoke branch in core +`acq` behavior. + +In parallel, the patterns repository has introduced a reusable devenv-capable +base-image pattern +([GSA-TTS/agentic-coding-patterns#395](https://github.com/GSA-TTS/agentic-coding-patterns/pull/395)): +**Nix (single-user) + devenv + direnv on a parametrized `BASE_IMAGE`**. That is +not necessarily a NixOS image. This is a useful property: Ubuntu and similar +general-purpose images are more familiar to many teams, and bring-your-own-image +users are likely to start from them, while still getting Nix/devenv/direnv on +top. + +The proposed direction is to make the sandbox image provide a generic devenv +substrate, move agent installation and configuration into kits, and have `acq` +compose those declarations consistently across backends. + +## Decision Drivers + +- **Reduce agent-specific behavior in core `acq`.** `acq` should orchestrate + sessions, sandboxes, credentials, mounts, networks, and kits; it should not + need built-in install/config knowledge for every agent harness. +- **Keep the base image generic and familiar.** A parametrized base image with + Nix, devenv, and direnv layered on top lets teams use Ubuntu-like bases while + preserving reproducible environment management. +- **Make agent support a kit-authoring problem.** Adding OpenCode, Pi, Goose, + Prime Agent, or a future harness should primarily mean adding or selecting an + agent kit. +- **Single-source the guest environment contract.** Kit declarations should + describe files, environment, volumes, egress, startup steps, and entrypoints in + one neutral vocabulary instead of threading backend-specific logic through the + msb and sbx adapters. +- **Preserve backend neutrality.** The same high-level kit model should apply to + both msb and sbx, even if each backend translates declarations differently. +- **Minimize base-image proliferation.** Agent-populated images may remain useful + as cache optimizations, but should not define behavior. + +## Considered Options + +1. **Keep agent-populated base images and core `acq` agent knowledge.** This is + the current shape. It works for a small set of agents, but does not scale as + additional harnesses arrive. +2. **Use a NixOS-only base image.** This maximizes Nix consistency, but raises + the adoption cost for teams more familiar with Ubuntu-like images and does not + match likely bring-your-own-image usage. +3. **Use a parametrized base image with Nix, devenv, and direnv layered on top, + plus agent kits.** This keeps the base image generic, preserves familiar OS + choices, and moves agent-specific knowledge to kits. + +## Proposed Decision Outcome + +Proposed option: **Option 3**. + +`acq` should target an agent-less, devenv-enabled base-image contract: Nix +(single-user), devenv, and direnv layered onto a parametrized `BASE_IMAGE`. +Agent installation, agent-specific configuration, and the agent entrypoint should +move into agent kits. `acq run ` may infer the matching agent +kit when the user does not explicitly provide it; `shell` remains the likely +special case because it is not an agent harness. + +The high-level layering becomes: + +```text +User / control plane + CLI, Agor-like UI, future Factory UI + +Normalized session API + acq today, possibly split later + +Harness adapter layer + OpenCode, Pi, Goose, Prime Agent, future agents + +Environment / capability layer + kits + devenv + +Sandbox control layer + acq lifecycle, credentials, network, mounts + +Backends + msb, sbx + +Generic devenv-enabled base + parametrized BASE_IMAGE + Nix (single-user) + devenv + direnv +``` + +The corresponding kit model is expected to look like: + +```text +acq +├─ backends: msb, sbx +├─ image: ACQ_IMAGE / BASE_IMAGE-derived devenv image +└─ kits + ├─ built-in bundle + │ ├─ neutral: CA trust, playbook, git signing, shared security policy + │ ├─ OCI engine: podman or equivalent container-run capability + │ ├─ agent: opencode | pi | goose | prime-agent | ... + │ └─ agent-aware: USAi/provider config in the selected agent's format + └─ extra kits + ├─ team kit + └─ personal kit +``` + +This ADR is intentionally proposed, not accepted. It records the direction and +questions for discussion before implementation. + +## Lifecycle Sketch + +At create time: + +1. Resolve the backend, base image, agent, and kit set. +2. Merge kit declarations for create-time concerns such as egress and volumes. +3. Create the microVM from the generic devenv-enabled image. +4. Prepare the agent user and baseline guest contract. +5. Apply kits in order: built-in bundle first, then extra kits, with explicit + conflict rules. +6. Let the selected agent kit install or expose the agent binary and declare its + entrypoint. + +At run or attach time: + +1. Start the sandbox if needed. +2. Re-apply idempotent file, environment, and startup declarations as required. +3. Attach using the selected agent kit's declared command. +4. Activate the workspace's devenv/direnv environment according to the final + contract. + +## Open Questions + +- **Activation contract:** Does `acq` run `devenv up`, `devenv shell`, + `direnv allow`, or a combination? Which step belongs to the base image, the + kit layer, and the project workspace? +- **Process supervision:** Can devenv's process supervision replace ad hoc + supervisor loops used by related projects, and if so, what does `acq` need to + guarantee? +- **Create-time versus run-time declarations:** Which kit declarations must be + resolved before VM creation, and which can be re-applied safely on every run? +- **Kit ordering and conflicts:** What does "later wins" mean for security- + sensitive fields such as egress, environment variables, volumes, and startup + commands? +- **Agent kit inference:** How should `acq run opencode .` infer an OpenCode + agent kit? How does the user inspect, override, or disable that inference? +- **Agent-aware provider config:** Should `usai-provider` remain one kit that + emits configuration in the selected agent's format, or should each agent kit + own its provider-config translation? +- **OCI engine kit:** Should rootless podman setup move out of the msb adapter + into a default OCI engine kit that can be applied consistently by both + backends? +- **Bring-your-own images:** What is the minimum base-image contract `acq` can + require when users provide their own `BASE_IMAGE` or `ACQ_IMAGE`? +- **Dotfiles and personal configuration:** Are host dotfiles best represented as + devenv configuration, a personal kit, a mounted volume, or explicit copy-in + behavior? +- **Compatibility:** How much current `acq run opencode .` behavior must remain + unchanged during migration? + +## Consequences + +### Positive Consequences + +- Core `acq` becomes less coupled to any one agent harness. +- New agent support can be developed and reviewed as kit behavior. +- The base-image story becomes simpler: generic devenv substrate first, + agent-populated images only as optional cache optimizations. +- The same environment/capability model can span msb and sbx. +- Kit-authoring agents and humans can rely on the broader devenv ecosystem. + +### Negative Consequences + +- devenv becomes load-bearing architecture and must be documented, versioned, and + verified as part of the sandbox contract. +- Kit merge semantics become more important and need security review. +- Debugging startup failures may require better introspection into generated + devenv config and kit application order. +- Agent install steps must be idempotent and safe to re-run. + +### Compliance Consequences + +- **AC-6 / CM-7:** Moving capabilities into explicit kits supports least + privilege and least functionality, provided optional kits cannot silently widen + permissions. +- **CM-2 / CM-3 / CM-6:** A declarative kit and base-image contract improves + configuration baselines and change traceability. +- **SA-8 / SA-15:** Separating harness, environment, and sandbox concerns makes + architecture review and secure development responsibilities clearer. +- **SC-7:** Egress declarations must fail closed and remain auditable when merged + across built-in, team, and personal kits. + +## Links + +- [ADR-0010: acq Pluggable Backends](0010-acq-pluggable-backends.md) +- [ADR-0011: MSB Backend and Neutral Kits](0011-msb-backend-and-neutral-kits.md) +- [ADR-0018: MSB Balanced Egress Baseline](0018-msb-balanced-egress-baseline.md) +- [ADR-0020: Ensure an OCI container engine in msb sandboxes via rootless podman](0020-msb-oci-engine-via-podman.md) +- [ADR-0022: Neutral Image Override](0022-neutral-image-override.md) +- [ADR-0023: Neutral Volumes Kit Vocabulary](0023-neutral-volumes-kit-vocabulary.md) +- [GSA-TTS/agentic-coding-quickstart#473: discussion issue](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/473) +- [GSA-TTS/agentic-coding-patterns#395: devenv base image pattern](https://github.com/GSA-TTS/agentic-coding-patterns/pull/395) From 78e5e60186d1813d259631eaa887e5f24a18d10c Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 16 Sep 2026 01:42:30 -0700 Subject: [PATCH 02/37] docs(adr): renumber devenv agent-kits ADR to 0030 0028 is claimed by the Windows-native secret storage ADR and 0029 by another in-flight PR, so the agent-kits-on-devenv ADR takes 0030. Rename only; content unchanged. Refs: GSA-TTS/agentic-coding-quickstart#464, GSA-TTS/agentic-coding-quickstart#474 --- ...kits-on-devenv-base.md => 0030-agent-kits-on-devenv-base.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/adr/{0028-agent-kits-on-devenv-base.md => 0030-agent-kits-on-devenv-base.md} (99%) diff --git a/docs/adr/0028-agent-kits-on-devenv-base.md b/docs/adr/0030-agent-kits-on-devenv-base.md similarity index 99% rename from docs/adr/0028-agent-kits-on-devenv-base.md rename to docs/adr/0030-agent-kits-on-devenv-base.md index 2cbb462f..f64c2592 100644 --- a/docs/adr/0028-agent-kits-on-devenv-base.md +++ b/docs/adr/0030-agent-kits-on-devenv-base.md @@ -11,7 +11,7 @@ risk_treatment: mitigate supersedes: [] --- -# ADR-0028: Use Agent Kits on a Devenv-Enabled Agent-Less Base Image +# ADR-0030: Use Agent Kits on a Devenv-Enabled Agent-Less Base Image ## Context and Problem Statement From 66a8bccc10529ce4d455d676a668161b5c6492d2 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 16 Sep 2026 01:43:08 -0700 Subject: [PATCH 03/37] docs(adr): add devenv 2.x baseline and upstream impact Records that the patterns devenv image currently ships devenv 1.11.2 (nixos-25.05) and that devenv 2.x first lands in nixos-26.05 (2.1.2) or nixpkgs-unstable (2.2/2.3), then maps the 2.x changes that affect this ADR: native process supervision, devenv.nix-keyed activation, out-of-tree --from/devenv allow, port allocation and the localhost proxy, bundled SecretSpec, JSON introspection, and the smaller faster footprint. Refs: GSA-TTS/agentic-coding-patterns#395 --- docs/adr/0030-agent-kits-on-devenv-base.md | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/adr/0030-agent-kits-on-devenv-base.md b/docs/adr/0030-agent-kits-on-devenv-base.md index f64c2592..5ce806ae 100644 --- a/docs/adr/0030-agent-kits-on-devenv-base.md +++ b/docs/adr/0030-agent-kits-on-devenv-base.md @@ -34,6 +34,56 @@ The proposed direction is to make the sandbox image provide a generic devenv substrate, move agent installation and configuration into kits, and have `acq` compose those declarations consistently across backends. +## Devenv Baseline and Upstream Impact + +The substrate this ADR depends on is currently behind the releases that ship the +behaviors it leans on. `integrations/isolation/images/devenv/` in the patterns +repository pins its toolchain to `nixos-25.05` and installs `devenv` from that +channel, which resolves to **devenv 1.11.2**. devenv 2.x is present in neither +`nixos-25.05` nor `nixos-25.11`; it first appears in `nixos-26.05` (2.1.2) and in +`nixpkgs-unstable` (2.2/2.3). The devenv major is therefore part of the substrate +contract, not an incidental of the pin. + +Changes since devenv 2.0 that bear directly on this ADR: + +- **Process supervision.** devenv 2.0 makes its native Rust process manager the + default (dependency ordering, readiness probes, watchdog, socket activation, + restart policies). 2.2 adds a shared manager that other terminals attach to, + cold-starts a named process, and stops with `devenv down`; 2.3 adds shutdown + signal and grace. Detached manager state lives in `.devenv`, so the workspace + needs a persistent path for it. +- **Activation.** 2.0 makes `devenv shell` self-reloading and direnv optional; + 2.2 keys auto-activation on `devenv.nix` rather than `devenv.yaml`, and + `devenv init` no longer writes `.envrc` by default. A workspace must therefore + carry a `devenv.nix` to auto-activate. +- **Out-of-tree environments.** 2.0 adds `--from`; 2.2 makes a `--from` source + persistent per directory via `devenv allow`, carrying profiles and (for local + sources) the source's full `devenv.yaml`. A substrate can now be referenced at + run time instead of only baked into an image. +- **Ports.** 2.0 adds automatic port allocation and a strict-port mode; 2.3 adds + a localhost reverse proxy with per-process hostnames, optional HTTPS via + `mkcert`, and Linux capabilities for privileged binds. +- **Secrets.** 2.x bundles SecretSpec, a provider-based secret manager that + prompts before releasing values. +- **Introspection.** `devenv eval`, `devenv build`, and `devenv tasks list` + expose configuration and task state as JSON, and traces identify the invoking + caller. These are documented, semver-versioned surfaces. +- **Footprint.** The devenv closure shrank from 528 MB to 376 MB and bundles + Nix 2.35.2; the nixpkgs-wide ELF-note loader cache and a statically linked + devenv build lower startup cost further. +- **Platforms.** 2.2 drops `x86_64-darwin`. The sandbox is Linux-only, so this + affects contributor hosts but not the image matrix. + +Consequences for the substrate and the kit contract: + +- The base image must move to a channel that carries the intended devenv major, + and the devenv major must be recorded as part of the substrate contract. +- Kit-declared processes and any status `acq` inspects must account for + `.devenv` state persistence and for devenv's automatic quiet mode when it + detects a coding agent (`DEVENV_NO_AI_AGENT=1` opts out). +- No existing kit uses devenv, so there is no 1.x-to-2.x migration cost for kit + authors; kits can target 2.x directly. + ## Decision Drivers - **Reduce agent-specific behavior in core `acq`.** `acq` should orchestrate From 725a6640d96859f8d7b841222c3605efcfbdaa21 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 16 Sep 2026 01:44:51 -0700 Subject: [PATCH 04/37] docs(adr): incorporate review feedback on agent-kit/devenv layering Records the review outcomes for the questions the ADR left open: a content-pinned agent install contract; human-owned activation with an explicit ACQ_ env opt-in for non-interactive use; a create-time vs run-time rule plus a max-merged resources: field; the native devenv process manager as the general in-guest supervisor (host side uses podman-compose); provider facts exported by usai-provider and rendered by each agent kit; a /nix-anchored bring-your-own-image contract; union-only visible egress; a personal kit plus a neutral ~/.rc.d hook with documented use-cases; built-in-bundle-only agent kit inference; an optional OCI engine kit; declared privileged capabilities under passwordless sudo; SecretSpec confined to in-sandbox usage; and the stability contract to preserve during migration. Leaves three follow-ups open: merge semantics beyond egress, the exact minimum image contract, and migration sequencing. Refs: GSA-TTS/agentic-coding-quickstart#473, GSA-TTS/agentic-coding-quickstart#474 --- docs/adr/0030-agent-kits-on-devenv-base.md | 183 +++++++++++++++++---- 1 file changed, 148 insertions(+), 35 deletions(-) diff --git a/docs/adr/0030-agent-kits-on-devenv-base.md b/docs/adr/0030-agent-kits-on-devenv-base.md index 5ce806ae..8d917dd9 100644 --- a/docs/adr/0030-agent-kits-on-devenv-base.md +++ b/docs/adr/0030-agent-kits-on-devenv-base.md @@ -161,16 +161,16 @@ acq └─ kits ├─ built-in bundle │ ├─ neutral: CA trust, playbook, git signing, shared security policy - │ ├─ OCI engine: podman or equivalent container-run capability + │ ├─ OCI engine: podman or equivalent (optional, not in the default set) │ ├─ agent: opencode | pi | goose | prime-agent | ... - │ └─ agent-aware: USAi/provider config in the selected agent's format + │ └─ provider facts: USAi endpoint, key env var, model catalog └─ extra kits ├─ team kit └─ personal kit ``` -This ADR is intentionally proposed, not accepted. It records the direction and -questions for discussion before implementation. +This ADR is intentionally proposed, not accepted. It records the direction, the +review outcomes, and the questions that remain open before implementation. ## Lifecycle Sketch @@ -190,37 +190,141 @@ At run or attach time: 1. Start the sandbox if needed. 2. Re-apply idempotent file, environment, and startup declarations as required. 3. Attach using the selected agent kit's declared command. -4. Activate the workspace's devenv/direnv environment according to the final - contract. +4. Surface any pending workspace activation (devenv or direnv) for the human to + approve, per the activation contract. + +## Review Outcomes + +Review of the proposed direction converged on the following answers to the +questions above. They are recorded as the intended shape for implementation. + +### Agent install contract + +Agent binaries are installed at create time by their agent kit and **pinned by +content**: `nix profile install github:NixOS/nixpkgs/#` at the +substrate's declared rev (content-hashed, cached, and identical across arches), +or a sha256-pinned release binary for agents nixpkgs does not carry (the +goose-server pattern). Create-time cost is once per sandbox and is acceptable +next to the image pull, the Nix seed, and the first devenv build. Any published +agent-populated image is an artifact produced by applying the kit, so the image +and the kit cannot drift. The current create-time fallback +(`npm install -g opencode-ai` resolving to latest) is not the contract. + +### Activation + +Activation is a human trust decision. The base image ships Nix, devenv, direnv, +and the shell hook; the workspace owns its `.envrc`; `acq`'s job at run or attach +time ends at giving `exec` and `run` a login shell in the primary repository. +`acq` may detect an unactivated workspace, surface the command, and — only on +explicit confirmation — invoke it. It never auto-approves and never runs +activation non-interactively, **unless the user has explicitly opted in through +an `ACQ_` environment variable** (for example `ACQ_AUTO_ACTIVATION=1`), which +delegates the trust decision to the user's own environment. Because direnv and +devenv trust is path-scoped, per-worktree approval is a property of the +workspace, not something `acq` should globalize; preferring `devenv shell` or a +shared `--from` source keeps the trust unit at the project level. + +### Create-time versus run-time, and `resources` + +Create-time is anything the backend must know before boot: egress, volumes, +ports, and resources. Run-time is files, environment, and startup steps, which +are re-applied on msb on every run. The vocabulary needs a neutral `resources:` +field for CPU and memory, **max-merged** across kits, matching the two-halves +shape of `volumes:`. Today `acq` sizes msb guests from an environment variable +only, so a kit cannot express what its builds actually need. + +### Process supervision + +The general in-guest supervisor is devenv's native process manager. +Agent-internal supervision (for example goose's `background:`) is a kit +implementation detail and is not modeled by `acq`. Out-of-guest services are the +host's concern and use podman-compose, chosen for cross-platform support +including Windows (devenv does not run on Windows), rather than a second devenv +supervision story. + +### Provider configuration + +`usai-provider` exports the provider **facts** (endpoint, key environment +variable, model catalog); each agent kit **renders** those facts in its own +configuration format. `acq` never merges agent configuration files — the agent +owns its config-merge semantics, for example OpenCode's project-layer deep merge +for its permission gate. + +### Bring-your-own image contract + +The contract must pin **where the Nix store lives** (`/nix`), not merely require +that nix, devenv, and direnv exist. A kit volume that seeds and shadows a baked +store (as the login.gov team kit does) breaks silently otherwise. The workspace +must also carry a `devenv.nix` so auto-activation applies. + +### Egress and kit conflicts + +Egress is **union-only and visible**: a personal kit must not widen network reach +without a trace. "Later wins" applies to environment and files, not to egress. + +### Personal dotfiles + +Personal preferences are a personal kit (via `ACQ_EXTRA_KITS`) that delivers +files, pinned `nix profile install` tools, and shell snippets. The neutral +`~/.rc.d` sourcing hook belongs in the base-image contract or a built-in neutral +kit, not in a team kit. Adopt the hook broadly, with documented guardrails: it is +kit-owned rather than user-editable, sourced for bash and zsh in deterministic +lexical order, handled through their native conf.d mechanisms for fish and +nushell, never used for secrets, and not used to duplicate what devenv already +provides inside a devenv shell. Documented use-cases: the hook itself (neutral +kit); agent shell integration that must exist outside a devenv shell; team tool +environment and completions; personal aliases and functions; and the +direnv/devenv hook. + +### Agent kit inference + +`acq run ` resolves the agent kit by name match against the +**built-in bundle only**, never against extra kits, so a team or personal kit +named after an agent cannot silently become that agent. The create output (or an +equivalent such as `acq kit ls`) prints the selected kit; an explicit `--kit` +wins; `shell` selects no agent kit. + +### OCI engine kit + +Rootless podman setup moves out of the msb adapter into an explicit OCI engine +kit usable by both backends, but it is **optional and not part of the default +bundle**: it is a create-time install plus egress for every sandbox, and many +kits never need it. + +### Capabilities available through passwordless sudo + +`acq` grants the agent passwordless sudo, so devenv features that need privilege +— binding privileged ports, the localhost proxy, `mkcert` CA trust, and +`linux.capabilities` — are technically available in-guest. They must be +explicitly declared by a kit and off by default, and treated like other in-guest +CA trust rather than enabled implicitly by a workspace. + +### SecretSpec scope + +SecretSpec is confined to in-sandbox usage. Host- and boundary-level secrets +remain in `acq`'s secret store; this ADR does not move the trust boundary into +SecretSpec. + +### Stability contract during migration + +The refactor must preserve: `--clone` semantics with the `ACQ_CLONE` and +`ACQ_WORKSPACE` guest markers +([ADR-0027](0027-neutral-clone-option.md)); startup steps re-applied on every msb +run; `ACQ_EXTRA_KITS` local paths applied after the built-in bundle; `acq exec` +and `acq shell` landing in the primary repository with a login shell; `ACQ_IMAGE` +as the image override; and `volumes:` mounted at boot before any exec. The +login.gov team's `scripts/verify` asserts each of these against a real `--clone` +sandbox on the active backend and can gate the refactor. ## Open Questions -- **Activation contract:** Does `acq` run `devenv up`, `devenv shell`, - `direnv allow`, or a combination? Which step belongs to the base image, the - kit layer, and the project workspace? -- **Process supervision:** Can devenv's process supervision replace ad hoc - supervisor loops used by related projects, and if so, what does `acq` need to - guarantee? -- **Create-time versus run-time declarations:** Which kit declarations must be - resolved before VM creation, and which can be re-applied safely on every run? -- **Kit ordering and conflicts:** What does "later wins" mean for security- - sensitive fields such as egress, environment variables, volumes, and startup - commands? -- **Agent kit inference:** How should `acq run opencode .` infer an OpenCode - agent kit? How does the user inspect, override, or disable that inference? -- **Agent-aware provider config:** Should `usai-provider` remain one kit that - emits configuration in the selected agent's format, or should each agent kit - own its provider-config translation? -- **OCI engine kit:** Should rootless podman setup move out of the msb adapter - into a default OCI engine kit that can be applied consistently by both - backends? -- **Bring-your-own images:** What is the minimum base-image contract `acq` can - require when users provide their own `BASE_IMAGE` or `ACQ_IMAGE`? -- **Dotfiles and personal configuration:** Are host dotfiles best represented as - devenv configuration, a personal kit, a mounted volume, or explicit copy-in - behavior? -- **Compatibility:** How much current `acq run opencode .` behavior must remain - unchanged during migration? +- **Merge semantics beyond egress:** "later wins" is accepted for environment + and files and "union" for egress, but volume and startup-step conflicts across + built-in, team, and personal kits still need a rule. +- **Minimum bring-your-own-image contract:** the exact required contents and how + `acq` verifies them (including `/nix`, `devenv.nix`, and the shell hook). +- **Migration sequencing:** the order in which `acq run opencode .` behavior + changes, and which regression checks gate each step. ## Consequences @@ -228,6 +332,8 @@ At run or attach time: - Core `acq` becomes less coupled to any one agent harness. - New agent support can be developed and reviewed as kit behavior. +- Agent binaries are installed pinned by content and provider configuration is + rendered per agent kit, so images need not bake any agent. - The base-image story becomes simpler: generic devenv substrate first, agent-populated images only as optional cache optimizations. - The same environment/capability model can span msb and sbx. @@ -237,6 +343,11 @@ At run or attach time: - devenv becomes load-bearing architecture and must be documented, versioned, and verified as part of the sandbox contract. +- `.devenv` (evaluation cache and detached process-manager state) must be + persisted for supervision and caching to work across runs. +- Privileged devenv capabilities (privileged binds, the localhost proxy, CA + trust) are reachable through passwordless sudo and must be declared per kit and + off by default. - Kit merge semantics become more important and need security review. - Debugging startup failures may require better introspection into generated devenv config and kit application order. @@ -246,13 +357,14 @@ At run or attach time: - **AC-6 / CM-7:** Moving capabilities into explicit kits supports least privilege and least functionality, provided optional kits cannot silently widen - permissions. + permissions; capability-widening features (privileged binds, CA trust) are + declared and off by default. - **CM-2 / CM-3 / CM-6:** A declarative kit and base-image contract improves configuration baselines and change traceability. - **SA-8 / SA-15:** Separating harness, environment, and sandbox concerns makes architecture review and secure development responsibilities clearer. -- **SC-7:** Egress declarations must fail closed and remain auditable when merged - across built-in, team, and personal kits. +- **SC-7:** Egress declarations are union-only and visible across built-in, + team, and personal kits, and a personal kit cannot widen reach without a trace. ## Links @@ -262,5 +374,6 @@ At run or attach time: - [ADR-0020: Ensure an OCI container engine in msb sandboxes via rootless podman](0020-msb-oci-engine-via-podman.md) - [ADR-0022: Neutral Image Override](0022-neutral-image-override.md) - [ADR-0023: Neutral Volumes Kit Vocabulary](0023-neutral-volumes-kit-vocabulary.md) +- [ADR-0027: Neutral Clone Option](0027-neutral-clone-option.md) - [GSA-TTS/agentic-coding-quickstart#473: discussion issue](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/473) - [GSA-TTS/agentic-coding-patterns#395: devenv base image pattern](https://github.com/GSA-TTS/agentic-coding-patterns/pull/395) From 5eef3b3e3578fa218faa2ba7bb96847f01d41826 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 16 Sep 2026 02:04:35 -0700 Subject: [PATCH 05/37] docs(adr): factor guest sizing out of the devenv adoption story Removes the resources: field ask from the create-time versus run-time section so guest CPU/memory sizing does not complicate the devenv adoption story, and tracks the user need separately. Refs: GSA-TTS/agentic-coding-quickstart#475, GSA-TTS/agentic-coding-quickstart#474 --- docs/adr/0030-agent-kits-on-devenv-base.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/adr/0030-agent-kits-on-devenv-base.md b/docs/adr/0030-agent-kits-on-devenv-base.md index 8d917dd9..3f41bf3a 100644 --- a/docs/adr/0030-agent-kits-on-devenv-base.md +++ b/docs/adr/0030-agent-kits-on-devenv-base.md @@ -224,14 +224,14 @@ devenv trust is path-scoped, per-worktree approval is a property of the workspace, not something `acq` should globalize; preferring `devenv shell` or a shared `--from` source keeps the trust unit at the project level. -### Create-time versus run-time, and `resources` +### Create-time versus run-time -Create-time is anything the backend must know before boot: egress, volumes, -ports, and resources. Run-time is files, environment, and startup steps, which -are re-applied on msb on every run. The vocabulary needs a neutral `resources:` -field for CPU and memory, **max-merged** across kits, matching the two-halves -shape of `volumes:`. Today `acq` sizes msb guests from an environment variable -only, so a kit cannot express what its builds actually need. +Create-time is anything the backend must know before boot: egress, volumes, and +ports. Run-time is files, environment, and startup steps, which are re-applied on +msb on every run. + +Guest sizing (CPU and memory) is a separate kit-vocabulary gap tracked in its +own issue (see Links). ### Process supervision @@ -375,5 +375,6 @@ sandbox on the active backend and can gate the refactor. - [ADR-0022: Neutral Image Override](0022-neutral-image-override.md) - [ADR-0023: Neutral Volumes Kit Vocabulary](0023-neutral-volumes-kit-vocabulary.md) - [ADR-0027: Neutral Clone Option](0027-neutral-clone-option.md) +- [GSA-TTS/agentic-coding-quickstart#475: neutral `resources:` kit field for guest CPU and memory](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/475) - [GSA-TTS/agentic-coding-quickstart#473: discussion issue](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/473) - [GSA-TTS/agentic-coding-patterns#395: devenv base image pattern](https://github.com/GSA-TTS/agentic-coding-patterns/pull/395) From e01db4cec93c1c0297f42bedce6474fbbe7df0ea Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 00:20:39 +0000 Subject: [PATCH 06/37] test(migration): gate opencode agent-kit migration --- docs/BACKEND_GUIDE.md | 50 ++++++ scripts/verify-backends | 26 ++- test/bats/140-opencode-migration-gate.bats | 197 +++++++++++++++++++++ 3 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 test/bats/140-opencode-migration-gate.bats diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 143fbbb9..8364326b 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -1146,6 +1146,56 @@ practical floor (`acq kit validate` warns below it). Manage kits with `acq kit list | validate PATH | apply NAME KITREF`. +### ADR-0030 migration sequencing: agent-kit/devenv model + +ADR-0030 moves the `acq run opencode .` implementation toward an agent-kit and +devenv model. The migration is deliberately sequenced so user-visible behavior +stays stable while internals move behind the existing `acq` surface. + +**Increment 0: freeze the current contract before moving code.** Keep the +observable backend-neutral contract green: `--clone` and `ACQ_CLONE=1`, +`ACQ_WORKSPACE`, `ACQ_EXTRA_KITS` before CLI `--kit`, `ACQ_IMAGE`, boot-time +`volumes`, `acq exec` workspace/user behavior, and `acq shell` login-shell +behavior. Verification: + +- `./scripts/test-acq-bats test/bats/140-opencode-migration-gate.bats` +- `./scripts/test-acq-bats test/bats/35-image-override.bats test/bats/116-clone-option.bats test/bats/110-volumes.bats test/bats/30-dispatch-routing.bats test/bats/70-msb-backend.bats` +- `./scripts/verify-backends --only sbx` on an sbx-capable host +- `./scripts/verify-backends --only msb` on an msb-capable host + +**Increment 1: introduce the opencode agent-kit inputs without changing dispatch.** +Add the new kit/devenv artifacts and validation in the patterns/team-kit layer, +but keep `acq run opencode .` wired through the existing wrapper behavior until +Increment 0 remains green. When the team kit ships its own verifier, run it from +that repository or checked-out kit path with `scripts/verify` in addition to the +Quickstart gates above. Unresolved schema or team-kit vocabulary questions stay +inside this increment; do not change `acq` dispatch to depend on them first. + +**Increment 2: switch `opencode` provisioning to consume the agent kit.** Replace +only the internal source of the opencode configuration/devenv setup. Preserve the +same create-time inputs and guest markers. Verification: + +- Increment 0 offline bats commands +- team-kit `scripts/verify` when available +- `./scripts/verify-backends --only sbx` +- `./scripts/verify-backends --only msb` + +**Increment 3: move devenv-specific setup behind the kit boundary.** Remove any +now-duplicated wrapper-side setup only after the kit verifier and Quickstart gates +prove the kit supplies it. Defer unresolved image-layout, login-shell, and +workspace-start-directory questions to this increment so earlier increments do +not overfit speculative behavior. Verification: + +- Increment 0 offline bats commands +- focused live `./scripts/verify-backends --only ` for every backend + whose setup changed +- team-kit `scripts/verify` when available + +**Increment 4: cleanup and documentation.** Only after all gates above pass, +remove obsolete wrapper code and update user docs. Re-run the full offline suite +(`./scripts/test-acq-bats`) and live backend verifier on installed backends before +claiming the ADR-0030 migration complete. + ### Kit-bundle provenance and stale-sandbox refresh `acq` records, host-side, which built-in bundle ref each sandbox was built from diff --git a/scripts/verify-backends b/scripts/verify-backends index 5f12023b..d2cb1736 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -2,9 +2,12 @@ # # verify-backends — HOST-SIDE end-to-end check that the four neutral acq kits # apply cleanly on each INSTALLED backend, and that the core kit behaviors work. -# Unlike scripts/test-acq (which stubs sbx/msb and runs anywhere), this exercises -# the REAL toolchain and therefore must run on a machine that can create -# sandboxes: +# It is also the live sequencing gate for the ADR-0030 migration of +# `acq run opencode .` to the agent-kit/devenv model: keep this verifier green +# before, during, and after the internal migration so the observable acq contract +# remains stable. Unlike scripts/test-acq-bats (which stubs sbx/msb and runs +# anywhere), this exercises the REAL toolchain and therefore must run on a +# machine that can create sandboxes: # - sbx: Docker + KVM, `sbx login` done, sbx >= 0.39.0 # - msb: host virtualization (KVM on Linux, HVF on macOS, WHP on Windows), # msb >= 0.6.0 @@ -27,6 +30,12 @@ # ssh-agent the verifier spins up itself (no dependence on the caller's # agent). # 4. The playbook clone symlink appears at the agent's AGENTS.md path. +# 5. The migration-sensitive boot contract stays intact: the verifier's +# ACQ_EXTRA_KITS volume mounts at boot, acq exec starts in the workspace as +# the agent user, and an opencode sandbox re-attaches through `acq run NAME -- +# --version`. Offline bats additionally freeze the fresh `acq run opencode .` +# dispatch surface: --clone / ACQ_CLONE, ACQ_WORKSPACE, --kit ordering, +# ACQ_IMAGE, boot volumes, and login-shell shape. # A backend that is NOT installed is SKIPPED (its row prints "skipped"). # # This CANNOT run inside an sbx/msb sandbox (no nested sandboxes) — see ADR-0011 @@ -1161,6 +1170,17 @@ verify_agent_install() { dump "exec output" "$LAST_OUT" fi + # Exercise the live attach/launch path without requiring a real USAi key. A + # fresh `acq run opencode "$WORKDIR" -- --version` also performs post-create + # USAi key validation and intentionally fails with the verifier's synthetic key; + # this name-reattach form drives the same backend attach path and cleanup target. + if run_acq "run-opencode-version" --backend "$backend" run "$aname" -- --version; then + pass "${backend}: acq run re-attaches opencode sandbox and launches --version" + else + fail "${backend}: acq run did not re-attach opencode sandbox and launch --version" + dump "acq run output" "$LAST_OUT" + fi + # The guest must have enough RAM to actually RUN a Node.js agent TUI. msb's # 512 MiB default OOM-kills opencode on launch (quickstart#228 follow-up: it # started, then the terminal died with "Killed"). Assert a generous ceiling diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats new file mode 100644 index 00000000..d5bbac6e --- /dev/null +++ b/test/bats/140-opencode-migration-gate.bats @@ -0,0 +1,197 @@ +#!/usr/bin/env bats +# +# 140-opencode-migration-gate.bats - regression gate for moving +# `acq run opencode .` to the ADR-0030 agent-kit/devenv model. +# +# These tests intentionally freeze the current observable contracts only. They do +# not implement agent kits or dev environments. A future migration can rewrite the +# internals, but these surfaces must stay stable across both backends. +# +# shellcheck shell=bats + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +_mk_repo() { # PATH + mkdir -p "$1" + git -C "$1" init -q + printf 'one\n' >"$1/file.txt" + git -C "$1" add file.txt + git -C "$1" -c user.email=t@example.gov -c user.name=t -c commit.gpgsign=false commit -q -m init +} + +_msb_run_gate() { # [ENV KEY=VAL...] -- ARGS... + local env_kv=() + while [ "$1" != "--" ]; do env_kv+=("$1"); shift; done + shift + run bash -c ' + tag="$1"; shift + export ACQ_MSB_KIT_PASSTHROUGH=1 ACQ_UPDATE_CHECK=0 + export ACQ_STATE_DIR="'"$STUBDIR"'/state-$tag" + while [ "$1" != "--" ]; do export "$1"; shift; done + shift + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-$tag" + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + printf "USAI-REAL\n" | acq_secret_store "$(_acq_secret_key usai)" >/dev/null + ACQ_BACKEND=msb "'"$ACQ"'" "$@" 2>&1 >/dev/null + ' _ "$BATS_TEST_NUMBER" ${env_kv[@]+"${env_kv[@]}"} -- "$@" +} + +_seed_sbx_usai() { + printf 'sk-test\n' | env ACQ_BACKEND=sbx "$ACQ" secret set -g usai >/dev/null 2>&1 || true + seed_sbx_usai_proxy_fixture +} + +_create_line() { printf '%s\n' "$(cat "$CALLS")" | grep "^$1 create"; } + +@test "migration gate: common kit order is built-ins, ACQ_EXTRA_KITS, then CLI --kit" { + load_acq + # shellcheck disable=SC2034 # read by sourced _build_kit_list + ACQ_EXTRA_KITS="/extra/env-one /extra/env-two" + # shellcheck disable=SC2034 # read by sourced _build_kit_list + ACQ_CLI_KITS=("/extra/cli-one" "/extra/cli-two") + _build_kit_list + + assert_equal "${KITS[0]}" "$ZSCALER_KIT" + assert_equal "${KITS[1]}" "$USAI_KIT" + assert_equal "${KITS[2]}" "$PLAYBOOK_KIT" + assert_equal "${KITS[3]}" "$GITSSHSIGN_KIT" + assert_equal "${KITS[4]}" "/extra/env-one" + assert_equal "${KITS[5]}" "/extra/env-two" + assert_equal "${KITS[6]}" "/extra/cli-one" + assert_equal "${KITS[7]}" "/extra/cli-two" +} + +@test "migration gate(msb): run opencode --clone preserves clone markers, image, kit order, and boot volumes" { + local proj="$STUBDIR/migproj" extra="$STUBDIR/extra-kit" cli="$STUBDIR/cli-kit" + _mk_repo "$proj" + mkdir -p "$extra" "$cli" + cat >"$extra/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: extra-kit +displayName: Extra Kit +description: env extra kit with boot-time volume +volumes: + - path: /cache + size: 256m +SPEC + cat >"$cli/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: cli-kit +displayName: CLI Kit +description: cli kit wins repeated boot-time volume path +volumes: + - path: /cache + size: 512m + - path: /scratch + type: tmpfs + size: 64m +SPEC + + _msb_run_gate ACQ_EXTRA_KITS="$extra" ACQ_IMAGE=localhost/acq-migration:test \ + STUB_RECORDED_AGENT=opencode STUB_AGENT_PRESENT=1 STUB_RECORDED_WORKSPACE="$proj" -- \ + run opencode --clone --kit "$cli" "$proj" -- --version + + local line repo scratch + load_acq + line=$(_create_line msb) + repo=$(canonicalize_path "$proj") + scratch="$STUBDIR/state-$BATS_TEST_NUMBER/clones/opencode-migproj/migproj" + + assert_regex "$line" "--volume $(canonicalize_path "$scratch"):${repo}( |$)" + refute_regex "$line" "--volume ${repo}:${repo}" + assert_regex "$line" "--env ACQ_WORKSPACE=${repo}( |$)" + assert_regex "$line" '--env ACQ_CLONE=1( |$)' + assert_regex "$line" 'localhost/acq-migration:test$' + assert_regex "$line" '--mount-named acq-opencode-migproj-cache-[0-9]+:/cache:kind=disk,size=512m' + assert_regex "$line" '--tmpfs /scratch:64m' + refute_regex "$line" 'size=256m' + + local log + log=$(cat "$CALLS") + assert_regex "$log" 'msb exec -t -u agent -w .* opencode-migproj -- opencode --version' +} + +@test "migration gate(msb): ACQ_CLONE=1 run opencode matches --clone markers" { + local proj="$STUBDIR/envclone-msb" + _mk_repo "$proj" + + _msb_run_gate ACQ_CLONE=1 STUB_RECORDED_AGENT=opencode STUB_AGENT_PRESENT=1 \ + STUB_RECORDED_WORKSPACE="$proj" -- run opencode "$proj" -- --version + + local line repo scratch + load_acq + line=$(_create_line msb) + repo=$(canonicalize_path "$proj") + scratch="$STUBDIR/state-$BATS_TEST_NUMBER/clones/opencode-envclone-msb/envclone-msb" + assert_regex "$line" "--volume $(canonicalize_path "$scratch"):${repo}( |$)" + assert_regex "$line" "--env ACQ_WORKSPACE=${repo}( |$)" + assert_regex "$line" '--env ACQ_CLONE=1( |$)' + refute_regex "$line" '--clone' + assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w .* opencode-envclone-msb -- opencode --version' +} + +@test "migration gate(msb): acq exec remains non-login; acq shell uses the agent login shell" { + printf 'migbox\n' >"$STUBDIR/.msb_sandbox_list" + printf 'migbox\n' >"$STUBDIR/.msb_running_list" + + run env -u EMAIL -u GIT_AUTHOR_NAME -u GIT_AUTHOR_EMAIL -u GIT_COMMITTER_NAME -u GIT_COMMITTER_EMAIL \ + HOME="$STUBDIR/nohome" XDG_CONFIG_HOME="$STUBDIR/noconfig" GIT_CONFIG_NOSYSTEM=1 \ + STUB_RECORDED_WORKSPACE=/workspace STUB_AGENT_PASSWD_SHELL=/bin/bash \ + ACQ_BACKEND=msb "$ACQ" exec migbox -- pwd + assert_success + local exec_line + exec_line=$(grep '^msb exec -u agent .* migbox -- pwd' "$CALLS") + assert_regex "$exec_line" '-w /workspace' + refute_regex "$exec_line" ' -t ' + refute_regex "$exec_line" 'SHELL=/bin/bash' + refute_regex "$exec_line" '/bin/bash -l' + + : >"$CALLS" + run env -u EMAIL -u GIT_AUTHOR_NAME -u GIT_AUTHOR_EMAIL -u GIT_COMMITTER_NAME -u GIT_COMMITTER_EMAIL \ + HOME="$STUBDIR/nohome" XDG_CONFIG_HOME="$STUBDIR/noconfig" GIT_CONFIG_NOSYSTEM=1 \ + TERM=xterm-256color STUB_RECORDED_WORKSPACE=/workspace STUB_AGENT_PASSWD_SHELL=/bin/bash \ + ACQ_BACKEND=msb "$ACQ" shell migbox + assert_success + assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /workspace -e TERM=xterm-256color -e SHELL=/bin/bash migbox -- /bin/bash -l' +} + +@test "migration gate(sbx): run opencode keeps native clone, workspace markers, image template, and kit order" { + local proj="$STUBDIR/sbxproj" + _mk_repo "$proj" + _seed_sbx_usai + run env ACQ_BACKEND=sbx ACQ_EXTRA_KITS="/extra/env-one /extra/env-two" ACQ_IMAGE=ghcr.io/acq/migration:v1 \ + STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 "$ACQ" run opencode --clone --kit /extra/cli-one "$proj" -- --version + assert_success + + local line logical + line=$(_create_line sbx) + logical=$(cd "$proj" && pwd) + assert_regex "$line" '--clone' + assert_regex "$line" "--env ACQ_WORKSPACE=${logical}( |$)" + assert_regex "$line" '--env ACQ_CLONE=1( |$)' + assert_regex "$line" '--template ghcr\.io/acq/migration:v1' + assert_regex "$line" '--kit /extra/env-one .*--kit /extra/env-two .*--kit /extra/cli-one' + assert_regex "$(cat "$CALLS")" 'sbx run --name opencode-sbxproj -- --version' +} + +@test "migration gate(sbx): ACQ_CLONE=1 run opencode matches --clone markers" { + local proj="$STUBDIR/envclone-sbx" + _mk_repo "$proj" + _seed_sbx_usai + run env ACQ_BACKEND=sbx ACQ_CLONE=1 STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 \ + "$ACQ" run opencode "$proj" -- --version + assert_success + + local line logical + line=$(_create_line sbx) + logical=$(cd "$proj" && pwd) + assert_regex "$line" '--clone' + assert_regex "$line" "--env ACQ_WORKSPACE=${logical}( |$)" + assert_regex "$line" '--env ACQ_CLONE=1( |$)' + assert_regex "$(cat "$CALLS")" 'sbx run --name opencode-envclone-sbx -- --version' +} From 80d0f1c875a9ed038ce1c5245f251049fbeb9e97 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 05:01:29 +0000 Subject: [PATCH 07/37] feat(agent-kits): infer built-in agent kit metadata --- acq | 11 +++- acq.backends/agents.sh | 43 +++++++++++++++ acq.backends/common.sh | 69 +++++++++++++++++++++++-- acq.backends/msb.sh | 41 ++++----------- test/bats/30-dispatch-routing.bats | 42 +++++++++++++++ test/bats/36-agent-catalog.bats | 23 +++++++++ test/bats/50-kit-list-completeness.bats | 57 ++++++++++++++++++++ test/bats/95-kit-ref-persistence.bats | 7 ++- 8 files changed, 256 insertions(+), 37 deletions(-) diff --git a/acq b/acq index 77dc8d6a..a09db177 100755 --- a/acq +++ b/acq @@ -1039,7 +1039,13 @@ case "$subcommand" in # would silently re-pin the host port of every later sandbox in the shell. fi extract_kit_flags "$@" - [ "${#ACQ_CLI_KITS[@]}" -gt 0 ] && _build_kit_list # re-fold with CLI kits + _acq_kit_agent="" + _acq_kit_first=$(first_positional "${ACQ_KIT_REMAINING[@]}" || true) + if [ -n "$_acq_kit_first" ] && is_known_agent "$_acq_kit_first"; then + _acq_kit_agent="$_acq_kit_first" + fi + _build_kit_list "$_acq_kit_agent" + unset _acq_kit_agent _acq_kit_first # Rebuild positional args as: . set -- "$_sub" ${ACQ_KIT_REMAINING[@]+"${ACQ_KIT_REMAINING[@]}"} ;; @@ -1078,6 +1084,7 @@ case "$subcommand" in preflight_workspace_path "$ws" || exit 1 local_name=$(derive_name "$@") || exit 1 [ -n "$local_name" ] || { echo "acq: could not determine sandbox name" >&2; exit 1; } + create_agent=$(first_positional "$@") # The USAi API key must be stored before provisioning: msb binds secrets only # at create time, so a sandbox created before the key exists carries no USAi @@ -1095,6 +1102,7 @@ case "$subcommand" in # just proceeds without a scoped token (the token is optional). [ -n "$ws" ] || ws="$PWD" advise_github_scope "$local_name" "$ws" + acq_print_selected_agent_kit "$create_agent" acq_backend_provision "$local_name" "$@" @@ -1130,6 +1138,7 @@ case "$subcommand" in # AGENT form: create if missing, then attach. local_name=$(derive_name "${create_args[@]}") || exit 1 [ -n "$local_name" ] || { echo "acq: could not determine sandbox name" >&2; exit 1; } + acq_print_selected_agent_kit "$first" # Pre-flight: validate the workspace path before provisioning (#issue). # Only enforce on a fresh create — a re-attach to an existing sandbox diff --git a/acq.backends/agents.sh b/acq.backends/agents.sh index 8da32472..d5532fe9 100644 --- a/acq.backends/agents.sh +++ b/acq.backends/agents.sh @@ -61,3 +61,46 @@ acq_agent_has_msb_install_recipe() { *) return 1 ;; esac } + +# Agent-specific built-in kit metadata. Only the built-in bundle participates in +# implicit selection; team/personal kits supplied through ACQ_EXTRA_KITS or --kit +# are never matched by agent name. +acq_agent_builtin_kit_name() { + case "$1" in + opencode) printf '%s\n' opencode ;; + shell|"") return 1 ;; + *) return 1 ;; + esac +} + +acq_agent_kit_entrypoint() { + case "$1" in + opencode) printf '%s\n' opencode ;; + shell|"") return 1 ;; + *) return 1 ;; + esac +} + +acq_agent_kit_install_owner() { + case "$1" in + opencode) printf '%s\n' kit ;; + shell|"") return 1 ;; + *) return 1 ;; + esac +} + +acq_agent_kit_start_owner() { + case "$1" in + opencode) printf '%s\n' kit ;; + shell|"") return 1 ;; + *) return 1 ;; + esac +} + +acq_agent_builtin_kit_enabled() { + case "$1" in + opencode) return 1 ;; + shell|"") return 1 ;; + *) return 1 ;; + esac +} diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 1f0ee7a0..29aa4375 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -45,8 +45,7 @@ ZSCALER_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR GITSSHSIGN_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR}/git-ssh-sign" # Neutral kit directory names (relative to PATTERNS_KIT_DIR), in apply order. -# kit-translate.sh resolves a kit's spec.yaml + files/ from these names. The -# built-in kit set maps 1:1 to the four *_KIT refs above. +# kit-translate.sh resolves a kit's spec.yaml + files/ from these names. # # ORDER MATTERS: zscaler-ca-certificate is applied FIRST so its CA trust is in # place before any later kit makes an outbound HTTPS request. Behind a @@ -184,6 +183,7 @@ ACQ_UPDATE_CHECK="${ACQ_UPDATE_CHECK:-1}" # neutral schema). Folded into the kit list by _build_kit_list, alongside # ACQ_EXTRA_KITS. One ref per element. ACQ_CLI_KITS=() +ACQ_BUILTIN_KIT_COUNT=0 # Host-port overrides supplied on the command line via `--publish HOST:GUEST` # (repeatable) on run/create, one `HOST:GUEST` pair per element (ADR-0034). @@ -631,11 +631,72 @@ split_noglob() { eval "$_name=(\"\$@\")" } -# Assemble the full kit list: built-ins, then extras. +_acq_builtin_kit_ref() { + case "$1" in + zscaler-ca-certificate) printf '%s\n' "$ZSCALER_KIT" ;; + usai-provider) printf '%s\n' "$USAI_KIT" ;; + agentic-coding-playbook) printf '%s\n' "$PLAYBOOK_KIT" ;; + git-ssh-sign) printf '%s\n' "$GITSSHSIGN_KIT" ;; + *) return 1 ;; + esac +} + +_acq_builtin_support_kit_names() { + printf '%s\n' zscaler-ca-certificate usai-provider agentic-coding-playbook git-ssh-sign +} + +_acq_agent_builtin_kit_ref() { + local kit_name + kit_name=$(acq_agent_builtin_kit_name "$1") || return 1 + printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$kit_name" +} + +_acq_selected_builtin_kit_refs() { + local agent="${1:-}" name kit + for name in $(_acq_builtin_support_kit_names); do + kit=$(_acq_builtin_kit_ref "$name") || return 1 + printf '%s\n' "$kit" + done + if [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] && [ -n "$agent" ] \ + && acq_is_known_agent "$agent" && acq_agent_builtin_kit_enabled "$agent"; then + kit=$(_acq_agent_builtin_kit_ref "$agent") || return 1 + printf '%s\n' "$kit" + fi +} + +acq_selected_agent_kit_summary() { + local agent="${1:-}" kit_name entrypoint install_owner start_owner apply_state="deferred" + [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] || return 1 + kit_name=$(acq_agent_builtin_kit_name "$agent") || return 1 + entrypoint=$(acq_agent_kit_entrypoint "$agent") || entrypoint="" + install_owner=$(acq_agent_kit_install_owner "$agent") || install_owner="" + start_owner=$(acq_agent_kit_start_owner "$agent") || start_owner="" + acq_agent_builtin_kit_enabled "$agent" && apply_state="enabled" + printf 'agent=%s kit=%s entrypoint=%s install_owner=%s start_owner=%s apply=%s\n' \ + "$agent" "$kit_name" "${entrypoint:-none}" "${install_owner:-none}" \ + "${start_owner:-none}" "$apply_state" +} + +acq_print_selected_agent_kit() { + local agent="${1:-}" summary + summary=$(acq_selected_agent_kit_summary "$agent") || return 0 + if [ "${summary##*apply=}" = "enabled" ]; then + printf 'acq: selected built-in agent kit: %s\n' "$summary" >&2 + else + printf 'acq: built-in agent kit candidate (deferred; no agent kit applied): %s\n' "$summary" >&2 + fi +} + +# Assemble the full kit list: selected built-ins, then extras. # Zscaler CA trust FIRST (see ACQ_KIT_NAMES) so later network-fetching kits # succeed behind a TLS-intercepting proxy. _build_kit_list() { - KITS=("$ZSCALER_KIT" "$USAI_KIT" "$PLAYBOOK_KIT" "$GITSSHSIGN_KIT") + local agent="${1:-}" kit + KITS=() + while IFS= read -r kit; do + [ -n "$kit" ] && KITS+=("$kit") + done < <(_acq_selected_builtin_kit_refs "$agent") + ACQ_BUILTIN_KIT_COUNT="${#KITS[@]}" if [ -n "$ACQ_EXTRA_KITS" ]; then local _extra_kits=() split_noglob _extra_kits "$ACQ_EXTRA_KITS" diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index ba1129d0..52cc16f7 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -3110,20 +3110,12 @@ acq_backend_provision() { # by _acq_msb_vsock_flags_into when an ssh-agent forward is emitted. See ADR-0021. _ACQ_MSB_SSH_AGENT_FORWARDING=0 - # Fetch each built-in kit and gather its create-time contributions. + # Fetch each selected kit and gather its create-time contributions. # Zscaler CA trust FIRST so later network-fetching kits (playbook clone, USAi # validation) succeed behind a TLS-intercepting proxy (e.g. Zscaler). local kitref kitdir - local kits=("$ZSCALER_KIT" "$USAI_KIT" "$PLAYBOOK_KIT" "$GITSSHSIGN_KIT") - # Include any extra kits (env-supplied) and CLI-supplied --kit refs. - if [ -n "${ACQ_EXTRA_KITS:-}" ]; then - local _extras=() - split_noglob _extras "$ACQ_EXTRA_KITS" - kits+=("${_extras[@]}") - fi - if [ "${#ACQ_CLI_KITS[@]}" -gt 0 ]; then - kits+=("${ACQ_CLI_KITS[@]}") - fi + _build_kit_list "$agent" + local kits=("${KITS[@]}") for kitref in "${kits[@]}"; do kitdir=$(_acq_msb_fetch_kit "$kitref") || { @@ -5696,26 +5688,13 @@ acq_backend_ensure_kits_applied() { # passwd-shell setup existed. Best-effort like the rest of the heal. _acq_msb_ensure_agent_user "$name" || \ echo "acq(msb): warning: agent-user heal failed for '$name'." >&2 - local kits=("$ZSCALER_KIT" "$USAI_KIT" "$PLAYBOOK_KIT" "$GITSSHSIGN_KIT") - local builtin_count="${#kits[@]}" - if [ -n "${ACQ_EXTRA_KITS:-}" ]; then - local _extras=() - split_noglob _extras "$ACQ_EXTRA_KITS" - kits+=("${_extras[@]}") - fi - # CLI-supplied `--kit ` refs (ACQ_CLI_KITS) MUST be healed too, exactly as - # the provision path folds them in (see acq_backend_provision's kit assembly). - # These kits' STARTUP-phase commands (e.g. openchamber's supervisor loops for - # the shared `opencode serve` and the web UI) are re-run only by this heal — - # `msb start` alone does not replay them (ADR-0017). Omitting them here meant a - # resumed/rebooted sandbox came back with the create-time `-p` port mappings - # intact but NOTHING listening behind them, because the kit's startup was never - # re-run: `acq ports` showed the ports mapped while the services were dead. Fold - # ACQ_CLI_KITS in so `acq run --kit … ` heals its full kit set. - if [ "${#ACQ_CLI_KITS[@]}" -gt 0 ]; then - kits+=("${ACQ_CLI_KITS[@]}") - fi - local ok=1 + # Rebuild after any persisted refs were loaded by the dispatcher. CLI-supplied + # `--kit ` refs must be healed too, exactly as the provision path folds + # them in, so resumed kit services come back with their startup re-run. + _build_kit_list "" + local kits=("${KITS[@]}") + local builtin_count="${ACQ_BUILTIN_KIT_COUNT:-4}" + local kitref kitdir i=0 ok=1 acq_spin_start "Refreshing configuration kits" _acq_msb_heal_kit_set "$name" "$builtin_count" "${kits[@]}" || ok=0 acq_spin_stop "Refreshing configuration kits" diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 0835b5c8..35c3a57c 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -417,3 +417,45 @@ _seed_usai() { refute_regex "$create_line" '--kit evil-agent-arg' assert_regex "$log" '--kit evil-agent-arg' } + +@test "agent-kits: run opencode reports inferred built-in kit" { + local proj="$STUBDIR/agentkit-opencode"; mkdir -p "$proj" + _seed_usai + run env ACQ_BACKEND=sbx "$ACQ" run opencode "$proj" + + assert_success + assert_output --partial 'built-in agent kit candidate (deferred; no agent kit applied): agent=opencode kit=opencode entrypoint=opencode install_owner=kit start_owner=kit apply=deferred' + refute_regex "$(cat "$CALLS")" 'acq-kits/opencode' +} + +@test "agent-kits: create opencode reports inferred built-in kit" { + local proj="$STUBDIR/agentkit-create"; mkdir -p "$proj" + _seed_usai + run env ACQ_BACKEND=sbx "$ACQ" create opencode "$proj" + + assert_success + assert_output --partial 'built-in agent kit candidate (deferred; no agent kit applied): agent=opencode kit=opencode entrypoint=opencode install_owner=kit start_owner=kit apply=deferred' + refute_regex "$(cat "$CALLS")" 'acq-kits/opencode' +} + +@test "agent-kits: explicit --kit suppresses implicit selection notice" { + local proj="$STUBDIR/agentkit-explicit"; mkdir -p "$proj" + _seed_usai + run env ACQ_BACKEND=sbx "$ACQ" run opencode --kit /tmp/team-opencode "$proj" + + assert_success + refute_output --partial 'built-in agent kit candidate' + local create_line; create_line=$(grep '^sbx create' "$CALLS") + assert_regex "$create_line" '--kit /tmp/team-opencode' + refute_regex "$create_line" 'acq-kits/opencode' +} + +@test "agent-kits: shell reports no inferred agent kit" { + local proj="$STUBDIR/agentkit-shell"; mkdir -p "$proj" + _seed_usai + run env ACQ_BACKEND=sbx "$ACQ" run shell "$proj" + + assert_success + refute_output --partial 'built-in agent kit candidate' + refute_regex "$(cat "$CALLS")" 'acq-kits/opencode' +} diff --git a/test/bats/36-agent-catalog.bats b/test/bats/36-agent-catalog.bats index cddecefb..ffee5016 100644 --- a/test/bats/36-agent-catalog.bats +++ b/test/bats/36-agent-catalog.bats @@ -82,3 +82,26 @@ load 'helper' run bash -c '. "'"$REPO_ROOT"'/acq.backends/agents.sh"; acq_is_known_agent prime-agent' assert_failure } + +@test "agents: opencode has built-in agent-kit metadata" { + run bash -c ' + . "'"$REPO_ROOT"'/acq.backends/agents.sh" + acq_agent_builtin_kit_name opencode + acq_agent_kit_entrypoint opencode + acq_agent_kit_install_owner opencode + acq_agent_kit_start_owner opencode + acq_agent_builtin_kit_enabled opencode || printf "apply-deferred\n" + ' + assert_success + assert_output $'opencode\nopencode\nkit\nkit\napply-deferred' +} + +@test "agents: shell selects no built-in agent kit" { + run bash -c ' + . "'"$REPO_ROOT"'/acq.backends/agents.sh" + acq_agent_builtin_kit_name shell || true + acq_agent_kit_entrypoint shell || true + ' + assert_success + assert_output "" +} diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index 89f23086..b84e8fcd 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -35,3 +35,60 @@ load 'helper' assert_equal "$bad" "0" done } + +@test "agent-kits: default kit list is support-only" { + load_acq + ACQ_EXTRA_KITS="" + ACQ_CLI_KITS=() + _build_kit_list + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + refute_regex "$(printf '%s\n' "${KITS[@]}")" 'acq-kits/opencode' +} + +@test "agent-kits: shell kit list is support-only" { + load_acq + ACQ_EXTRA_KITS="" + ACQ_CLI_KITS=() + _build_kit_list shell + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + refute_regex "$(printf '%s\n' "${KITS[@]}")" 'acq-kits/opencode' +} + +@test "agent-kits: opencode inference is visible while apply is deferred" { + load_acq + ACQ_EXTRA_KITS="" + ACQ_CLI_KITS=() + _build_kit_list opencode + + run acq_selected_agent_kit_summary opencode + assert_success + assert_output 'agent=opencode kit=opencode entrypoint=opencode install_owner=kit start_owner=kit apply=deferred' + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + refute_regex "$(printf '%s\n' "${KITS[@]}")" 'acq-kits/opencode' +} + +@test "agent-kits: explicit CLI kit suppresses implicit agent-kit inference" { + load_acq + ACQ_EXTRA_KITS="" + ACQ_CLI_KITS=(/tmp/team-opencode) + + run acq_selected_agent_kit_summary opencode + assert_failure + _build_kit_list opencode + local joined; joined=$(printf '%s\n' "${KITS[@]}") + assert_regex "$joined" '/tmp/team-opencode' + refute_regex "$joined" 'acq-kits/opencode' +} + +@test "agent-kits: ACQ_EXTRA_KITS never drive implicit selection" { + load_acq + ACQ_EXTRA_KITS="/tmp/opencode" + ACQ_CLI_KITS=() + _build_kit_list shell + + run acq_selected_agent_kit_summary shell + assert_failure + assert_regex "$(printf '%s\n' "${KITS[@]}")" '/tmp/opencode' +} diff --git a/test/bats/95-kit-ref-persistence.bats b/test/bats/95-kit-ref-persistence.bats index 99c148bd..20595d62 100644 --- a/test/bats/95-kit-ref-persistence.bats +++ b/test/bats/95-kit-ref-persistence.bats @@ -106,9 +106,14 @@ SPEC . "${REPO_ROOT}/acq.backends/secret-store.sh" . "${REPO_ROOT}/acq.backends/msb.sh" ACQ_CLI_KITS=() + _build_kit_list _acq_msb_fetch_kit() { printf '%s\n' "$clikit"; } acq_cli_kits_load msb clidurable - acq_backend_ensure_kits_applied clidurable >/dev/null 2>&1 ) + acq_backend_ensure_kits_applied clidurable >/dev/null 2>&1 + printf 'first-kit=%s\n' "${KITS[0]}" >> "$CALLS" ) run cat "$CALLS" assert_output --partial 'clidurable:/home/agent/clidurable-marker' + assert_output --partial 'first-kit=git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=' + assert_output --partial 'acq-kits/zscaler-ca-certificate' + refute_output --partial 'acq-kits/opencode' } From 2ecadfa1e8e9c37645ba815e8e7b18060f851aae Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 05:09:14 +0000 Subject: [PATCH 08/37] test(agent-kits): cover enabled built-in kit selection --- test/bats/50-kit-list-completeness.bats | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index b84e8fcd..a0d0e49c 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -69,6 +69,17 @@ load 'helper' refute_regex "$(printf '%s\n' "${KITS[@]}")" 'acq-kits/opencode' } +@test "agent-kits: enabled built-in agent kit is appended after support bundle" { + load_acq + ACQ_EXTRA_KITS="" + ACQ_CLI_KITS=() + acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + _build_kit_list opencode + + assert_regex "${KITS[4]}" 'acq-kits/opencode$' + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" +} + @test "agent-kits: explicit CLI kit suppresses implicit agent-kit inference" { load_acq ACQ_EXTRA_KITS="" From 6148aa6f617308f0e7e24b3da184daaaccc75793 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 05:58:39 +0000 Subject: [PATCH 09/37] fix(agent-kits): preserve recorded agent for kit refresh --- acq.backends/common.sh | 31 ++-- acq.backends/msb.sh | 147 ++++++++++++---- acq.backends/sbx.sh | 210 ++++++++++++++++++++--- test/bats/50-kit-list-completeness.bats | 1 + test/bats/74-msb-restart-durability.bats | 1 + test/bats/85-kit-provenance.bats | 17 ++ test/bats/90-sbx-startup-kit.bats | 98 +++++++++++ 7 files changed, 436 insertions(+), 69 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 29aa4375..93c7f69d 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -632,13 +632,14 @@ split_noglob() { } _acq_builtin_kit_ref() { - case "$1" in - zscaler-ca-certificate) printf '%s\n' "$ZSCALER_KIT" ;; - usai-provider) printf '%s\n' "$USAI_KIT" ;; - agentic-coding-playbook) printf '%s\n' "$PLAYBOOK_KIT" ;; - git-ssh-sign) printf '%s\n' "$GITSSHSIGN_KIT" ;; - *) return 1 ;; - esac + local name="${1:-}" known + for known in "${ACQ_KIT_NAMES[@]}"; do + if [ "$known" = "$name" ]; then + printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$name" + return 0 + fi + done + return 1 } _acq_builtin_support_kit_names() { @@ -1673,11 +1674,13 @@ _acq_provenance_file() { # Write (or overwrite) a sandbox's provenance record. Call ONLY after a # successful bundle apply. Records the bundle identity + the exact applied ref + -# an ISO-8601 UTC timestamp + the backend. Best-effort: a write failure warns -# (debug) and returns non-zero but never aborts the caller (fail-open). -# Usage: acq_provenance_write BACKEND SANDBOX_NAME +# an ISO-8601 UTC timestamp + the backend. A third AGENT argument records the +# agent selected at create time; when omitted, an existing agent field is +# preserved across in-place refreshes. Best-effort: a write failure warns (debug) +# and returns non-zero but never aborts the caller (fail-open). +# Usage: acq_provenance_write BACKEND SANDBOX_NAME [AGENT] acq_provenance_write() { - local backend="${1:-}" name="${2:-}" + local backend="${1:-}" name="${2:-}" agent="${3:-}" [ -n "$backend" ] && [ -n "$name" ] || return 1 local file dir ts workspace workspace_source file=$(_acq_provenance_file "$backend" "$name") || return 1 @@ -1691,6 +1694,11 @@ acq_provenance_write() { return 1 fi ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "unknown") + [ -n "$agent" ] || agent=$(acq_provenance_field "$backend" "$name" agent) + case "$agent" in + "") : ;; + *[!a-z-]*) agent="" ;; + esac # Write atomically via a temp file + mv so a crash mid-write can't leave a # half-written record that later parses as a bogus "current" ref. local tmp="${file}.tmp.$$" @@ -1700,6 +1708,7 @@ acq_provenance_write() { printf 'repo=%s\n' "$ACQ_BUILTIN_BUNDLE_REPO" printf 'applied_ref=%s\n' "$PATTERNS_KIT_REF" printf 'backend=%s\n' "$backend" + [ -n "$agent" ] && printf 'agent=%s\n' "$agent" printf 'applied_at=%s\n' "$ts" if [ -n "${workspace:-}" ]; then printf 'workspace_source=host\n' diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 52cc16f7..9b0dae66 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -74,6 +74,17 @@ if ! command -v acq_is_known_agent >/dev/null 2>&1; then . "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/agents.sh" fi +_acq_msb_select_kits() { + local agent="${1:-}" + if command -v _build_kit_list >/dev/null 2>&1; then + _build_kit_list "$agent" + elif ! declare -p KITS >/dev/null 2>&1 || [ "${#KITS[@]}" -eq 0 ]; then + KITS=("$ZSCALER_KIT" "$USAI_KIT" "$PLAYBOOK_KIT" "$GITSSHSIGN_KIT") + ACQ_BUILTIN_KIT_COUNT="${#KITS[@]}" + ACQ_BUILTIN_SUPPORT_KIT_COUNT="${#KITS[@]}" + fi +} + # --------------------------------------------------------------------------- # _acq_msb_cli — invoke the msb CLI with MSYS argument rewriting disabled # --------------------------------------------------------------------------- @@ -239,8 +250,11 @@ ACQ_MSB_KIT_CACHE="${ACQ_MSB_KIT_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/acq/kits # ACQ_EXEC_READY_TIMEOUT. ACQ_MSB_EXEC_READY_TIMEOUT="${ACQ_MSB_EXEC_READY_TIMEOUT:-${ACQ_EXEC_READY_TIMEOUT:-60}}" -# USAi models path (matches common.sh USAI_MODELS_URL host) for --secret host. -ACQ_MSB_USAI_HOST="api.gsa.usai.gov" +# USAi provider facts for --secret binding; common.sh owns the canonical values. +USAI_PROVIDER_HOST="${USAI_PROVIDER_HOST:-api.gsa.usai.gov}" +USAI_PROVIDER_BIND_HOSTS="${USAI_PROVIDER_BIND_HOSTS:-$USAI_PROVIDER_HOST}" +USAI_PROVIDER_KEY_ENV="${USAI_PROVIDER_KEY_ENV:-USAI_API_KEY}" +ACQ_MSB_USAI_HOST="$USAI_PROVIDER_BIND_HOSTS" # GitHub credential hosts for the msb --secret binding. Bind the REST API and # git-transport hosts so both API calls and HTTPS git clone/push can substitute @@ -283,7 +297,7 @@ _acq_msb_service_binding() { fi fi case "$_service" in - usai) printf '%s\t%s\n' "USAI_API_KEY" "$ACQ_MSB_USAI_HOST"; return 0 ;; + usai) printf '%s\t%s\n' "$USAI_PROVIDER_KEY_ENV" "$ACQ_MSB_USAI_HOST"; return 0 ;; github) printf '%s\t%s\n' "GITHUB_TOKEN" "$ACQ_MSB_GITHUB_HOST"; return 0 ;; esac printf '\t\n' @@ -1031,7 +1045,7 @@ $(acq_secret_meta_list "$_name") EOF fi elif [ -n "${USAI_API_KEY:-}" ]; then - eval "$_arrn+=(--secret \"USAI_API_KEY@\${ACQ_MSB_USAI_HOST}\")" + eval "$_arrn+=(--secret \"${USAI_PROVIDER_KEY_ENV}@\${ACQ_MSB_USAI_HOST}\")" fi } @@ -1084,6 +1098,9 @@ EOF # matter for start. acq_backend_start() { local _name="$1" + if command -v acq_provider_facts_load_from_kit_or_fallback >/dev/null 2>&1; then + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + fi local _start_secret_flags=() _start_secret_names=() _acq_msb_bind_secrets_into _start_secret_flags _start_secret_names "$_name" local _start_rc=0 @@ -3114,7 +3131,7 @@ acq_backend_provision() { # Zscaler CA trust FIRST so later network-fetching kits (playbook clone, USAi # validation) succeed behind a TLS-intercepting proxy (e.g. Zscaler). local kitref kitdir - _build_kit_list "$agent" + _acq_msb_select_kits "$agent" local kits=("${KITS[@]}") for kitref in "${kits[@]}"; do @@ -3800,7 +3817,7 @@ EOF # Record host-side bundle provenance now the built-in bundle is applied. # Best-effort: a provenance write failure never affects the # sandbox. Reached only when provision did not abort earlier under set -e. - acq_provenance_write msb "$name" || true + acq_provenance_write msb "$name" "$agent" || true acq_workspace_record_write msb "$name" "$_first_host" || true # Persist the CLI (`--kit`) and extra (ACQ_EXTRA_KITS) kit refs so a later @@ -4095,13 +4112,15 @@ _acq_msb_ensure_agent_user() { # /bin/sh otherwise — a stock bash-less image keeps working exactly as today. # Also writes a Debian-skel-style ~/.profile bridge: a LOGIN bash reads only # ~/.profile, so without the bridge even `bash -l` skips ~/.bashrc on images -# whose baked home ships no ~/.profile. The bare `export SHELL=/bin/sh` line -# earlier acq versions appended is removed wherever it appears (it stopped a -# login bash cold); a ~/.profile with any other content is left alone (image/ -# user-owned — Debian's own skel already bridges). Idempotent and re-run on -# every provision AND heal (even on an agent-user-ready marker hit), which is -# what upgrades sandboxes created before this setup. Fail-soft: a sync failure -# leaves sessions on the /bin/sh fallback, never blocks the run. +# whose baked home ships no ~/.profile. The bridge also sources kit-owned +# ~/.rc.d/*.sh snippets for bash in deterministic lexical order. The bare +# `export SHELL=/bin/sh` line earlier acq versions appended is removed wherever +# it appears (it stopped a login bash cold); a ~/.profile with any other content +# is left alone (image/user-owned — Debian's own skel already bridges). +# Idempotent and re-run on every provision AND heal (even on an agent-user-ready +# marker hit), which is what upgrades sandboxes created before this setup. +# Fail-soft: a sync failure leaves sessions on the /bin/sh fallback, never blocks +# the run. _acq_msb_ensure_agent_shell() { local name="$1" shell # Probe for bash host-side so the target is decided once and threaded to the @@ -4111,7 +4130,9 @@ _acq_msb_ensure_agent_shell() { shell=$(_acq_msb_safe_shell_or_sh "$shell") acq_debug "msb: syncing agent login shell to $shell in $name" # NOTE: single-quoted `sh -c` string — no single quotes inside (they would - # close the outer quote); `echo` writes the profile lines for the same reason. + # close the outer quote). The rc.d-sourcing block IS single-quote-bearing, but + # it is passed as a positional arg ($2), never interpolated into this string, + # so its quoting is inert here. `echo`/`printf` write the profile lines. # # The bridge exports the shell passwd ACTUALLY holds after the sync attempt # (re-read into `current`), not the requested target: an image with bash but @@ -4119,12 +4140,30 @@ _acq_msb_ensure_agent_shell() { # about the shell sessions really run. # # The rewrite is bounded to a file acq owns outright: missing/empty, or the - # marker present AND still just the bridge (3 lines). Lines other tools - # append below the bridge (rustup et al) must survive the heal, so a - # marker-plus-appended file is left untouched. + # marker present AND still just the bridge (the header lines + shared rc-block + # line count, computed host-side). Lines other tools append below the bridge + # (rustup et al) must survive the heal, so a marker-plus-appended file is left + # untouched. + # The rc.d-sourcing block is the ONE shared bridge text from common.sh + # (acq_login_profile_rc_block); it is threaded to the guest as $2 so the sbx + # and msb bridges cannot silently drift (ADR-0030). The guest writes the 3 + # fixed header lines + the shared block via a single here-doc-free `printf`, + # then re-owns the file to the agent. `_acq_rc_block` is captured host-side so + # the guest receives it as inert data (never re-expanded on the host). + local _acq_rc_block + _acq_rc_block=$(acq_login_profile_rc_block) + # Rewrite bound: 3 fixed header lines + the shared block's line count. Counted + # host-side so the "still just the bridge acq owns" heal guard stays exact if + # the shared block grows (rather than a hard-coded literal that would silently + # go stale). Appended lines below the bridge (rustup et al) push the total + # past this bound and are left untouched. + local _acq_bridge_lines + _acq_bridge_lines=$(( 3 + $(printf '%s\n' "$_acq_rc_block" | wc -l) )) _acq_msb_cli exec "$name" -u 0 -- sh -c ' set -e target="$1" + rc_block="$2" + max_lines="$3" current=$({ getent passwd agent 2>/dev/null || grep "^agent:" /etc/passwd 2>/dev/null; } | head -n1 | cut -d: -f7) if [ "$current" != "$target" ]; then if command -v usermod >/dev/null 2>&1; then @@ -4139,16 +4178,17 @@ _acq_msb_ensure_agent_shell() { if [ -f "$profile" ]; then sed -i "\|^export SHELL=/bin/sh\$|d" "$profile" 2>/dev/null || true fi - if [ ! -s "$profile" ] || { grep -qs acq-login-profile "$profile" && [ "$(wc -l < "$profile")" -le 3 ]; }; then + if [ ! -s "$profile" ] || { grep -qs acq-login-profile "$profile" && [ "$(wc -l < "$profile")" -le "$max_lines" ]; }; then { - echo "# acq-login-profile: written by acq (rewritten on heal; do not edit these 3 lines)." + echo "# acq-login-profile: written by acq (rewritten on heal; do not edit this block)." echo "export SHELL=$current" echo "if [ -n \"\$BASH_VERSION\" ] && [ -f \"\$HOME/.bashrc\" ]; then . \"\$HOME/.bashrc\"; fi" + printf "%s\n" "$rc_block" } > "$profile" _agrp=$(id -gn agent 2>/dev/null || echo agent) chown "agent:${_agrp}" "$profile" fi - ' sh "$shell" /dev/null 2>&1 || { + ' sh "$shell" "$_acq_rc_block" "$_acq_bridge_lines" /dev/null 2>&1 || { echo "acq(msb): warning: could not sync the agent login shell in '$name';" >&2 echo "acq(msb): interactive sessions fall back to /bin/sh." >&2 return 0 @@ -4839,6 +4879,17 @@ EOF _ACQ_MSB_RUN_WS_NAME="" _ACQ_MSB_RUN_WS="" +acq_backend_recorded_agent() { + local name="$1" agent + agent=$(acq_provenance_field msb "$name" agent) + if [ -z "$agent" ]; then + agent=$({ msb exec "$name" -u 0 -- sh -c 'cat /var/lib/acq/agent 2>/dev/null' /dev/null || true; } | tr -d '[:space:]') + fi + if [ -n "$agent" ] && _acq_msb_safe_agent_token "$agent"; then + printf '%s\n' "$agent" + fi +} + # _acq_msb_workspace_for NAME — the guest workspace a session starts in (-w). # Prefer an explicit ACQ_MSB_WORKSPACE override; otherwise the guest path # recorded at provision (it mirrors the host mount path, so it cannot be @@ -4864,6 +4915,10 @@ _acq_msb_workspace_for() { printf '%s\n' "$ws" } +acq_backend_workspace_for() { + _acq_msb_workspace_for "$1" +} + # _acq_msb_term_flags_into ARRVAR — `-e` flags forwarding the host's terminal # identity (TERM, COLORTERM) into an interactive (-t) session. msb does not # propagate the caller's terminal env into the guest PTY, so sessions see @@ -4952,8 +5007,17 @@ acq_backend_run() { _ACQ_MSB_RUN_WS_NAME="$name" _ACQ_MSB_RUN_WS="$ws" fi - _acq_msb_cli exec -u agent -e HOME=/home/agent -w "$ws" ${_sockflag[@]+"${_sockflag[@]}"} \ - ${_gitident[@]+"${_gitident[@]}"} ${_kitenv[@]+"${_kitenv[@]}"} "$name" "$@" + if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ + && command -v acq_session_is_user >/dev/null 2>&1 && acq_session_is_user \ + && command -v acq_guest_exec_script >/dev/null 2>&1 && [ "${1:-}" = "--" ]; then + shift + _acq_msb_cli exec -u agent -e HOME=/home/agent -w "$ws" ${_sockflag[@]+"${_sockflag[@]}"} \ + ${_gitident[@]+"${_gitident[@]}"} ${_kitenv[@]+"${_kitenv[@]}"} \ + -e "ACQ_WORKSPACE=$ws" "$name" -- sh -c "$(acq_guest_exec_script)" sh "$@" + else + _acq_msb_cli exec -u agent -e HOME=/home/agent -w "$ws" ${_sockflag[@]+"${_sockflag[@]}"} \ + ${_gitident[@]+"${_gitident[@]}"} ${_kitenv[@]+"${_kitenv[@]}"} "$name" "$@" + fi } # --------------------------------------------------------------------------- @@ -5051,9 +5115,17 @@ _acq_msb_attach() { local shell shell=$(_acq_msb_agent_passwd_shell "$name") - MSYS2_ARG_CONV_EXCL='*' exec msb exec -t -u agent -w "$ws" ${_term[@]+"${_term[@]}"} -e "SHELL=$shell" \ - ${_sockflag[@]+"${_sockflag[@]}"} ${_gitident[@]+"${_gitident[@]}"} \ - ${_kitenv[@]+"${_kitenv[@]}"} "$name" -- "$agent" "$@" + if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ + && command -v acq_guest_exec_script >/dev/null 2>&1; then + MSYS2_ARG_CONV_EXCL='*' exec msb exec -t -u agent -w "$ws" ${_term[@]+"${_term[@]}"} -e "SHELL=$shell" \ + ${_sockflag[@]+"${_sockflag[@]}"} ${_gitident[@]+"${_gitident[@]}"} \ + ${_kitenv[@]+"${_kitenv[@]}"} -e "ACQ_WORKSPACE=$ws" "$name" -- sh -c \ + "$(acq_guest_exec_script)" sh "$agent" "$@" + else + MSYS2_ARG_CONV_EXCL='*' exec msb exec -t -u agent -w "$ws" ${_term[@]+"${_term[@]}"} -e "SHELL=$shell" \ + ${_sockflag[@]+"${_sockflag[@]}"} ${_gitident[@]+"${_gitident[@]}"} \ + ${_kitenv[@]+"${_kitenv[@]}"} "$name" -- "$agent" "$@" + fi } # _acq_msb_shell_exec NAME [WS] — exec into an interactive login shell as the @@ -5075,9 +5147,17 @@ _acq_msb_shell_exec() { _acq_msb_git_identity_env_flags_into _gitident _acq_msb_kit_env_flags_into _kitenv "$name" _acq_msb_term_flags_into _term - MSYS2_ARG_CONV_EXCL='*' exec msb exec -t -u agent -w "$ws" ${_term[@]+"${_term[@]}"} -e "SHELL=$shell" \ - ${_sockflag[@]+"${_sockflag[@]}"} ${_gitident[@]+"${_gitident[@]}"} \ - ${_kitenv[@]+"${_kitenv[@]}"} "$name" -- "$shell" -l + if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ + && command -v acq_guest_shell_script >/dev/null 2>&1; then + MSYS2_ARG_CONV_EXCL='*' exec msb exec -t -u agent -w "$ws" ${_term[@]+"${_term[@]}"} -e "SHELL=$shell" \ + ${_sockflag[@]+"${_sockflag[@]}"} ${_gitident[@]+"${_gitident[@]}"} \ + ${_kitenv[@]+"${_kitenv[@]}"} -e "ACQ_WORKSPACE=$ws" "$name" -- sh -c \ + "$(acq_guest_shell_script)" sh "$shell" + else + MSYS2_ARG_CONV_EXCL='*' exec msb exec -t -u agent -w "$ws" ${_term[@]+"${_term[@]}"} -e "SHELL=$shell" \ + ${_sockflag[@]+"${_sockflag[@]}"} ${_gitident[@]+"${_gitident[@]}"} \ + ${_kitenv[@]+"${_kitenv[@]}"} "$name" -- "$shell" -l + fi } # --------------------------------------------------------------------------- @@ -5691,7 +5771,9 @@ acq_backend_ensure_kits_applied() { # Rebuild after any persisted refs were loaded by the dispatcher. CLI-supplied # `--kit ` refs must be healed too, exactly as the provision path folds # them in, so resumed kit services come back with their startup re-run. - _build_kit_list "" + local agent + agent=$(acq_backend_recorded_agent "$name") + _acq_msb_select_kits "$agent" local kits=("${KITS[@]}") local builtin_count="${ACQ_BUILTIN_KIT_COUNT:-4}" local kitref kitdir i=0 ok=1 @@ -5702,7 +5784,7 @@ acq_backend_ensure_kits_applied() { # msb re-applies all built-in kits idempotently, so on full # success the sandbox carries the currently pinned bundle. Best-effort write. if [ "$ok" -eq 1 ]; then - acq_provenance_write msb "$name" || true + acq_provenance_write msb "$name" "$agent" || true return 0 fi return 1 @@ -6218,6 +6300,11 @@ acq_backend_doctor() { printf '[msb: installed %s]\n' "$ver" } +acq_backend_doctor_sandbox() { + local name="$1" + msb exec -u agent -e HOME=/home/agent "$name" -- sh -c "$(acq_image_contract_doctor_script)" +} + # --------------------------------------------------------------------------- # is_known_agent — used by the acq run dispatch # --------------------------------------------------------------------------- diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index d8718e2c..b89e45e8 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -38,6 +38,11 @@ if ! command -v acq_is_known_agent >/dev/null 2>&1; then . "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/agents.sh" fi +USAI_PROVIDER_HOST="${USAI_PROVIDER_HOST:-api.gsa.usai.gov}" +USAI_PROVIDER_BIND_HOSTS="${USAI_PROVIDER_BIND_HOSTS:-$USAI_PROVIDER_HOST}" +USAI_PROVIDER_KEY_ENV="${USAI_PROVIDER_KEY_ENV:-USAI_API_KEY}" +USAI_PROVIDER_MODELS_URL="${USAI_PROVIDER_MODELS_URL:-https://${USAI_PROVIDER_HOST}/api/v1/models}" + # Minimum sbx version required. # # Bumped 0.38.0 -> 0.39.0: acq exports the guest-visible workspace markers @@ -456,6 +461,8 @@ acq_backend_provision() { _acq_sbx_ensure_kit_sources_allowed local name="$1" shift + local agent + agent=$(first_positional "$@") # Host ssh-agent trust-boundary notice (ADR-0021). sbx forwards the host # ssh-agent into the guest IMPLICITLY whenever SSH_AUTH_SOCK is set, so — as on # msb — a user who always exports it (tmux/screen/profile persistence) could @@ -568,9 +575,18 @@ acq_backend_provision() { return 1 fi acq_spin_stop "Waiting for kit startup in '$name'" - acq_provenance_write sbx "$name" || true + acq_provenance_write sbx "$name" "$agent" || true acq_workspace_record_write sbx "$name" "$_primary_ws" || true _acq_sbx_seed_extra_kit_marker "$name" + # Backend parity (ADR-0030): sbx DELIVERS ~/.rc.d/*.sh via kit files[] but, + # unlike msb's .profile bridge, nothing SOURCES them at login. Write the same + # bridge here at create time so a kit-dropped snippet actually runs. This is a + # post-create `sbx exec`, NOT a startup-bearing kit, so it does not interact + # with the sbx >= 0.38 live-extend refusal (that gate only rejects `sbx kit + # add` of setup.startup kits — see _acq_sbx_kit_add). The helper waits for + # exec readiness itself, so this also works when no extra-kit marker write + # happened before it. + _acq_sbx_ensure_rc_bridge "$name" # Persist the CLI (`--kit`) / extra kit refs alongside provenance so a later # resume heal can reload them (see acq_cli_kits_write). Best-effort. acq_cli_kits_write sbx "$name" || true @@ -630,6 +646,82 @@ _acq_sbx_seed_extra_kit_marker() { done } +# --------------------------------------------------------------------------- +# _acq_sbx_ensure_rc_bridge — write the login-profile rc.d sourcing bridge +# --------------------------------------------------------------------------- +# sbx delivers kit-owned ~/.rc.d/*.sh files (via kit files[]), but sbx's agent +# templates ship no ~/.profile that sources them, so on sbx a dropped snippet +# never runs — the exact parity gap this closes (ADR-0030). msb writes an +# equivalent bridge in _acq_msb_ensure_agent_shell; the rc-sourcing block is the +# ONE shared text from common.sh (acq_login_profile_rc_block) so the two +# backends cannot silently drift. The block gates itself to bash and sources +# ~/.rc.d in C-collation (deterministic lexical) order. +# +# Unlike msb, sbx's agent user already has a working login shell, so this ONLY +# adds the rc.d bridge (no shell/passwd sync). It writes ~/.profile only when acq +# owns it outright (missing/empty, or the acq-rc marker present AND still just +# this bridge), so a user/image ~/.profile or lines other tools appended survive +# untouched — the same discipline msb applies. +# +# Runs as a post-create `sbx exec` (NOT a startup-bearing kit), so it is +# unaffected by the sbx >= 0.38 refusal to live-extend a sandbox with +# setup.startup kits (_acq_sbx_kit_add) — no regression to that handling. +# Fail-soft: any error leaves rc.d unsourced (delivery still happened), never +# blocks the run. In offline/no-network mode (ACQ_SBX_KIT_PASSTHROUGH) the real +# `sbx exec` is stubbed by the unit harness, so no live sandbox is required. +_acq_sbx_ensure_rc_bridge() { + local name="$1" rc_block bridge_lines + command -v acq_login_profile_rc_block >/dev/null 2>&1 || return 0 + if ! _acq_sbx_wait_for_exec_ready "$name"; then + echo "acq(sbx): warning: sandbox '$name' not exec-ready; could not install the ~/.rc.d login bridge." >&2 + echo "acq(sbx): kit-dropped ~/.rc.d/*.sh snippets will not be sourced at login." >&2 + return 0 + fi + rc_block=$(acq_login_profile_rc_block) + # Header lines written before the block (the marker comment). Bound computed + # host-side so the "acq owns it outright" guard tracks the shared block's real + # size instead of a literal that could silently go stale if the block grows. + bridge_lines=$(( 1 + $(printf '%s\n' "$rc_block" | wc -l) )) + # The block is threaded to the guest as a positional arg ($1), never + # interpolated into the single-quoted sh -c string (the block contains single + # quotes, which would otherwise close the outer quote). + sbx exec "$name" -- sh -c ' + set -e + rc_block="$1" + max_lines="$2" + profile="$HOME/.profile" + if [ ! -s "$profile" ] || { grep -qs acq-login-profile-rc "$profile" && [ "$(wc -l < "$profile")" -le "$max_lines" ]; }; then + { + echo "# acq-login-profile-rc: written by acq (do not edit this block)." + printf "%s\n" "$rc_block" + } > "$profile" + fi + ' sh "$rc_block" "$bridge_lines" /dev/null 2>&1 || { + echo "acq(sbx): warning: could not install the ~/.rc.d login bridge in '$name';" >&2 + echo "acq(sbx): kit-dropped ~/.rc.d/*.sh snippets will not be sourced at login." >&2 + return 0 + } +} + +acq_backend_recorded_agent() { + local agent + agent=$(acq_provenance_field sbx "$1" agent) + if [ -n "$agent" ] && acq_agent_safe_token "$agent"; then + printf '%s\n' "$agent" + fi +} + +acq_backend_workspace_for() { + acq_provenance_field sbx "$1" workspace +} + +_acq_sbx_attach_command() { + local name="$1" agent + agent=$(acq_backend_recorded_agent "$name") + [ -n "$agent" ] || agent="bash" + printf '%s\n' "$agent" +} + # --------------------------------------------------------------------------- # acq_backend_run — run a command inside a sandbox # --------------------------------------------------------------------------- @@ -641,7 +733,14 @@ acq_backend_run() { # Expect `-- CMD...` separator. No `-u agent` needed: sbx's agent templates # bake the unprivileged `agent` user (UID 1000, HOME=/home/agent) as the # default exec user, unlike a plain msb OCI base (which defaults to root). - sbx exec "$name" "$@" + if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ + && command -v acq_session_is_user >/dev/null 2>&1 && acq_session_is_user \ + && command -v acq_guest_exec_script >/dev/null 2>&1 && [ "${1:-}" = "--" ]; then + shift + sbx exec "$name" -- sh -c "$(acq_guest_exec_script)" sh "$@" + else + sbx exec "$name" "$@" + fi } # --------------------------------------------------------------------------- @@ -652,7 +751,12 @@ acq_backend_run() { # allocates the PTY itself via `exec -it`; exec hands it the terminal directly. acq_backend_shell() { _acq_sbx_apply_git_identity_kit "$1" - exec sbx exec -it "$1" bash + if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ + && command -v acq_guest_shell_script >/dev/null 2>&1; then + exec sbx exec -it "$1" -- bash -lc "$(acq_guest_shell_script)" sh bash + else + exec sbx exec -it "$1" bash + fi } # --------------------------------------------------------------------------- @@ -666,11 +770,32 @@ acq_backend_attach() { echo "acq: refusing to attach to '$name' because kit heal did not finish." >&2 return 1 fi + local agent ws + agent=$(_acq_sbx_attach_command "$name") + ws=$(acq_backend_workspace_for "$name") if [ "$#" -gt 0 ] && [ "$1" = "--" ]; then shift - sbx run --name "$name" -- "$@" + if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ + && command -v acq_guest_exec_script >/dev/null 2>&1; then + if [ -n "$ws" ]; then + sbx run --name "$name" -- env "ACQ_WORKSPACE=$ws" sh -c "$(acq_guest_exec_script)" sh "$agent" "$@" + else + sbx run --name "$name" -- sh -c "$(acq_guest_exec_script)" sh "$agent" "$@" + fi + else + sbx run --name "$name" -- "$@" + fi else - sbx run --name "$name" + if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ + && command -v acq_guest_exec_script >/dev/null 2>&1; then + if [ -n "$ws" ]; then + sbx run --name "$name" -- env "ACQ_WORKSPACE=$ws" sh -c "$(acq_guest_exec_script)" sh "$agent" + else + sbx run --name "$name" -- sh -c "$(acq_guest_exec_script)" sh "$agent" + fi + else + sbx run --name "$name" + fi fi } @@ -907,6 +1032,29 @@ acq_backend_ensure_kits_applied() { esac fi + if [ "$force" = "1" ] && command -v acq_backend_recorded_agent >/dev/null 2>&1; then + local agent refresh_kit_ref refresh_local refresh_idx support_count handled_support_count label + agent=$(acq_backend_recorded_agent "$name") + _build_kit_list "$agent" + support_count="${ACQ_BUILTIN_SUPPORT_KIT_COUNT:-4}" + handled_support_count=$(_acq_builtin_support_kit_names | wc -l | tr -d ' ') + refresh_idx=0 + for refresh_kit_ref in "${KITS[@]}"; do + refresh_idx=$((refresh_idx + 1)) + [ "$refresh_idx" -le "$handled_support_count" ] && continue + [ "$refresh_idx" -le "${ACQ_BUILTIN_KIT_COUNT:-4}" ] || break + label="agent kit" + [ "$refresh_idx" -le "$support_count" ] && label="support kit" + refresh_local=$(_acq_sbx_translate_kit "$refresh_kit_ref") + _kadd_rc=0; _acq_sbx_kit_add "$name" "$refresh_local" || _kadd_rc=$? + case $_kadd_rc in + 0) echo "acq: $label refreshed in '$name'." >&2 ;; + 3) _acq_sbx_print_recreate_notice "$name"; ok=0 ;; + *) echo "acq: warning: 'sbx kit add' ($label) failed for '$name' (see error above)." >&2; ok=0 ;; + esac + done + fi + _acq_sbx_apply_git_identity_kit "$name" [ "${_ACQ_SBX_LAST_KIT_ADD_SUCCEEDED:-0}" = "1" ] && healed=1 @@ -965,12 +1113,12 @@ acq_backend_ensure_kits_applied() { # needs to know which HOST(s) the credential is injected for and (for the # placeholder/env path) which ENV var. Keep this table backend-neutral here so # sbx.sh and msb.sh agree on the mapping. -# usai -> api.gsa.usai.gov USAI_API_KEY +# usai -> $USAI_PROVIDER_HOST $USAI_PROVIDER_KEY_ENV # github -> github.com,api.github.com GITHUB_TOKEN (sbx built-in service) # Echoes "host1[,host2] ENVVAR"; empty for unknown services. _acq_service_hosts_env() { case "$1" in - usai) printf 'api.gsa.usai.gov\tUSAI_API_KEY\n' ;; + usai) printf '%s\t%s\n' "$USAI_PROVIDER_BIND_HOSTS" "$USAI_PROVIDER_KEY_ENV" ;; github) printf 'github.com,api.github.com\tGITHUB_TOKEN\n' ;; *) printf '\t\n' ;; esac @@ -1570,10 +1718,10 @@ acq_backend_key_present() { local service="${1:-}" scope_sandbox="${2:-}" case "$service" in usai) - if [ -n "$scope_sandbox" ] && _acq_sbx_secret_exists "" "$scope_sandbox" usai USAI_API_KEY; then + if [ -n "$scope_sandbox" ] && _acq_sbx_secret_exists "" "$scope_sandbox" usai "$USAI_PROVIDER_KEY_ENV"; then return 0 fi - _acq_sbx_secret_exists -g "" usai USAI_API_KEY + _acq_sbx_secret_exists -g "" usai "$USAI_PROVIDER_KEY_ENV" ;; *) return 0 @@ -1617,14 +1765,15 @@ _acq_sbx_custom_placeholder() { # --------------------------------------------------------------------------- # acq_backend_rotate_key — rotate the global USAi key (per ADR-0012) # --------------------------------------------------------------------------- -# Rotate the global USAI_API_KEY custom secret in sbx, PRESERVING its proxy +# Rotate the global USAi custom secret in sbx, PRESERVING its proxy # placeholder so existing sandboxes keep resolving to the new value. Carried # verbatim from the former scripts/rotate-apikey (which is now a thin shim that # calls `acq usai-rotate-api-key`). Never places the secret value on argv — sbx # prompts for the new key at its own prompt. Returns non-zero on failure. acq_backend_rotate_key() { - local usai_host="api.gsa.usai.gov" - local usai_models_url="https://${usai_host}/api/v1/models" + local usai_host="$USAI_PROVIDER_HOST" + local usai_models_url="$USAI_PROVIDER_MODELS_URL" + local usai_env="$USAI_PROVIDER_KEY_ENV" # Read the current secret table once (avoids a TOCTOU window + a second call). local secret_ls @@ -1633,25 +1782,25 @@ acq_backend_rotate_key() { return 1 } - # Grab the existing placeholder, anchoring on the USAI_API_KEY token rather + # Grab the existing placeholder, anchoring on the provider key env token rather # than a fixed column, taking only the FIRST match so a duplicated state can't # produce a multi-line value. Preserved across rotation so running sandboxes # that already injected it keep resolving to the new secret. local placeholder placeholder=$(printf '%s\n' "$secret_ls" \ - | awk '{ for (i = 1; i <= NF; i++) if ($i == "USAI_API_KEY") { print $(i+1); exit } }') + | awk -v env="$usai_env" '{ for (i = 1; i <= NF; i++) if ($i == env) { print $(i+1); exit } }') if [ -z "$placeholder" ]; then - echo "acq(sbx): USAI_API_KEY not found. Run 'acq secret ls' to check." >&2 + echo "acq(sbx): $usai_env not found. Run 'acq secret ls' to check." >&2 return 1 fi - # Count USAI_API_KEY rows. More than one means a previous rotation (before the - # fix in ADR-0008) left duplicate/ghost entries; the proxy can then resolve the - # placeholder to an empty entry and validation fails with 401. + # Count provider key env rows. More than one means a previous rotation (before + # the fix in ADR-0008) left duplicate/ghost entries; the proxy can then resolve + # the placeholder to an empty entry and validation fails with 401. local row_count row_count=$(printf '%s\n' "$secret_ls" \ - | grep -cE '[[:space:]]USAI_API_KEY[[:space:]]' || true) + | grep -cE "[[:space:]]${usai_env}[[:space:]]" || true) if [ "${row_count:-0}" -gt 1 ]; then # --- Cleanup path for data corrupted by the pre-fix rotation bug ---------- @@ -1668,25 +1817,25 @@ acq_backend_rotate_key() { # We hold the placeholder already, so remove EACH duplicate row by placeholder # (rm by placeholder targets the one row; loop while any remain), then recreate # a single canonical entry. - echo "Found $row_count USAI_API_KEY entries; consolidating to a single secret." >&2 + echo "Found $row_count $usai_env entries; consolidating to a single secret." >&2 - local _ph="$placeholder" _host="$usai_host" + local _ph="$placeholder" _host="$usai_host" _env="$usai_env" # shellcheck disable=SC2064 trap "{ echo '' >&2 - echo 'ERROR: rotation was interrupted while the USAI_API_KEY secret was removed' >&2 + echo 'ERROR: rotation was interrupted while the USAi secret was removed' >&2 echo 'but before the new value was set. Your sandboxes currently have NO USAi key.' >&2 echo 'Recover by re-running this rotation, or manually:' >&2 - echo ' sbx secret set-custom --host ${_host} --env USAI_API_KEY --placeholder ${_ph}' >&2 + echo ' sbx secret set-custom --host ${_host} --env ${_env} --placeholder ${_ph}' >&2 }" EXIT - # Remove every USAI_API_KEY custom row. Each row's placeholder is re-read from + # Remove every USAi custom row. Each row's placeholder is re-read from # a fresh listing so we clear duplicates that may share OR differ in # placeholder; -f avoids the confirm prompt, /dev/null \ - | awk '{ for (i = 1; i <= NF; i++) if ($i == "USAI_API_KEY") { print $(i+1); exit } }') + | awk -v env="$usai_env" '{ for (i = 1; i <= NF; i++) if ($i == env) { print $(i+1); exit } }') [ -n "$_dup_ph" ] || break if ! rm_err=$(sbx secret rm --placeholder "$_dup_ph" -f &1); then trap - EXIT @@ -1700,7 +1849,7 @@ acq_backend_rotate_key() { # Recreate the single secret with the preserved placeholder. Omitting # --value makes sbx prompt for the new key (keeps it out of shell history). sbx secret set-custom --host "$usai_host" \ - --env USAI_API_KEY --placeholder "$placeholder" || { + --env "$usai_env" --placeholder "$placeholder" || { echo "acq(sbx): 'sbx secret set-custom' failed. See recovery steps above." >&2 return 1 } @@ -1712,7 +1861,7 @@ acq_backend_rotate_key() { # Omitting --value makes sbx prompt for the new key (no shell-history leak). # Global is the default (no `-g`); see the CLI-change note above. sbx secret set-custom --host "$usai_host" \ - --env USAI_API_KEY --placeholder "$placeholder" || { + --env "$usai_env" --placeholder "$placeholder" || { echo "acq(sbx): 'sbx secret set-custom' failed." >&2 return 1 } @@ -1760,7 +1909,7 @@ acq_backend_rotate_key() { local status status=$(sbx exec "$validation_sbx" -- sh -c \ "curl -sS -o /dev/null -w '%{http_code}' \ - -H \"Authorization: Bearer \$USAI_API_KEY\" \ + -H \"Authorization: Bearer \$${usai_env}\" \ $usai_models_url" 2>/dev/null || true) acq_backend_terminate "$validation_sbx" >/dev/null 2>&1 || true @@ -1788,6 +1937,11 @@ acq_backend_doctor() { printf '[sbx: installed %s]\n' "$ver" } +acq_backend_doctor_sandbox() { + local name="$1" + sbx exec "$name" -- env HOME=/home/agent sh -c "$(acq_image_contract_doctor_script)" +} + # --------------------------------------------------------------------------- # is_known_agent — used by the acq run dispatch # --------------------------------------------------------------------------- diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index a0d0e49c..b9ae0877 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -74,6 +74,7 @@ load 'helper' ACQ_EXTRA_KITS="" ACQ_CLI_KITS=() acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + _acq_builtin_kit_ref() { printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$1"; } _build_kit_list opencode assert_regex "${KITS[4]}" 'acq-kits/opencode$' diff --git a/test/bats/74-msb-restart-durability.bats b/test/bats/74-msb-restart-durability.bats index 879145c0..63718d73 100644 --- a/test/bats/74-msb-restart-durability.bats +++ b/test/bats/74-msb-restart-durability.bats @@ -133,6 +133,7 @@ SPEC assert_regex "$(cat "$CALLS")" 'clikitbox:/home/agent/clikit-marker' } + @test "0017: startup is staged via --script-path but never designated --entrypoint (removed knob inert)" { : > "$CALLS" run bash -c ' diff --git a/test/bats/85-kit-provenance.bats b/test/bats/85-kit-provenance.bats index c066face..2e809072 100644 --- a/test/bats/85-kit-provenance.bats +++ b/test/bats/85-kit-provenance.bats @@ -37,6 +37,23 @@ _seed_stale_provenance() { # BACKEND NAME assert_equal "$status" "stale" } +@test "provenance: records and preserves the sandbox agent" { + acq_provenance_write sbx agentbox opencode + assert_equal "$(acq_provenance_field sbx agentbox agent)" "opencode" + acq_provenance_write sbx agentbox + assert_equal "$(acq_provenance_field sbx agentbox agent)" "opencode" +} + +@test "provenance: recorded agent can rebuild an enabled agent kit list" { + acq_provenance_write msb agenthealbox opencode + acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + _acq_builtin_kit_ref() { printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$1"; } + + _build_kit_list "$(acq_provenance_field msb agenthealbox agent)" + assert_regex "${KITS[4]}" 'acq-kits/opencode$' + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" +} + @test "provenance: backend keying — sbx and msb records do not collide" { acq_provenance_write sbx dup assert_equal "$(acq_provenance_status msb dup)" "unknown" diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index 6d486f26..0b33d63a 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -202,6 +202,72 @@ STUB refute_regex "$(cat "$spec")" 'GIT_AUTHOR_NAME: Global User' } +@test "rc.d(sbx): neutral kit files materialize as home rc snippets" { + local kit="$STUBDIR/rcd-kit" out="$STUBDIR/rcd-sbx-v2" + mkdir -p "$kit/files/home/agent/.rc.d" + cat > "$kit/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: rc-hook +displayName: RC Hook +description: shell rc hook fixture +files: + - path: /home/agent/.rc.d/10-team.sh + mode: "0644" + source: files/home/agent/.rc.d/10-team.sh +SPEC + printf 'export TEAM_TOOL=1\n' > "$kit/files/home/agent/.rc.d/10-team.sh" + + run kit_translate_to_sbx "$kit" "$out" + assert_success + [ -f "$out/files/home/agent/.rc.d/10-team.sh" ] + assert_equal "$(cat "$out/files/home/agent/.rc.d/10-team.sh")" "export TEAM_TOOL=1" + assert_regex "$(cat "$out/spec.yaml")" '/home/agent/.rc.d/10-team.sh' + assert_regex "$(cat "$out/spec.yaml")" 'chmod 0644' + refute_regex "$(cat "$out/spec.yaml")" 'direnv allow|USAI_API_KEY|GITHUB_TOKEN' +} + +@test "rc.d(sbx): provision installs the ~/.rc.d login bridge (backend parity)" { + # Parity with msb (ADR-0030): sbx DELIVERS ~/.rc.d files but nothing sources + # them at login without this create-time bridge. The bridge text is the ONE + # shared block from common.sh (acq_login_profile_rc_block), so the assertions + # below (C-collation ordering, bash gate, guards) hold for BOTH backends. + : > "$CALLS" + ( + acq_backend_provision rcdbridgebox shell /tmp + ) >/dev/null 2>&1 || true + local log; log=$(cat "$CALLS") + # The bridge is written via a post-create `sbx exec` (not a startup kit). + assert_regex "$log" 'acq-login-profile-rc' + # Deterministic lexical order: an LC_ALL=C ls list, NOT a locale-dependent glob. + assert_regex "$log" 'LC_ALL=C ls' + assert_regex "$log" 'for _acq_rc in' + assert_regex "$log" 'SC1090' + assert_regex "$log" 'unset _acq_rc' + # bash-only ~/.profile gate preserved; zsh uses native startup files in images + # that support it. + assert_regex "$log" 'BASH_VERSION' + refute_regex "$log" 'ZSH_VERSION' + # The readiness probe must happen before the bridge install, so a freshly + # created sandbox does not silently miss the hook while exec is still starting. + assert_regex "$log" 'echo ok.*acq-login-profile-rc' +} + +@test "rc.d(sbx): the login bridge does not use a startup-bearing kit (no live-extend refusal)" { + # The bridge must NOT be delivered via `sbx kit add` of a setup.startup kit — + # that path is refused on sbx >= 0.38 (see _acq_sbx_kit_add). It runs as a + # plain `sbx exec`, so provision must never emit a `kit add` for the bridge. + : > "$CALLS" + ( + acq_backend_provision rcdnostartupbox shell /tmp + ) >/dev/null 2>&1 || true + local log; log=$(cat "$CALLS") + # The rc-bridge exec line carries the marker; no `sbx kit add` line should. + local bridgelines; bridgelines=$(printf '%s\n' "$log" | grep 'acq-login-profile-rc' || true) + [ -n "$bridgelines" ] + refute_regex "$bridgelines" 'kit add' +} + @test "provision(sbx): ACQ_EXTRA_KITS is marked into ~/.acq-extra-kits at create" { : > "$CALLS" # Subshell, NOT `bash -c`: acq_backend_provision is a sourced function, which @@ -277,6 +343,38 @@ STUB refute_output --partial 'cannot extend a live sandbox' } +@test "agent-kit-heal(sbx): forced refresh includes recorded agent kit" { + cat >"$STUBDIR/sbx" <<'STUB' +#!/usr/bin/env bash +{ printf 'sbx'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >>"$CALLS" +case "${1:-}" in + version) printf 'sbx version: v0.38.0 abc123\n' ;; + ls) [ -f "$STUBDIR/.sandbox_list" ] && cat "$STUBDIR/.sandbox_list"; exit 0 ;; + exec) + snippet=""; prev="" + for a in "$@"; do [ "$prev" = "-c" ] && { snippet="$a"; break; }; prev="$a"; done + case "$snippet" in *present*) printf 'present\n' ;; *) exit 0 ;; esac ;; + kit) exit 0 ;; + settings) exit 0 ;; + *) exit 0 ;; +esac +STUB + chmod +x "$STUBDIR/sbx" + printf 'agentrefreshbox\n' > "$STUBDIR/.sandbox_list" + acq_provenance_write sbx agentrefreshbox opencode + # Simulate a READY built-in agent kit offline. The real selection gate + # (_acq_selected_builtin_kit_refs -> acq_agent_builtin_kit_ready) requires the + # pinned patterns bundle to actually ship a valid `opencode` agent kit, which + # it does not yet (enablement is deferred, quickstart #485). Stubbing + # readiness here keeps this refresh-path regression test meaningful without + # falsely enabling opencode in shipped behavior. + acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + acq_agent_builtin_kit_ready() { [ "$1" = "opencode" ]; } + _acq_builtin_kit_ref() { printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$1"; } + ( ACQ_FORCE_KIT_REAPPLY=1 acq_backend_ensure_kits_applied agentrefreshbox >/dev/null 2>&1 ) || true + assert_regex "$(cat "$CALLS")" 'acq-kits/opencode' +} + @test "provision(sbx): startup barrier timeout removes the unsafe sandbox" { cat >"$STUBDIR/sbx" <<'STUB' #!/usr/bin/env bash From 1b8aa2d92acb70c4d752aea4942f8bd91ddc59ca Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 06:29:45 +0000 Subject: [PATCH 10/37] feat(devenv): add explicit project env activation --- acq | 45 +++++++++++++++++++---- acq.backends/common.sh | 54 ++++++++++++++++++++++++++++ test/bats/30-dispatch-routing.bats | 48 ++++++++++++++++++++++++- test/bats/72-msb-agent-user.bats | 57 ++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 7 deletions(-) diff --git a/acq b/acq index a09db177..4b07a80d 100755 --- a/acq +++ b/acq @@ -139,6 +139,10 @@ Global flags (before the subcommand): var/default (a set ACQ_MSB_IMAGE wins, with a notice). The image must satisfy the base-image contract in docs/BACKEND_GUIDE.md. + ACQ_ACTIVATE_PROJECT_ENV=1 + Opt in to human-approved project env activation for + direnv/devenv workspaces. By default acq only detects and + advises; it never runs `direnv allow` or activation. run/create flags: (These are recognized only AFTER the subcommand — `acq create --publish …`, not @@ -368,8 +372,10 @@ acq exec — run a command inside a sandbox Usage: acq exec NAME -- CMD... -Runs CMD as the unprivileged agent user in the workspace. On msb, when the host -ssh-agent is forwarded, SSH_AUTH_SOCK is injected so git/ssh reach the agent. +Runs CMD as the unprivileged agent user in the workspace. If the workspace uses +direnv/devenv, acq prints activation guidance and only activates after the +explicit ACQ_ACTIVATE_PROJECT_ENV=1 opt-in. On msb, when the host ssh-agent is +forwarded, SSH_AUTH_SOCK is injected so git/ssh reach the agent. U ;; shell) @@ -379,10 +385,12 @@ acq shell — open an interactive shell in a sandbox Usage: acq shell NAME -Opens an interactive shell in an existing sandbox as the unprivileged agent -user (the human counterpart to 'acq run NAME', which relaunches the recorded -agent; 'acq exec' is non-interactive). On msb, when the host ssh-agent is -forwarded, SSH_AUTH_SOCK is injected so git/ssh reach the agent. +Opens an interactive shell in an existing sandbox as the unprivileged agent user +(the human counterpart to 'acq run NAME', which relaunches the recorded agent; +'acq exec' is non-interactive). If the workspace uses direnv/devenv, acq prints +activation guidance and only activates after the explicit +ACQ_ACTIVATE_PROJECT_ENV=1 opt-in. On msb, when the host ssh-agent is forwarded, +SSH_AUTH_SOCK is injected so git/ssh reach the agent. U ;; cp) @@ -1102,6 +1110,7 @@ case "$subcommand" in # just proceeds without a scoped token (the token is optional). [ -n "$ws" ] || ws="$PWD" advise_github_scope "$local_name" "$ws" + advise_project_env_activation "$ws" acq_print_selected_agent_kit "$create_agent" acq_backend_provision "$local_name" "$@" @@ -1196,6 +1205,7 @@ case "$subcommand" in # at create; this re-attach case is the live-refeed path. [ -n "$ws" ] || ws="$PWD" advise_github_scope "$local_name" "$ws" + advise_project_env_activation "$ws" else # Fresh create: the USAi API key MUST be stored before we provision, # because a backend (msb) binds secrets only at create time. Creating the @@ -1226,6 +1236,7 @@ case "$subcommand" in # proceeds without a scoped token (the token is optional). [ -n "$ws" ] || ws="$PWD" advise_github_scope "$local_name" "$ws" + advise_project_env_activation "$ws" acq_backend_provision "$local_name" "${create_args[@]}" fi @@ -1261,8 +1272,10 @@ case "$subcommand" in warn_if_published_ports_dead "$local_name" if [ "${#agent_args[@]}" -gt 0 ]; then + ACQ_SESSION_KIND="attach" acq_backend_attach "$local_name" -- "${agent_args[@]}" else + ACQ_SESSION_KIND="attach" acq_backend_attach "$local_name" fi @@ -1289,6 +1302,11 @@ case "$subcommand" in acq_backend_ensure_kits_applied "$first" || true maybe_offer_bundle_refresh "${ACQ_RESOLVED_BACKEND}" "$first" "$_pre_heal_status" warn_if_no_ssh_signing_key + _attach_ws="" + if command -v acq_backend_workspace_for >/dev/null 2>&1; then + _attach_ws=$(acq_backend_workspace_for "$first" 2>/dev/null || true) + fi + advise_project_env_activation "$_attach_ws" # Backend-neutral health note (same as the create-and-attach path): if the # sandbox publishes ports with nothing listening behind them, surface it @@ -1313,10 +1331,13 @@ case "$subcommand" in done if [ "${#agent_args[@]}" -gt 0 ]; then + ACQ_SESSION_KIND="attach" acq_backend_attach "$first" -- "${agent_args[@]}" elif [ "${#rest[@]}" -gt 0 ]; then + ACQ_SESSION_KIND="attach" acq_backend_attach "$first" "${rest[@]}" else + ACQ_SESSION_KIND="attach" acq_backend_attach "$first" fi else @@ -1435,6 +1456,12 @@ case "$subcommand" in local_name="${1:-}" [ -n "$local_name" ] || { echo "acq: exec: missing sandbox name" >&2; exit 1; } shift + _exec_ws="" + if command -v acq_backend_workspace_for >/dev/null 2>&1; then + _exec_ws=$(acq_backend_workspace_for "$local_name" 2>/dev/null || true) + fi + advise_project_env_activation "$_exec_ws" + ACQ_SESSION_KIND="exec" acq_backend_run "$local_name" "$@" ;; @@ -1443,6 +1470,12 @@ case "$subcommand" in acq_backend_guard_version local_name="${1:-}" [ -n "$local_name" ] || { echo "acq: shell: missing sandbox name" >&2; exit 1; } + _shell_ws="" + if command -v acq_backend_workspace_for >/dev/null 2>&1; then + _shell_ws=$(acq_backend_workspace_for "$local_name" 2>/dev/null || true) + fi + advise_project_env_activation "$_shell_ws" + ACQ_SESSION_KIND="shell" acq_backend_shell "$local_name" ;; diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 93c7f69d..fc477677 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -176,6 +176,11 @@ _acq_apply_configured_extra_kits() { # this for a single invocation (parsed in the acq entry point). ACQ_UPDATE_CHECK="${ACQ_UPDATE_CHECK:-1}" +# Human-owned activation contract: acq can detect direnv/devenv workspaces and +# print guidance, but does not evaluate project-provided activation files unless +# the user explicitly opts in for the current invocation/session. +ACQ_ACTIVATE_PROJECT_ENV="${ACQ_ACTIVATE_PROJECT_ENV:-0}" + # Kits supplied on the command line via `--kit ` (repeatable) on # run/create. These are extracted from the arg list by extract_kit_flags (below) # BEFORE the args reach the backend, so a neutral kit ref is translated by acq @@ -873,6 +878,55 @@ host_path() { printf '%s\n' "$p" } +acq_project_env_kind() { + local ws="${1:-}" + [ -n "$ws" ] && [ -d "$ws" ] || return 1 + if [ -f "$ws/.envrc" ]; then + printf 'direnv\n' + return 0 + fi + if [ -f "$ws/devenv.nix" ] || [ -f "$ws/devenv.yaml" ] || [ -f "$ws/devenv.lock" ]; then + printf 'devenv\n' + return 0 + fi + return 1 +} + +advise_project_env_activation() { + local ws="${1:-}" kind + kind=$(acq_project_env_kind "$ws" 2>/dev/null || true) + [ -n "$kind" ] || return 0 + [ "${ACQ_PROJECT_ENV_NOTICE:-1}" = "0" ] && return 0 + if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ]; then + echo "acq: project environment detected ($kind); ACQ_ACTIVATE_PROJECT_ENV=1 will" >&2 + echo " use already-approved direnv export when available." >&2 + return 0 + fi + echo "acq: project environment detected ($kind) in $ws." >&2 + echo " acq will not run project activation or 'direnv allow' automatically." >&2 + echo " If you trust this repo and have approved its .envrc yourself, re-run" >&2 + echo " this acq command with ACQ_ACTIVATE_PROJECT_ENV=1." >&2 +} + +acq_session_is_user() { + case "${ACQ_SESSION_KIND:-}" in + exec|shell|attach) return 0 ;; + *) return 1 ;; + esac +} + +acq_guest_exec_script() { + printf '%s\n' 'set -e; if [ -n "${ACQ_WORKSPACE:-}" ] && [ -d "$ACQ_WORKSPACE" ]; then cd "$ACQ_WORKSPACE"; fi; if [ -f .envrc ]; then if command -v direnv >/dev/null 2>&1; then eval "$(direnv export sh)"; else echo "acq: .envrc found, but direnv is not installed in this sandbox; continuing without activation." >&2; fi; elif [ -f devenv.nix ] || [ -f devenv.yaml ] || [ -f devenv.lock ]; then echo "acq: devenv files found, but no .envrc/direnv activation is available; continuing without activation." >&2; fi; exec "$@"' +} + +acq_guest_shell_script() { + if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ]; then + printf '%s\n' 'set -e; shell="${1:-${SHELL:-/bin/sh}}"; if [ -n "${ACQ_WORKSPACE:-}" ] && [ -d "$ACQ_WORKSPACE" ]; then cd "$ACQ_WORKSPACE"; fi; if [ -f .envrc ]; then if command -v direnv >/dev/null 2>&1; then eval "$(direnv export sh)"; else echo "acq: .envrc found, but direnv is not installed in this sandbox; continuing without activation." >&2; fi; elif [ -f devenv.nix ] || [ -f devenv.yaml ] || [ -f devenv.lock ]; then echo "acq: devenv files found, but no .envrc/direnv activation is available; continuing without activation." >&2; fi; exec "$shell" -l' + else + printf '%s\n' 'set -e; shell="${1:-${SHELL:-/bin/sh}}"; if [ -n "${ACQ_WORKSPACE:-}" ] && [ -d "$ACQ_WORKSPACE" ]; then cd "$ACQ_WORKSPACE"; fi; exec "$shell" -l' + fi +} + # _acq_valid_vsock_port PORT — succeed (return 0) iff PORT is an integer in # 1..4294967294 and not the reserved value 123. msb rejects port 0 and reserves # 123 (see ADR-0021); we mirror that validation host-side so a bad port is diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 35c3a57c..2b78c8f5 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -357,7 +357,53 @@ _seed_usai() { assert_regex "$(cat "$CALLS")" 'sbx rm --force mybox' : > "$CALLS" run env ACQ_BACKEND=sbx "$ACQ" exec mybox -- echo hi - assert_regex "$(cat "$CALLS")" 'sbx exec mybox' + assert_regex "$(cat "$CALLS")" 'sbx exec mybox -- echo hi' + refute_regex "$(cat "$CALLS")" 'direnv export|direnv allow' +} + +@test "project-env: sbx exec does not advise without a known workspace" { + run env ACQ_BACKEND=sbx "$ACQ" exec mybox -- echo hi + refute_output --partial 'project environment detected' + assert_regex "$(cat "$CALLS")" 'sbx exec mybox -- echo hi' +} + +@test "project-env: run detects direnv workspace but does not activate by default" { + local proj="$STUBDIR/direnvproj"; mkdir -p "$proj" + touch "$proj/.envrc" + _seed_usai + run env ACQ_BACKEND=sbx "$ACQ" run opencode "$proj" -- echo hi + assert_success + assert_output --partial 'project environment detected (direnv)' + assert_output --partial 'will not run project activation' + assert_output --partial 'ACQ_ACTIVATE_PROJECT_ENV=1' + local log; log=$(cat "$CALLS") + local run_line; run_line=$(grep '^sbx run --name opencode-direnvproj' "$CALLS") + assert_regex "$run_line" 'sbx run --name opencode-direnvproj -- echo hi' + refute_regex "$log" 'direnv export|direnv allow' +} + +@test "project-env: run opt-in wraps command with direnv export, never allow" { + local proj="$STUBDIR/direnvproj2"; mkdir -p "$proj" + touch "$proj/.envrc" + _seed_usai + run env ACQ_BACKEND=sbx ACQ_ACTIVATE_PROJECT_ENV=1 "$ACQ" run opencode "$proj" -- echo hi + assert_success + assert_output --partial 'project environment detected (direnv)' + local log; log=$(cat "$CALLS") + assert_regex "$log" 'sbx run --name opencode-direnvproj2 -- sh -c' + assert_regex "$log" 'direnv export sh' + refute_regex "$log" 'direnv allow' +} + +@test "project-env: create detects devenv files without activating anything" { + local proj="$STUBDIR/devenvproj"; mkdir -p "$proj" + touch "$proj/devenv.nix" + _seed_usai + run env ACQ_BACKEND=sbx "$ACQ" create opencode "$proj" + assert_success + assert_output --partial 'project environment detected (devenv)' + assert_output --partial 'will not run project activation' + refute_regex "$(cat "$CALLS")" 'direnv export|direnv allow' } @test "dispatch: an unknown subcommand passes through to the backend, announced, not doubled" { diff --git a/test/bats/72-msb-agent-user.bats b/test/bats/72-msb-agent-user.bats index 00e9c6b4..3ccb021c 100644 --- a/test/bats/72-msb-agent-user.bats +++ b/test/bats/72-msb-agent-user.bats @@ -457,3 +457,60 @@ _attach() { # PRE_SNIPPET NAME assert_regex "$log" '\-e SHELL=/bin/sh badshbox -- /bin/sh -l' refute_regex "$log" 'rm -rf' } + +@test "msb: default user exec stays direct backend argv in the primary repo" { + : > "$CALLS" + run bash -c ' + export STUB_RECORDED_WORKSPACE=/tmp/myrepo ACQ_SESSION_KIND=exec + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_backend_run wsbox -- git status >/dev/null 2>&1 + ' + local line; line=$(grep -- 'wsbox -- git status' "$CALLS") + assert_regex "$line" '\-w /tmp/myrepo' + assert_regex "$line" 'wsbox -- git status' + refute_regex "$line" 'ACQ_WORKSPACE=/tmp/myrepo' + refute_regex "$line" 'direnv export| sh -c | -lc ' +} + +@test "msb: opt-in user exec evaluates already-approved direnv export" { + : > "$CALLS" + run bash -c ' + export STUB_RECORDED_WORKSPACE=/tmp/myrepo ACQ_SESSION_KIND=exec ACQ_ACTIVATE_PROJECT_ENV=1 + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_backend_run wsbox -- git status >/dev/null 2>&1 + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" '-e ACQ_WORKSPACE=/tmp/myrepo wsbox -- sh -c' + assert_regex "$log" 'direnv export sh' + refute_regex "$log" 'direnv allow| -lc ' +} + +@test "msb: opt-in non-interactive exec does not use login flags" { + : > "$CALLS" + run bash -c ' + export STUB_RECORDED_WORKSPACE=/tmp/myrepo STUB_AGENT_PASSWD_SHELL=/bin/sh + export ACQ_SESSION_KIND=exec ACQ_ACTIVATE_PROJECT_ENV=1 + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_backend_run wsbox -- git status >/dev/null 2>&1 + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" 'wsbox -- sh -c' + refute_regex "$log" '/bin/sh -lc|/bin/bash -lc' +} + +@test "msb: internal exec helpers are not wrapped as user project sessions" { + : > "$CALLS" + run bash -c ' + export STUB_RECORDED_WORKSPACE=/tmp/myrepo ACQ_ACTIVATE_PROJECT_ENV=1 + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_backend_run wsbox -- git status >/dev/null 2>&1 + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" 'wsbox -- git status' + refute_regex "$log" 'ACQ_WORKSPACE=/tmp/myrepo' + refute_regex "$log" 'direnv export' +} From 16b9c18af9bfb98eb57238db1f9c411b9b37fa44 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 07:02:52 +0000 Subject: [PATCH 11/37] fix(devenv): constrain project env activation --- acq | 18 +++++++-------- acq.backends/common.sh | 18 +++++++++++---- test/bats/30-dispatch-routing.bats | 37 ++++++++++++++++++++++++++---- test/bats/72-msb-agent-user.bats | 23 ++++++++++++++++--- 4 files changed, 75 insertions(+), 21 deletions(-) diff --git a/acq b/acq index 4b07a80d..67abaa96 100755 --- a/acq +++ b/acq @@ -140,9 +140,9 @@ Global flags (before the subcommand): image must satisfy the base-image contract in docs/BACKEND_GUIDE.md. ACQ_ACTIVATE_PROJECT_ENV=1 - Opt in to human-approved project env activation for - direnv/devenv workspaces. By default acq only detects and - advises; it never runs `direnv allow` or activation. + Opt in to using already-approved `direnv export` for + workspaces with .envrc. Devenv-only files are detected and + advised, but acq never runs `direnv allow` or `devenv shell`. run/create flags: (These are recognized only AFTER the subcommand — `acq create --publish …`, not @@ -373,9 +373,9 @@ Usage: acq exec NAME -- CMD... Runs CMD as the unprivileged agent user in the workspace. If the workspace uses -direnv/devenv, acq prints activation guidance and only activates after the -explicit ACQ_ACTIVATE_PROJECT_ENV=1 opt-in. On msb, when the host ssh-agent is -forwarded, SSH_AUTH_SOCK is injected so git/ssh reach the agent. +direnv/devenv, acq prints activation guidance; with ACQ_ACTIVATE_PROJECT_ENV=1 it +uses already-approved `direnv export` when .envrc is present. On msb, when the +host ssh-agent is forwarded, SSH_AUTH_SOCK is injected so git/ssh reach the agent. U ;; shell) @@ -388,9 +388,9 @@ Usage: Opens an interactive shell in an existing sandbox as the unprivileged agent user (the human counterpart to 'acq run NAME', which relaunches the recorded agent; 'acq exec' is non-interactive). If the workspace uses direnv/devenv, acq prints -activation guidance and only activates after the explicit -ACQ_ACTIVATE_PROJECT_ENV=1 opt-in. On msb, when the host ssh-agent is forwarded, -SSH_AUTH_SOCK is injected so git/ssh reach the agent. +activation guidance; with ACQ_ACTIVATE_PROJECT_ENV=1 it uses already-approved +`direnv export` when .envrc is present. On msb, when the host ssh-agent is +forwarded, SSH_AUTH_SOCK is injected so git/ssh reach the agent. U ;; cp) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index fc477677..a6500077 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -180,6 +180,10 @@ ACQ_UPDATE_CHECK="${ACQ_UPDATE_CHECK:-1}" # print guidance, but does not evaluate project-provided activation files unless # the user explicitly opts in for the current invocation/session. ACQ_ACTIVATE_PROJECT_ENV="${ACQ_ACTIVATE_PROJECT_ENV:-0}" +# In-process marker set only by acq dispatch before user-facing exec/shell/attach. +# Ignore inherited environment values so internal helper probes cannot be wrapped +# by setting ACQ_SESSION_KIND outside acq. +ACQ_SESSION_KIND="" # Kits supplied on the command line via `--kit ` (repeatable) on # run/create. These are extracted from the arg list by extract_kit_flags (below) @@ -903,9 +907,9 @@ advise_project_env_activation() { return 0 fi echo "acq: project environment detected ($kind) in $ws." >&2 - echo " acq will not run project activation or 'direnv allow' automatically." >&2 + echo " acq will not run project activation, 'direnv allow', or 'devenv shell' automatically." >&2 echo " If you trust this repo and have approved its .envrc yourself, re-run" >&2 - echo " this acq command with ACQ_ACTIVATE_PROJECT_ENV=1." >&2 + echo " this acq command with ACQ_ACTIVATE_PROJECT_ENV=1 to use direnv export." >&2 } acq_session_is_user() { @@ -1732,9 +1736,9 @@ _acq_provenance_file() { # agent selected at create time; when omitted, an existing agent field is # preserved across in-place refreshes. Best-effort: a write failure warns (debug) # and returns non-zero but never aborts the caller (fail-open). -# Usage: acq_provenance_write BACKEND SANDBOX_NAME [AGENT] +# Usage: acq_provenance_write BACKEND SANDBOX_NAME [AGENT] [WORKSPACE] acq_provenance_write() { - local backend="${1:-}" name="${2:-}" agent="${3:-}" + local backend="${1:-}" name="${2:-}" agent="${3:-}" workspace="${4:-}" [ -n "$backend" ] && [ -n "$name" ] || return 1 local file dir ts workspace workspace_source file=$(_acq_provenance_file "$backend" "$name") || return 1 @@ -1749,10 +1753,15 @@ acq_provenance_write() { fi ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "unknown") [ -n "$agent" ] || agent=$(acq_provenance_field "$backend" "$name" agent) + [ -n "$workspace" ] || workspace=$(acq_provenance_field "$backend" "$name" workspace) case "$agent" in "") : ;; *[!a-z-]*) agent="" ;; esac + case "$workspace" in + "") : ;; + *[!A-Za-z0-9._/-]*) workspace="" ;; + esac # Write atomically via a temp file + mv so a crash mid-write can't leave a # half-written record that later parses as a bogus "current" ref. local tmp="${file}.tmp.$$" @@ -1763,6 +1772,7 @@ acq_provenance_write() { printf 'applied_ref=%s\n' "$PATTERNS_KIT_REF" printf 'backend=%s\n' "$backend" [ -n "$agent" ] && printf 'agent=%s\n' "$agent" + [ -n "$workspace" ] && printf 'workspace=%s\n' "$workspace" printf 'applied_at=%s\n' "$ts" if [ -n "${workspace:-}" ]; then printf 'workspace_source=host\n' diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 2b78c8f5..f7e529dd 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -361,9 +361,12 @@ _seed_usai() { refute_regex "$(cat "$CALLS")" 'direnv export|direnv allow' } -@test "project-env: sbx exec does not advise without a known workspace" { +@test "project-env: sbx exec advises when workspace provenance is known" { + local proj="$STUBDIR/known-env"; mkdir -p "$proj" + touch "$proj/.envrc" + acq_provenance_write sbx mybox opencode "$proj" run env ACQ_BACKEND=sbx "$ACQ" exec mybox -- echo hi - refute_output --partial 'project environment detected' + assert_output --partial 'project environment detected (direnv)' assert_regex "$(cat "$CALLS")" 'sbx exec mybox -- echo hi' } @@ -382,19 +385,31 @@ _seed_usai() { refute_regex "$log" 'direnv export|direnv allow' } -@test "project-env: run opt-in wraps command with direnv export, never allow" { +@test "project-env: run opt-in wraps recorded agent with direnv export" { local proj="$STUBDIR/direnvproj2"; mkdir -p "$proj" touch "$proj/.envrc" _seed_usai - run env ACQ_BACKEND=sbx ACQ_ACTIVATE_PROJECT_ENV=1 "$ACQ" run opencode "$proj" -- echo hi + run env ACQ_BACKEND=sbx ACQ_ACTIVATE_PROJECT_ENV=1 "$ACQ" run opencode "$proj" -- --version assert_success assert_output --partial 'project environment detected (direnv)' local log; log=$(cat "$CALLS") - assert_regex "$log" 'sbx run --name opencode-direnvproj2 -- sh -c' + assert_regex "$log" 'sbx run --name opencode-direnvproj2 -- env ACQ_WORKSPACE=.* sh -c' assert_regex "$log" 'direnv export sh' + assert_regex "$log" 'sh opencode --version' refute_regex "$log" 'direnv allow' } +@test "project-env: run opt-in wraps normal sbx attach too" { + local proj="$STUBDIR/direnvproj3"; mkdir -p "$proj" + touch "$proj/.envrc" + _seed_usai + run env ACQ_BACKEND=sbx ACQ_ACTIVATE_PROJECT_ENV=1 "$ACQ" run opencode "$proj" + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" 'sbx run --name opencode-direnvproj3 -- env ACQ_WORKSPACE=.* sh -c' + assert_regex "$log" 'sh opencode' +} + @test "project-env: create detects devenv files without activating anything" { local proj="$STUBDIR/devenvproj"; mkdir -p "$proj" touch "$proj/devenv.nix" @@ -406,6 +421,18 @@ _seed_usai() { refute_regex "$(cat "$CALLS")" 'direnv export|direnv allow' } +@test "project-env: inherited session marker cannot wrap sbx helper exec" { + run bash -c ' + export ACQ_ACTIVATE_PROJECT_ENV=1 ACQ_SESSION_KIND=exec + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/sbx.sh" + acq_backend_run helperbox -- sh -c "echo probe" >/dev/null 2>&1 + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" 'sbx exec helperbox -- sh -c echo probe' + refute_regex "$log" 'direnv export|ACQ_WORKSPACE' +} + @test "dispatch: an unknown subcommand passes through to the backend, announced, not doubled" { run env ACQ_BACKEND=sbx "$ACQ" policy init balanced local log; log=$(cat "$CALLS") diff --git a/test/bats/72-msb-agent-user.bats b/test/bats/72-msb-agent-user.bats index 3ccb021c..fa81216b 100644 --- a/test/bats/72-msb-agent-user.bats +++ b/test/bats/72-msb-agent-user.bats @@ -461,9 +461,10 @@ _attach() { # PRE_SNIPPET NAME @test "msb: default user exec stays direct backend argv in the primary repo" { : > "$CALLS" run bash -c ' - export STUB_RECORDED_WORKSPACE=/tmp/myrepo ACQ_SESSION_KIND=exec + export STUB_RECORDED_WORKSPACE=/tmp/myrepo . "'"$REPO_ROOT"'/acq.backends/common.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + ACQ_SESSION_KIND=exec acq_backend_run wsbox -- git status >/dev/null 2>&1 ' local line; line=$(grep -- 'wsbox -- git status' "$CALLS") @@ -476,9 +477,10 @@ _attach() { # PRE_SNIPPET NAME @test "msb: opt-in user exec evaluates already-approved direnv export" { : > "$CALLS" run bash -c ' - export STUB_RECORDED_WORKSPACE=/tmp/myrepo ACQ_SESSION_KIND=exec ACQ_ACTIVATE_PROJECT_ENV=1 + export STUB_RECORDED_WORKSPACE=/tmp/myrepo ACQ_ACTIVATE_PROJECT_ENV=1 . "'"$REPO_ROOT"'/acq.backends/common.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + ACQ_SESSION_KIND=exec acq_backend_run wsbox -- git status >/dev/null 2>&1 ' local log; log=$(cat "$CALLS") @@ -491,9 +493,10 @@ _attach() { # PRE_SNIPPET NAME : > "$CALLS" run bash -c ' export STUB_RECORDED_WORKSPACE=/tmp/myrepo STUB_AGENT_PASSWD_SHELL=/bin/sh - export ACQ_SESSION_KIND=exec ACQ_ACTIVATE_PROJECT_ENV=1 + export ACQ_ACTIVATE_PROJECT_ENV=1 . "'"$REPO_ROOT"'/acq.backends/common.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + ACQ_SESSION_KIND=exec acq_backend_run wsbox -- git status >/dev/null 2>&1 ' local log; log=$(cat "$CALLS") @@ -514,3 +517,17 @@ _attach() { # PRE_SNIPPET NAME refute_regex "$log" 'ACQ_WORKSPACE=/tmp/myrepo' refute_regex "$log" 'direnv export' } + +@test "msb: inherited session marker cannot wrap internal helper exec" { + : > "$CALLS" + run bash -c ' + export STUB_RECORDED_WORKSPACE=/tmp/myrepo ACQ_ACTIVATE_PROJECT_ENV=1 ACQ_SESSION_KIND=exec + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_backend_run wsbox -- git status >/dev/null 2>&1 + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" 'wsbox -- git status' + refute_regex "$log" 'ACQ_WORKSPACE=/tmp/myrepo' + refute_regex "$log" 'direnv export' +} From ac065777b2383c910a34d815dbaf9bb02bb8ad68 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 07:44:11 +0000 Subject: [PATCH 12/37] feat(provider): centralize usai provider facts --- acq.backends/common.sh | 45 ++++++++++++++----------- test/bats/30-dispatch-routing.bats | 1 + test/bats/50-kit-list-completeness.bats | 13 +++++++ 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index a6500077..388ba6e2 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -39,7 +39,8 @@ PATTERNS_KIT_REPO="git+https://github.com/GSA-TTS/agentic-coding-patterns.git" PATTERNS_KIT_REF="6c6753c60a2b24322fb2e8c0d8e8af60c56ede8f" # agentic-coding-patterns v1.9.0 PATTERNS_KIT_DIR="integrations/isolation/acq-kits" -USAI_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR}/usai-provider" +USAI_PROVIDER_KIT_NAME="usai-provider" +USAI_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR}/${USAI_PROVIDER_KIT_NAME}" PLAYBOOK_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR}/agentic-coding-playbook" ZSCALER_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR}/zscaler-ca-certificate" GITSSHSIGN_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR}/git-ssh-sign" @@ -55,7 +56,7 @@ GITSSHSIGN_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_ # intercepting CA is already trusted. Establishing trust first makes the rest # of the bundle succeed on corporate networks. # shellcheck disable=SC2034 # consumed by `acq kit list` in the acq entry point -ACQ_KIT_NAMES=(zscaler-ca-certificate usai-provider agentic-coding-playbook git-ssh-sign) +ACQ_KIT_NAMES=(zscaler-ca-certificate "$USAI_PROVIDER_KIT_NAME" agentic-coding-playbook git-ssh-sign) # One-line descriptions for the built-in kits, parallel to ACQ_KIT_NAMES, shown # as the frozen ("always applied") rows in the `acq configure` picker (ADR-0031). @@ -206,9 +207,13 @@ ACQ_PUBLISH_FLAGS=() KIT_SOURCE_PREFIX="github.com/GSA-TTS/" KIT_SOURCE_PREFIXES=("$KIT_SOURCE_PREFIX") -# USAi endpoint constants -USAI_MODELS_URL="https://api.gsa.usai.gov/api/v1/models" -KEY_MGMT_URL="https://gsa.usai.gov/console/key-management" +# Neutral USAi provider facts. The provider kit owns these facts; agent kits own +# rendering them into agent-specific config formats. +USAI_PROVIDER_HOST="api.gsa.usai.gov" +USAI_PROVIDER_BASE_URL="https://${USAI_PROVIDER_HOST}/api/v1" +USAI_PROVIDER_KEY_ENV="USAI_API_KEY" +USAI_PROVIDER_MODELS_URL="${USAI_PROVIDER_BASE_URL}/models" +USAI_PROVIDER_KEY_MGMT_URL="https://gsa.usai.gov/console/key-management" # Source the shared agent catalog (single source of truth for agent tokens and # the sandbox-template image naming convention; issue #377). Both adapters also @@ -300,7 +305,7 @@ ACQ_MANAGED_SECRET_SERVICES=" usai github gitlab " # Only acq-managed services are importable; an unknown service echoes nothing. _acq_import_env_vars_for() { case "$1" in - usai) printf 'USAI_API_KEY\n' ;; + usai) printf '%s\n' "$USAI_PROVIDER_KEY_ENV" ;; github) printf 'GITHUB_TOKEN GH_TOKEN\n' ;; gitlab) printf 'GITLAB_TOKEN\n' ;; *) printf '\n' ;; @@ -652,7 +657,7 @@ _acq_builtin_kit_ref() { } _acq_builtin_support_kit_names() { - printf '%s\n' zscaler-ca-certificate usai-provider agentic-coding-playbook git-ssh-sign + printf '%s\n' zscaler-ca-certificate "$USAI_PROVIDER_KIT_NAME" agentic-coding-playbook git-ssh-sign } _acq_agent_builtin_kit_ref() { @@ -2148,11 +2153,12 @@ ensure_opencode_postinstall() { # no free-form error text can splice into the caller's "(HTTP …)" message. check_key() { local name="$1" - local raw code exit_code + local raw code exit_code key_ref + key_ref="\$${USAI_PROVIDER_KEY_ENV}" raw=$(acq_backend_run "$name" -- sh -c \ "curl -sS -o /dev/null -w '%{http_code}' \ - -H \"Authorization: Bearer \$USAI_API_KEY\" \ - $USAI_MODELS_URL; printf '|%s' \"\$?\"" 2>/dev/null || true) + -H \"Authorization: Bearer $key_ref\" \ + $USAI_PROVIDER_MODELS_URL; printf '|%s' \"\$?\"" 2>/dev/null || true) _classify_key_status "$raw" } @@ -2223,11 +2229,12 @@ check_fresh_sandbox_key() { fi # shellcheck disable=SC2064 trap "acq_backend_terminate '$validation_name' /dev/null 2>&1 || true" EXIT - local raw + local raw key_ref + key_ref="\$${USAI_PROVIDER_KEY_ENV}" raw=$(acq_backend_run "$validation_name" -- sh -c \ "curl -sS -o /dev/null -w '%{http_code}' \ - -H \"Authorization: Bearer \$USAI_API_KEY\" \ - $USAI_MODELS_URL; printf '|%s' \"\$?\"" /dev/null || true) + -H \"Authorization: Bearer $key_ref\" \ + $USAI_PROVIDER_MODELS_URL; printf '|%s' \"\$?\"" /dev/null || true) status=$(_classify_key_status "$raw") acq_backend_terminate "$validation_name" /dev/null 2>&1 || true trap - EXIT @@ -2820,7 +2827,7 @@ advise_valid_key() { # the docs rather than guessing the user's network fix. _report_usai_unreachable() { echo >&2 - echo "acq: could not reach the USAi API ($USAI_MODELS_URL) from the sandbox." >&2 + echo "acq: could not reach the USAi API ($USAI_PROVIDER_MODELS_URL) from the sandbox." >&2 echo " The request did not complete (no HTTP response) — this is a network" >&2 echo " reachability problem, NOT an invalid or expired key, so rotating the" >&2 echo " key will not help." >&2 @@ -2847,7 +2854,7 @@ _report_usai_unreachable() { # and point at the docs. _report_usai_unresolved() { echo >&2 - echo "acq: the USAi API host in $USAI_MODELS_URL did not RESOLVE from the sandbox" >&2 + echo "acq: the USAi API host in $USAI_PROVIDER_MODELS_URL did not RESOLVE from the sandbox" >&2 echo " (DNS returned no address). This is a name-resolution problem, NOT an" >&2 echo " invalid or expired key, so rotating the key will not help." >&2 echo " If other public hosts (GitHub, npm) work from the sandbox but only" >&2 @@ -2938,16 +2945,16 @@ ensure_key_present() { # emit a single terse line and fail closed rather than the full interactive # help (mirrors the non-tty guard in the kit-update path above). if [ ! -t 0 ]; then - echo "acq: no USAi API key stored; set one with 'acq secret set -g usai' (see $KEY_MGMT_URL). Aborting." >&2 + echo "acq: no USAi API key stored; set one with 'acq secret set -g usai' (see $USAI_PROVIDER_KEY_MGMT_URL). Aborting." >&2 return 1 fi echo >&2 echo "No USAi API key is stored yet." >&2 - echo "USAi keys are created at $KEY_MGMT_URL and expire every 7 days." >&2 + echo "USAi keys are created at $USAI_PROVIDER_KEY_MGMT_URL and expire every 7 days." >&2 echo >&2 echo "To set one:" >&2 - echo " 1. Open $KEY_MGMT_URL" >&2 + echo " 1. Open $USAI_PROVIDER_KEY_MGMT_URL" >&2 echo " 2. Create a key (or copy an existing one) with the console copy button" >&2 echo >&2 @@ -3022,7 +3029,7 @@ ensure_valid_key() { echo "USAi keys expire every 7 days." >&2 echo >&2 echo "To rotate it:" >&2 - echo " 1. Open $KEY_MGMT_URL" >&2 + echo " 1. Open $USAI_PROVIDER_KEY_MGMT_URL" >&2 echo " 2. Choose 'Rotate' from the Actions menu for your key" >&2 echo " 3. Copy the new key using the console copy button" >&2 echo >&2 diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index f7e529dd..6163c213 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -275,6 +275,7 @@ _seed_usai() { run env STUB_OPENCODE_OK=1 STUB_KEY_STATUS=200 ACQ_BACKEND=sbx "$ACQ" run opencode "$proj" assert_success refute_output --partial 'Aborting attach' + assert_regex "$(cat "$CALLS")" 'Authorization: Bearer \$USAI_API_KEY' } @test "run(opencode): runs postinstall under a timeout guard when binary not functional" { diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index b9ae0877..50ff13cd 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -36,6 +36,19 @@ load 'helper' done } +@test "provider-facts: USAi facts are distinct from agent config rendering" { + load_acq + + assert_equal "$USAI_PROVIDER_KIT_NAME" "usai-provider" + assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" + assert_equal "$USAI_PROVIDER_BASE_URL" "https://api.gsa.usai.gov/api/v1" + assert_equal "$USAI_PROVIDER_KEY_ENV" "USAI_API_KEY" + assert_equal "$USAI_PROVIDER_MODELS_URL" "https://api.gsa.usai.gov/api/v1/models" + assert_equal "$USAI_PROVIDER_KEY_MGMT_URL" "https://gsa.usai.gov/console/key-management" + assert_regex "$USAI_KIT" "acq-kits/${USAI_PROVIDER_KIT_NAME}$" + refute_regex "$USAI_PROVIDER_MODELS_URL" 'opencode' +} + @test "agent-kits: default kit list is support-only" { load_acq ACQ_EXTRA_KITS="" From d6c54b19706934a1fad439becc2cc78037257038 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 18:48:58 +0000 Subject: [PATCH 13/37] docs(provider): define provider facts artifact contract --- acq.backends/common.sh | 7 ++- docs/adr/0030-agent-kits-on-devenv-base.md | 63 ++++++++++++++++++++++ test/bats/50-kit-list-completeness.bats | 2 +- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 388ba6e2..10be2804 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -207,8 +207,11 @@ ACQ_PUBLISH_FLAGS=() KIT_SOURCE_PREFIX="github.com/GSA-TTS/" KIT_SOURCE_PREFIXES=("$KIT_SOURCE_PREFIX") -# Neutral USAi provider facts. The provider kit owns these facts; agent kits own -# rendering them into agent-specific config formats. +# Transitional fallback defaults for the pinned usai-provider kit. ADR-0030's +# target state is for the kit to export these provider facts as static metadata +# consumable before sandbox creation. acq keeps these defaults only for backend +# credential binding, key validation, and offline tests until that artifact exists. +# These values are not authoritative once the artifact is available. USAI_PROVIDER_HOST="api.gsa.usai.gov" USAI_PROVIDER_BASE_URL="https://${USAI_PROVIDER_HOST}/api/v1" USAI_PROVIDER_KEY_ENV="USAI_API_KEY" diff --git a/docs/adr/0030-agent-kits-on-devenv-base.md b/docs/adr/0030-agent-kits-on-devenv-base.md index 3f41bf3a..f241d147 100644 --- a/docs/adr/0030-agent-kits-on-devenv-base.md +++ b/docs/adr/0030-agent-kits-on-devenv-base.md @@ -242,6 +242,27 @@ host's concern and use podman-compose, chosen for cross-platform support including Windows (devenv does not run on Windows), rather than a second devenv supervision story. +### Kit roles + +This ADR uses "provider kit", "agent kit", and "capability kit" as role labels +within the same neutral kit model. They are not separate schema families unless a +future ADR introduces role-specific validation. + +A provider-role kit publishes provider facts as static data that `acq` can +consume before sandbox creation. A provider-role kit does not render +agent-specific configuration. + +An agent-role kit installs or exposes an agent harness, declares its entrypoint, +and renders that harness's configuration from provider facts. An agent-role kit +does not define provider authority. + +A capability-role kit adds shared sandbox capabilities such as CA trust, playbook +configuration, git signing, or OCI tooling. + +A single kit may eventually carry multiple roles, but the default expectation is +one primary role per built-in kit so ownership and conflict behavior remain +clear. + ### Provider configuration `usai-provider` exports the provider **facts** (endpoint, key environment @@ -250,6 +271,48 @@ configuration format. `acq` never merges agent configuration files — the agent owns its config-merge semantics, for example OpenCode's project-layer deep merge for its permission gate. +#### Provider facts artifact contract + +The `usai-provider` kit is the authoritative source for USAi provider facts. It +publishes those facts as a static, non-executable metadata artifact in the kit +tree so `acq` can consume them before sandbox creation. + +The initial artifact path is: + +```text +provider-facts/usai.env +``` + +The file is parsed as data, never sourced or evaluated. `acq` accepts only known +keys and validates each value before use. The initial schema is: + +```sh +ACQ_PROVIDER_FACTS_SCHEMA=1 +ACQ_PROVIDER_ID=usai +ACQ_PROVIDER_HOST=api.gsa.usai.gov +ACQ_PROVIDER_BASE_URL=https://api.gsa.usai.gov/api/v1 +ACQ_PROVIDER_MODELS_URL=https://api.gsa.usai.gov/api/v1/models +ACQ_PROVIDER_KEY_ENV=USAI_API_KEY +ACQ_PROVIDER_KEY_MGMT_URL=https://gsa.usai.gov/console/key-management +ACQ_PROVIDER_BIND_HOSTS=api.gsa.usai.gov +``` + +`acq` may use provider facts only for sandbox lifecycle and credential plumbing: +backend secret binding, key validation, key-management guidance, and network/DNS +diagnostics. Agent-specific configuration rendering remains the responsibility +of agent kits. + +Precedence is: + +```text +explicit user --host/--env metadata > provider-facts artifact > acq fallback defaults +``` + +Until the pinned `usai-provider` kit includes this artifact, `acq` may carry +transitional fallback defaults for current backend behavior. Those fallback +defaults are not authoritative and must be removed or demoted once the artifact +is available at the pinned kit ref. + ### Bring-your-own image contract The contract must pin **where the Nix store lives** (`/nix`), not merely require diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index 50ff13cd..686528f7 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -36,7 +36,7 @@ load 'helper' done } -@test "provider-facts: USAi facts are distinct from agent config rendering" { +@test "provider-facts: transitional USAi fallback defaults remain neutral" { load_acq assert_equal "$USAI_PROVIDER_KIT_NAME" "usai-provider" From f5c61d6456ed8576012940a77da10ab7c264a3f5 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 19:07:11 +0000 Subject: [PATCH 14/37] chore(shellcheck): annotate sourced kit variables --- acq.backends/common.sh | 5 +++++ test/bats/50-kit-list-completeness.bats | 2 ++ 2 files changed, 7 insertions(+) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 10be2804..0a2fdbe3 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -40,9 +40,13 @@ PATTERNS_KIT_REF="6c6753c60a2b24322fb2e8c0d8e8af60c56ede8f" # agentic-coding-pa PATTERNS_KIT_DIR="integrations/isolation/acq-kits" USAI_PROVIDER_KIT_NAME="usai-provider" +# shellcheck disable=SC2034 # consumed by _acq_builtin_kit_ref after sourcing USAI_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR}/${USAI_PROVIDER_KIT_NAME}" +# shellcheck disable=SC2034 # consumed by _acq_builtin_kit_ref after sourcing PLAYBOOK_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR}/agentic-coding-playbook" +# shellcheck disable=SC2034 # consumed by _acq_builtin_kit_ref after sourcing ZSCALER_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR}/zscaler-ca-certificate" +# shellcheck disable=SC2034 # consumed by _acq_builtin_kit_ref after sourcing GITSSHSIGN_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_DIR}/git-ssh-sign" # Neutral kit directory names (relative to PATTERNS_KIT_DIR), in apply order. @@ -714,6 +718,7 @@ _build_kit_list() { while IFS= read -r kit; do [ -n "$kit" ] && KITS+=("$kit") done < <(_acq_selected_builtin_kit_refs "$agent") + # shellcheck disable=SC2034 # read by adapters/tests after _build_kit_list ACQ_BUILTIN_KIT_COUNT="${#KITS[@]}" if [ -n "$ACQ_EXTRA_KITS" ]; then local _extra_kits=() diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index 686528f7..3ec043b3 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -109,7 +109,9 @@ load 'helper' @test "agent-kits: ACQ_EXTRA_KITS never drive implicit selection" { load_acq + # shellcheck disable=SC2034 # read by sourced _build_kit_list ACQ_EXTRA_KITS="/tmp/opencode" + # shellcheck disable=SC2034 # read by sourced _build_kit_list ACQ_CLI_KITS=() _build_kit_list shell From 356328f4414c4c9aecadeae9bd54bf13e35fcc08 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 19:13:48 +0000 Subject: [PATCH 15/37] chore(shellcheck): document optional kit-list argument --- acq.backends/common.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 0a2fdbe3..b58daf90 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -712,6 +712,7 @@ acq_print_selected_agent_kit() { # Assemble the full kit list: selected built-ins, then extras. # Zscaler CA trust FIRST (see ACQ_KIT_NAMES) so later network-fetching kits # succeed behind a TLS-intercepting proxy. +# shellcheck disable=SC2120 # optional agent argument; many callers use default _build_kit_list() { local agent="${1:-}" kit KITS=() From 8c509a7a49b027d84b407e67ddbe0a049113898e Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 20:52:50 +0000 Subject: [PATCH 16/37] feat(provider): load usai facts artifact --- acq.backends/common.sh | 72 +++++++++++++++++++++++++ test/bats/50-kit-list-completeness.bats | 55 +++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index b58daf90..8f1e6e6f 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -221,6 +221,8 @@ USAI_PROVIDER_BASE_URL="https://${USAI_PROVIDER_HOST}/api/v1" USAI_PROVIDER_KEY_ENV="USAI_API_KEY" USAI_PROVIDER_MODELS_URL="${USAI_PROVIDER_BASE_URL}/models" USAI_PROVIDER_KEY_MGMT_URL="https://gsa.usai.gov/console/key-management" +USAI_PROVIDER_BIND_HOSTS="${USAI_PROVIDER_HOST}" +USAI_PROVIDER_FACTS_SOURCE="fallback" # Source the shared agent catalog (single source of truth for agent tokens and # the sandbox-template image naming convention; issue #377). Both adapters also @@ -319,6 +321,76 @@ _acq_import_env_vars_for() { esac } +_acq_provider_fact_safe_host() { + case "$1" in + ""|*://*|*/*|*..*|*[^A-Za-z0-9.,:-]*) return 1 ;; + *) return 0 ;; + esac +} + +_acq_provider_fact_safe_env() { + case "$1" in + ""|[!A-Za-z_]*|*[!A-Za-z0-9_]*) return 1 ;; + *) return 0 ;; + esac +} + +_acq_provider_fact_safe_url() { + case "$1" in + https://*) ;; + *) return 1 ;; + esac + case "$1" in + *[[:space:]]*|*[[:cntrl:]]*) return 1 ;; + *) return 0 ;; + esac +} + +acq_provider_facts_load() { + local file="${1:-}" line key value schema="" id="" + local host="" base_url="" models_url="" key_env="" key_mgmt_url="" bind_hosts="" + [ -n "$file" ] || return 1 + [ -f "$file" ] || return 2 + + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ""|'#'*) continue ;; esac + case "$line" in *=*) ;; *) return 1 ;; esac + key="${line%%=*}" + value="${line#*=}" + case "$key" in + ACQ_PROVIDER_FACTS_SCHEMA) schema="$value" ;; + ACQ_PROVIDER_ID) id="$value" ;; + ACQ_PROVIDER_HOST) host="$value" ;; + ACQ_PROVIDER_BASE_URL) base_url="$value" ;; + ACQ_PROVIDER_MODELS_URL) models_url="$value" ;; + ACQ_PROVIDER_KEY_ENV) key_env="$value" ;; + ACQ_PROVIDER_KEY_MGMT_URL) key_mgmt_url="$value" ;; + ACQ_PROVIDER_BIND_HOSTS) bind_hosts="$value" ;; + *) return 1 ;; + esac + done < "$file" + + [ "$schema" = "1" ] || return 1 + [ "$id" = "usai" ] || return 1 + _acq_provider_fact_safe_host "$host" || return 1 + _acq_provider_fact_safe_url "$base_url" || return 1 + _acq_provider_fact_safe_url "$models_url" || return 1 + _acq_provider_fact_safe_url "$key_mgmt_url" || return 1 + _acq_provider_fact_safe_env "$key_env" || return 1 + [ -n "$bind_hosts" ] || bind_hosts="$host" + _acq_provider_fact_safe_host "$bind_hosts" || return 1 + + USAI_PROVIDER_HOST="$host" + USAI_PROVIDER_BASE_URL="$base_url" + USAI_PROVIDER_MODELS_URL="$models_url" + USAI_PROVIDER_KEY_ENV="$key_env" + USAI_PROVIDER_KEY_MGMT_URL="$key_mgmt_url" + # shellcheck disable=SC2034 # consumed by adapters/tests after facts load + USAI_PROVIDER_BIND_HOSTS="$bind_hosts" + # shellcheck disable=SC2034 # consumed by diagnostics/tests after facts load + USAI_PROVIDER_FACTS_SOURCE="$file" +} + # _acq_import_detect_var SERVICE -> prints the NAME of the FIRST of SERVICE's # candidate env vars that is set and non-empty; empty output (rc 1) if none is # set. Returns the variable NAME ONLY — never the value — so the secret value is diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index 3ec043b3..c67bcf4a 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -45,10 +45,65 @@ load 'helper' assert_equal "$USAI_PROVIDER_KEY_ENV" "USAI_API_KEY" assert_equal "$USAI_PROVIDER_MODELS_URL" "https://api.gsa.usai.gov/api/v1/models" assert_equal "$USAI_PROVIDER_KEY_MGMT_URL" "https://gsa.usai.gov/console/key-management" + assert_equal "$USAI_PROVIDER_BIND_HOSTS" "api.gsa.usai.gov" + assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" assert_regex "$USAI_KIT" "acq-kits/${USAI_PROVIDER_KIT_NAME}$" refute_regex "$USAI_PROVIDER_MODELS_URL" 'opencode' } +@test "provider-facts: valid artifact overrides fallback defaults" { + load_acq + local facts="$STUBDIR/usai.env" + cat > "$facts" <<'FACTS' +ACQ_PROVIDER_FACTS_SCHEMA=1 +ACQ_PROVIDER_ID=usai +ACQ_PROVIDER_HOST=api.example.gov +ACQ_PROVIDER_BASE_URL=https://api.example.gov/api/v1 +ACQ_PROVIDER_MODELS_URL=https://api.example.gov/api/v1/models +ACQ_PROVIDER_KEY_ENV=EXAMPLE_API_KEY +ACQ_PROVIDER_KEY_MGMT_URL=https://example.gov/keys +ACQ_PROVIDER_BIND_HOSTS=api.example.gov,models.example.gov +FACTS + + acq_provider_facts_load "$facts" + assert_equal "$USAI_PROVIDER_HOST" "api.example.gov" + assert_equal "$USAI_PROVIDER_BASE_URL" "https://api.example.gov/api/v1" + assert_equal "$USAI_PROVIDER_MODELS_URL" "https://api.example.gov/api/v1/models" + assert_equal "$USAI_PROVIDER_KEY_ENV" "EXAMPLE_API_KEY" + assert_equal "$USAI_PROVIDER_KEY_MGMT_URL" "https://example.gov/keys" + assert_equal "$USAI_PROVIDER_BIND_HOSTS" "api.example.gov,models.example.gov" + assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "$facts" +} + +@test "provider-facts: missing artifact leaves fallback path available" { + load_acq + run acq_provider_facts_load "$STUBDIR/missing.env" + assert_failure 2 + assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" + assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" +} + +@test "provider-facts: invalid artifact fails closed without partial override" { + load_acq + local facts="$STUBDIR/bad-usai.env" + cat > "$facts" <<'FACTS' +ACQ_PROVIDER_FACTS_SCHEMA=1 +ACQ_PROVIDER_ID=usai +ACQ_PROVIDER_HOST=api.bad.gov +ACQ_PROVIDER_BASE_URL=https://api.bad.gov/api/v1 +ACQ_PROVIDER_MODELS_URL=https://api.bad.gov/api/v1/models +ACQ_PROVIDER_KEY_ENV=bad-key-name +ACQ_PROVIDER_KEY_MGMT_URL=https://bad.gov/keys +ACQ_PROVIDER_BIND_HOSTS=api.bad.gov +FACTS + + run acq_provider_facts_load "$facts" + assert_failure + assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" + assert_equal "$USAI_PROVIDER_KEY_ENV" "USAI_API_KEY" + assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" +} + @test "agent-kits: default kit list is support-only" { load_acq ACQ_EXTRA_KITS="" From 2f2bdd21b560da8ffede65717a89015fc69b75ca Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 21:04:39 +0000 Subject: [PATCH 17/37] feat(provider): load facts from usai kit --- acq | 7 ++++ acq.backends/common.sh | 20 ++++++++++ test/bats/50-kit-list-completeness.bats | 53 +++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/acq b/acq index 67abaa96..5d73d22f 100755 --- a/acq +++ b/acq @@ -946,6 +946,13 @@ esac # Resolve and load the backend adapter (required for all other subcommands). # --------------------------------------------------------------------------- acq_resolve_backend "${explicit_backend}" +_acq_provider_facts_rc=0 +acq_provider_facts_load_from_kit "$USAI_KIT" || _acq_provider_facts_rc=$? +case "$_acq_provider_facts_rc" in + 0|2) : ;; + *) echo "acq: invalid provider facts in pinned usai-provider kit; refusing to continue" >&2; exit 1 ;; +esac +unset _acq_provider_facts_rc # --------------------------------------------------------------------------- # acq_backend_guard_version — cheap version floor for state-touching verbs diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 8f1e6e6f..af826b56 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -391,6 +391,26 @@ acq_provider_facts_load() { USAI_PROVIDER_FACTS_SOURCE="$file" } +acq_provider_facts_load_from_kit() { + local kitref="${1:-$USAI_KIT}" base_dir="${2:-}" kitdir facts rc + if [ -z "$base_dir" ]; then + base_dir="${ACQ_PROVIDER_FACTS_CACHE_DIR:-${ACQ_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/acq}/provider-facts}" + fi + mkdir -p "$base_dir" || return 1 + if ! command -v kit_translate_fetch >/dev/null 2>&1; then + return 2 + fi + kitdir=$(kit_translate_fetch "$kitref" "$base_dir/usai-provider") || return 1 + facts="$kitdir/provider-facts/usai.env" + acq_provider_facts_load "$facts" + rc=$? + case "$rc" in + 0) return 0 ;; + 2) return 2 ;; + *) echo "acq: invalid provider facts artifact: $facts" >&2; return 1 ;; + esac +} + # _acq_import_detect_var SERVICE -> prints the NAME of the FIRST of SERVICE's # candidate env vars that is set and non-empty; empty output (rc 1) if none is # set. Returns the variable NAME ONLY — never the value — so the secret value is diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index c67bcf4a..8ebf756a 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -104,6 +104,59 @@ FACTS assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" } +@test "provider-facts: kit loader consumes artifact from a fetched kit" { + load_acq + local kit="$STUBDIR/provider-kit" cache="$STUBDIR/provider-cache" + mkdir -p "$kit/provider-facts" + cat > "$kit/provider-facts/usai.env" <<'FACTS' +ACQ_PROVIDER_FACTS_SCHEMA=1 +ACQ_PROVIDER_ID=usai +ACQ_PROVIDER_HOST=api.kit.gov +ACQ_PROVIDER_BASE_URL=https://api.kit.gov/api/v1 +ACQ_PROVIDER_MODELS_URL=https://api.kit.gov/api/v1/models +ACQ_PROVIDER_KEY_ENV=KIT_API_KEY +ACQ_PROVIDER_KEY_MGMT_URL=https://kit.gov/keys +ACQ_PROVIDER_BIND_HOSTS=api.kit.gov +FACTS + + acq_provider_facts_load_from_kit "$kit" "$cache" + assert_equal "$USAI_PROVIDER_HOST" "api.kit.gov" + assert_equal "$USAI_PROVIDER_KEY_ENV" "KIT_API_KEY" + assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "$kit/provider-facts/usai.env" +} + +@test "provider-facts: kit loader keeps fallback when artifact is absent" { + load_acq + local kit="$STUBDIR/provider-kit-empty" cache="$STUBDIR/provider-cache-empty" + mkdir -p "$kit" + + run acq_provider_facts_load_from_kit "$kit" "$cache" + assert_failure 2 + assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" + assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" +} + +@test "provider-facts: kit loader fails closed for invalid artifact" { + load_acq + local kit="$STUBDIR/provider-kit-bad" cache="$STUBDIR/provider-cache-bad" + mkdir -p "$kit/provider-facts" + cat > "$kit/provider-facts/usai.env" <<'FACTS' +ACQ_PROVIDER_FACTS_SCHEMA=1 +ACQ_PROVIDER_ID=usai +ACQ_PROVIDER_HOST=api.bad.gov +ACQ_PROVIDER_BASE_URL=https://api.bad.gov/api/v1 +ACQ_PROVIDER_MODELS_URL=https://api.bad.gov/api/v1/models +ACQ_PROVIDER_KEY_ENV=BAD-KEY +ACQ_PROVIDER_KEY_MGMT_URL=https://bad.gov/keys +ACQ_PROVIDER_BIND_HOSTS=api.bad.gov +FACTS + + run acq_provider_facts_load_from_kit "$kit" "$cache" + assert_failure + assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" + assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" +} + @test "agent-kits: default kit list is support-only" { load_acq ACQ_EXTRA_KITS="" From 9d574e05090275d4003640fe2353e988b5e5a6ee Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 17 Sep 2026 21:51:21 +0000 Subject: [PATCH 18/37] feat(kits): source neutral rc snippets --- test/bats/72-msb-agent-user.bats | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/bats/72-msb-agent-user.bats b/test/bats/72-msb-agent-user.bats index fa81216b..93e1e986 100644 --- a/test/bats/72-msb-agent-user.bats +++ b/test/bats/72-msb-agent-user.bats @@ -368,6 +368,16 @@ _attach() { # PRE_SNIPPET NAME refute_regex "$log" 'export SHELL=\$target' } +@test "rc.d(msb): login-profile bridge sources kit-owned shell snippets lexically" { + _provision rcdbox shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/rcd-secrets"' + local log; log=$(cat "$CALLS") + assert_regex "$log" '\.rc.d/\*.sh' + assert_regex "$log" 'for _acq_rc in' + assert_regex "$log" 'SC1090' + assert_regex "$log" 'unset _acq_rc' + refute_regex "$log" 'direnv allow' +} + @test "msb #426: the heal only rewrites a .profile acq owns outright (appended lines survive)" { # Tools like rustup append to ~/.profile below acq's bridge. The rewrite # condition must be marker-present AND still just the bridge (line-count @@ -376,7 +386,7 @@ _attach() { # PRE_SNIPPET NAME _provision profguard shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/profguard-secrets"' local log; log=$(cat "$CALLS") assert_regex "$log" 'acq-login-profile "\$profile"' - assert_regex "$log" '\-le 3' + assert_regex "$log" '\-le 13' } @test "msb: repeated acq exec reads the workspace marker once per process (cached)" { From 64f7af71329b54e93cfb4b3baae6803bc15d4417 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 18 Sep 2026 01:30:18 +0000 Subject: [PATCH 19/37] test(kits): cover neutral rc snippet delivery --- test/bats/74-msb-restart-durability.bats | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/bats/74-msb-restart-durability.bats b/test/bats/74-msb-restart-durability.bats index 63718d73..1041f18f 100644 --- a/test/bats/74-msb-restart-durability.bats +++ b/test/bats/74-msb-restart-durability.bats @@ -107,6 +107,37 @@ _first_line() { printf '%s\n' "$2" | grep -n -- "$1" | head -n1 | cut -d: -f1; } refute_regex "$(cat "$CALLS")" 'msb start livebox' } +@test "rc.d(msb): neutral kit files are copied under the agent rc directory" { + printf 'rcdkitbox\n' > "$STUBDIR/.msb_sandbox_list" + printf 'rcdkitbox\n' > "$STUBDIR/.msb_running_list" + : > "$CALLS" + local rcdkit="$STUBDIR/rcdkit"; mkdir -p "$rcdkit/files/home/agent/.rc.d" + cat > "$rcdkit/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: rcdkit +displayName: RC Kit +description: rc.d file fixture +files: + - path: /home/agent/.rc.d/10-team.sh + mode: "0644" + source: files/home/agent/.rc.d/10-team.sh + - path: /home/agent/.rc.d/90-personal.sh + mode: "0644" + source: files/home/agent/.rc.d/90-personal.sh +SPEC + printf 'export TEAM_TOOL=1\n' > "$rcdkit/files/home/agent/.rc.d/10-team.sh" + printf 'alias ll="ls -la"\n' > "$rcdkit/files/home/agent/.rc.d/90-personal.sh" + ( . "${REPO_ROOT}/acq.backends/msb.sh" + ACQ_CLI_KITS=("$rcdkit") + _acq_msb_fetch_kit() { printf '%s\n' "$rcdkit"; } + acq_backend_ensure_kits_applied rcdkitbox >/dev/null 2>&1 ) + local log; log=$(cat "$CALLS") + assert_regex "$log" 'rcdkitbox:/home/agent/.rc.d/10-team.sh' + assert_regex "$log" 'rcdkitbox:/home/agent/.rc.d/90-personal.sh' + assert_regex "$log" 'chown -R -P agent /home/agent/.rc.d' +} + @test "clikit-heal: a CLI --kit ref (ACQ_CLI_KITS) is applied during heal" { printf 'clikitbox\n' > "$STUBDIR/.msb_sandbox_list" printf 'clikitbox\n' > "$STUBDIR/.msb_running_list" From 8f6a9f5c3edab26404cec7358f5b304208a0c42a Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 18 Sep 2026 02:43:41 +0000 Subject: [PATCH 20/37] feat(images): add non-enforcing contract doctor --- acq | 22 ++++-- acq.backends/common.sh | 96 +++++++++++++++++++++++++ scripts/test-acq-lib.sh | 48 +++++++++++++ test/bats/65-doctor-image-contract.bats | 93 ++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 test/bats/65-doctor-image-contract.bats diff --git a/acq b/acq index 5d73d22f..0058c5e4 100755 --- a/acq +++ b/acq @@ -107,7 +107,8 @@ Subcommands: sandbox workspace when available. usai-rotate-api-key Rotate the USAi API key. version Show acq version + backend info. - doctor Backend health check + write config. + doctor [NAME] Backend health check, or non-enforcing + image diagnostics for an existing sandbox. backend list List detected backends (sbx, msb). backend set NAME Persist the default backend. backend unset Clear the persisted default (auto-detect). @@ -439,10 +440,14 @@ U ;; doctor) cat >&2 <<'U' -acq doctor — backend health check + write config +acq doctor — backend health check, or sandbox image diagnostics Usage: acq doctor + acq doctor NAME + +With NAME, runs non-enforcing ADR-0030 base-image contract diagnostics inside an +existing sandbox. Warnings are actionable but do not block create/run flows. U ;; *) @@ -936,9 +941,18 @@ case "$subcommand" in ;; doctor) + shift acq_resolve_backend "${explicit_backend}" 2>/dev/null || true - acq_print_doctor - exit 0 + if [ -n "${1:-}" ]; then + if [ -n "${2:-}" ]; then + echo "acq: doctor: expected at most one sandbox name" >&2 + exit 1 + fi + acq_print_image_contract_doctor "$1" + else + acq_print_doctor + fi + exit $? ;; esac diff --git a/acq.backends/common.sh b/acq.backends/common.sh index af826b56..2b4a6901 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -3172,6 +3172,102 @@ ensure_valid_key() { # acq doctor output helpers # ============================================================================ +acq_image_contract_doctor_script() { + cat <<'SH' +ACQ_ADR0030_IMAGE_DIAGNOSTIC=1 +warns=0 +ok() { printf ' ok: %s\n' "$1"; } +warn() { + warns=$((warns + 1)) + printf ' warning: %s\n' "$1" + printf ' fix: %s\n' "$2" +} + +if [ "${HOME:-}" = "/home/agent" ]; then + ok "HOME is /home/agent" +else + warn "HOME is ${HOME:-unset}, not /home/agent" "run diagnostics as the agent user with HOME=/home/agent" +fi + +if [ "$(id -un 2>/dev/null || true)" = "agent" ]; then + ok "running as agent user" +else + warn "diagnostic is not running as the agent user" "run sandbox diagnostics as the unprivileged agent user" +fi + +if [ -d /nix ]; then + ok "/nix exists" +else + warn "/nix is missing" "use a devenv-capable image with its Nix store at /nix" +fi + +for tool in nix devenv direnv; do + if command -v "$tool" >/dev/null 2>&1; then + ok "$tool is on PATH" + else + warn "$tool is not on PATH" "install $tool in the base image or a create-time kit" + fi +done + +if [ -w /home/agent ]; then + ok "/home/agent is writable" +else + warn "/home/agent is not writable" "make /home/agent owned/writable by the agent user" +fi + +if command -v sudo >/dev/null 2>&1; then + # Best-effort privilege probe: sudo -n may update guest-local sudo/audit state. + if sudo -n true >/dev/null 2>&1; then + ok "passwordless sudo probe works" + else + warn "sudo exists but passwordless sudo probe failed" "grant NOPASSWD sudo for declared privileged kit steps" + fi +else + warn "sudo is not on PATH" "install sudo and allow passwordless sudo where privileged kit features are needed" +fi + +home=${HOME:-/home/agent} +if [ -d "$home/.rc.d" ]; then + ok "~/.rc.d exists" +else + warn "~/.rc.d is missing" "install the neutral shell hook directory at /home/agent/.rc.d" +fi + +hook=0 +for profile in "$home/.profile" "$home/.bashrc" "$home/.zshrc" /etc/profile /etc/bash.bashrc /etc/profile.d/acq*.sh; do + if [ -f "$profile" ] && grep -Eq '(^|[[:space:]])(source|\.)[[:space:]].*\.rc\.d' "$profile" 2>/dev/null; then + hook=1 + break + fi +done +if [ "$hook" -eq 1 ]; then + ok "shell startup references ~/.rc.d" +else + warn "no shell startup hook for ~/.rc.d found" "source ~/.rc.d snippets from shell startup in deterministic order" +fi + +printf ' summary: %s warning(s); diagnostics only, create/run are not blocked\n' "$warns" +exit 0 +SH +} + +acq_print_image_contract_doctor() { + local name="$1" + if ! command -v acq_backend_doctor_sandbox >/dev/null 2>&1; then + echo "acq: doctor: backend '${ACQ_RESOLVED_BACKEND:-unknown}' cannot inspect sandboxes." >&2 + return 1 + fi + if ! acq_backend_exists "$name"; then + echo "acq: doctor: no such sandbox '$name'." >&2 + return 1 + fi + + printf 'acq: ADR-0030 image contract diagnostics for %s (backend: %s)\n' \ + "$name" "${ACQ_RESOLVED_BACKEND:-unknown}" + echo " This check is non-enforcing: warnings do not change create/run behavior." + acq_backend_doctor_sandbox "$name" +} + acq_print_doctor() { local sbx_status msb_status diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index 48e62601..30b90d37 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -118,6 +118,31 @@ case "${1:-}" in exec) snippet=""; prev="" for a in "$@"; do [ "$prev" = "-c" ] && { snippet="$a"; break; }; prev="$a"; done + case "$snippet" in + *"ACQ_ADR0030_IMAGE_DIAGNOSTIC=1"*) + if [ "${STUB_IMAGE_CONTRACT:-present}" = "missing" ]; then + printf ' ok: HOME is /home/agent\n' + printf ' ok: running as agent user\n' + printf ' ok: /nix exists\n' + printf ' warning: devenv is not on PATH\n' + printf ' fix: install devenv in the base image or a create-time kit\n' + printf ' warning: no shell startup hook for ~/.rc.d found\n' + printf ' fix: source ~/.rc.d snippets from shell startup in deterministic order\n' + printf ' summary: 2 warning(s); diagnostics only, create/run are not blocked\n' + else + printf ' ok: HOME is /home/agent\n' + printf ' ok: running as agent user\n' + printf ' ok: /nix exists\n' + printf ' ok: nix is on PATH\n' + printf ' ok: devenv is on PATH\n' + printf ' ok: direnv is on PATH\n' + printf ' ok: /home/agent is writable\n' + printf ' ok: passwordless sudo probe works\n' printf ' ok: ~/.rc.d exists\n' + printf ' ok: shell startup references ~/.rc.d\n' + printf ' summary: 0 warning(s); diagnostics only, create/run are not blocked\n' + fi + exit 0 ;; + esac # `opencode --version` is the postinstall functionality probe (bare argv, # not an `sh -c`). By default model the BROKEN state (exit 1) so the # postinstall path runs; STUB_OPENCODE_OK=1 makes it "already runnable". @@ -260,6 +285,29 @@ case "$_msb_sub" in exit 1 ;; esac case "$snippet" in + *"ACQ_ADR0030_IMAGE_DIAGNOSTIC=1"*) + if [ "${STUB_IMAGE_CONTRACT:-present}" = "missing" ]; then + printf ' ok: HOME is /home/agent\n' + printf ' ok: running as agent user\n' + printf ' ok: /nix exists\n' + printf ' warning: devenv is not on PATH\n' + printf ' fix: install devenv in the base image or a create-time kit\n' + printf ' warning: no shell startup hook for ~/.rc.d found\n' + printf ' fix: source ~/.rc.d snippets from shell startup in deterministic order\n' + printf ' summary: 2 warning(s); diagnostics only, create/run are not blocked\n' + else + printf ' ok: HOME is /home/agent\n' + printf ' ok: running as agent user\n' + printf ' ok: /nix exists\n' + printf ' ok: nix is on PATH\n' + printf ' ok: devenv is on PATH\n' + printf ' ok: direnv is on PATH\n' + printf ' ok: /home/agent is writable\n' + printf ' ok: passwordless sudo probe works\n' printf ' ok: ~/.rc.d exists\n' + printf ' ok: shell startup references ~/.rc.d\n' + printf ' summary: 0 warning(s); diagnostics only, create/run are not blocked\n' + fi + exit 0 ;; *"echo ok"*) printf 'ok\n' ;; # #321: the registry reachability probe curls `https:///` and prints # the `|` shape _classify_key_status reads. Default diff --git a/test/bats/65-doctor-image-contract.bats b/test/bats/65-doctor-image-contract.bats new file mode 100644 index 00000000..57677be9 --- /dev/null +++ b/test/bats/65-doctor-image-contract.bats @@ -0,0 +1,93 @@ +#!/usr/bin/env bats +# +# 65-doctor-image-contract.bats — ADR-0030 image contract diagnostics. +# +# shellcheck shell=bats + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +@test "doctor(sbx): reports present ADR-0030 image capabilities" { + printf 'box\n' > "$STUBDIR/.sandbox_list" + + run env ACQ_BACKEND=sbx "$ACQ" doctor box + + assert_success + assert_output --partial 'ADR-0030 image contract diagnostics for box (backend: sbx)' + assert_output --partial 'ok: HOME is /home/agent' + assert_output --partial 'ok: running as agent user' + assert_output --partial 'ok: /nix exists' + assert_output --partial 'ok: devenv is on PATH' + assert_output --partial 'summary: 0 warning(s); diagnostics only, create/run are not blocked' + assert_regex "$(cat "$CALLS")" 'sbx exec box -- env HOME=/home/agent sh -c' +} + +@test "doctor(sbx): missing image capabilities warn but stay non-fatal" { + printf 'box\n' > "$STUBDIR/.sandbox_list" + + run env ACQ_BACKEND=sbx STUB_IMAGE_CONTRACT=missing "$ACQ" doctor box + assert_success + assert_output --partial 'warning: devenv is not on PATH' + assert_output --partial 'fix: install devenv in the base image or a create-time kit' + assert_output --partial 'warning: no shell startup hook for ~/.rc.d found' + assert_output --partial 'summary: 2 warning(s); diagnostics only, create/run are not blocked' +} + +@test "doctor(msb): runs sandbox diagnostics as agent without secrets" { + printf 'box\n' > "$STUBDIR/.msb_sandbox_list" + + run env ACQ_BACKEND=msb USAI_API_KEY=SUPERSECRET GITHUB_TOKEN=GHSECRET "$ACQ" doctor box + assert_success + assert_output --partial 'ADR-0030 image contract diagnostics for box (backend: msb)' + assert_output --partial 'ok: passwordless sudo probe works' + assert_output --partial 'summary: 0 warning(s); diagnostics only, create/run are not blocked' + refute_output --partial 'SUPERSECRET' + refute_output --partial 'GHSECRET' + assert_regex "$(cat "$CALLS")" 'msb exec -u agent -e HOME=/home/agent box -- sh -c' +} + +@test "doctor: missing sandbox fails before guest diagnostics" { + run env ACQ_BACKEND=sbx "$ACQ" doctor missing + + assert_failure + assert_output --partial "acq: doctor: no such sandbox 'missing'." + refute_output --partial 'ADR-0030 image contract diagnostics' + refute_regex "$(cat "$CALLS")" 'sbx exec' +} + +@test "doctor: extra sandbox operands are rejected" { + printf 'box\n' > "$STUBDIR/.sandbox_list" + + run env ACQ_BACKEND=sbx "$ACQ" doctor box extra + assert_failure + assert_output --partial 'acq: doctor: expected at most one sandbox name' + refute_regex "$(cat "$CALLS")" 'sbx exec' +} + +@test "doctor script: warns when HOME is not the agent home" { + local script="$STUBDIR/image-doctor.sh" + bash -c '. "'"$REPO_ROOT"'/acq.backends/common.sh"; acq_image_contract_doctor_script' > "$script" + + run env HOME=/root sh "$script" + assert_success + assert_output --partial 'warning: HOME is /root, not /home/agent' + refute_output --partial 'direnv allow' +} + +@test "doctor script: detects actual rc.d source statements, not comments" { + local home="$STUBDIR/agent-home" script="$STUBDIR/image-doctor.sh" + mkdir -p "$home/.rc.d" + printf '# mentions .rc.d only\n' > "$home/.profile" + bash -c '. "'"$REPO_ROOT"'/acq.backends/common.sh"; acq_image_contract_doctor_script' > "$script" + + run env HOME="$home" sh "$script" + assert_success + assert_output --partial 'warning: no shell startup hook for ~/.rc.d found' + + printf '. "$HOME/.rc.d/10-team.sh"\n' > "$home/.profile" + run env HOME="$home" sh "$script" + assert_success + assert_output --partial 'ok: shell startup references ~/.rc.d' +} From 9048dbf690e44e04e64447684f445c2788f739a8 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 18 Sep 2026 17:12:36 +0000 Subject: [PATCH 21/37] feat(agent-kits): validate built-in agent artifacts --- acq.backends/common.sh | 77 +++++++++++- test/bats/140-opencode-migration-gate.bats | 131 +++++++++++++++++++++ test/bats/50-kit-list-completeness.bats | 14 ++- test/bats/85-kit-provenance.bats | 16 ++- 4 files changed, 232 insertions(+), 6 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 2b4a6901..c17df91c 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -198,6 +198,7 @@ ACQ_SESSION_KIND="" # ACQ_EXTRA_KITS. One ref per element. ACQ_CLI_KITS=() ACQ_BUILTIN_KIT_COUNT=0 +ACQ_AGENT_KIT_READY_CACHE="" # Host-port overrides supplied on the command line via `--publish HOST:GUEST` # (repeatable) on run/create, one `HOST:GUEST` pair per element (ADR-0034). @@ -765,6 +766,78 @@ _acq_agent_builtin_kit_ref() { printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$kit_name" } +kit_spec_agent_field() { + local spec="$1" key="$2" + [ -f "$spec" ] || return 1 + awk -v key="$key" ' + function trim(s){ sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s); return s } + /^agent:[[:space:]]*($|#)/ { in_agent=1; next } + /^[^[:space:]#][^:]*:/ { in_agent=0 } + in_agent { + line=$0 + sub(/[[:space:]]*#.*/,"",line) + if (line ~ "^ " key ":[[:space:]]*") { + sub("^ " key ":[[:space:]]*","",line) + line=trim(line) + gsub(/^\"|\"$/, "", line) + gsub(/^'\''|'\''$/, "", line) + if (line != "") print line + exit + } + } + ' "$spec" +} + +acq_validate_agent_builtin_kit_dir() { + local agent="$1" kitdir="$2" spec schema kind kit_name agent_name entrypoint + local expected_kit expected_entrypoint + expected_kit=$(acq_agent_builtin_kit_name "$agent") || return 1 + expected_entrypoint=$(acq_agent_kit_entrypoint "$agent") || return 1 + spec="$kitdir/spec.yaml" + [ -f "$spec" ] || return 1 + + schema=$(kit_spec_field "$spec" schemaVersion) || schema="" + kind=$(kit_spec_field "$spec" kind) || kind="" + kit_name=$(kit_spec_field "$spec" name) || kit_name="" + agent_name=$(kit_spec_agent_field "$spec" name) || agent_name="" + entrypoint=$(kit_spec_agent_field "$spec" entrypoint) || entrypoint="" + + [ "$schema" = "hybrid/v1" ] || return 1 + [ "$kind" = "mixin" ] || return 1 + [ "$kit_name" = "$expected_kit" ] || return 1 + [ "$agent_name" = "$agent" ] || return 1 + [ "$entrypoint" = "$expected_entrypoint" ] || return 1 +} + +acq_validate_agent_builtin_kit_ref() { + local agent="$1" kitref="$2" base_dir="${3:-}" kitdir + if [ -z "$base_dir" ]; then + base_dir="${ACQ_STATE_DIR:-${TMPDIR:-/tmp}/acq}/agent-kit-validation/$agent" + fi + kitdir=$(kit_translate_fetch "$kitref" "$base_dir") || return 1 + acq_validate_agent_builtin_kit_dir "$agent" "$kitdir" +} + +acq_agent_builtin_kit_ready() { + local agent="$1" kit cache_key cache_value + acq_agent_builtin_kit_enabled "$agent" || return 1 + kit=$(_acq_agent_builtin_kit_ref "$agent") || return 1 + cache_key="${agent}:$(printf '%s' "$kit" | cksum | cut -d' ' -f1)" + cache_value=$(printf '%s\n' "$ACQ_AGENT_KIT_READY_CACHE" | awk -F'\t' -v key="$cache_key" '$1 == key { print $2; exit }') + case "$cache_value" in + yes) return 0 ;; + no) return 1 ;; + esac + if acq_validate_agent_builtin_kit_ref "$agent" "$kit"; then + ACQ_AGENT_KIT_READY_CACHE="${ACQ_AGENT_KIT_READY_CACHE}${cache_key} yes +" + return 0 + fi + ACQ_AGENT_KIT_READY_CACHE="${ACQ_AGENT_KIT_READY_CACHE}${cache_key} no +" + return 1 +} + _acq_selected_builtin_kit_refs() { local agent="${1:-}" name kit for name in $(_acq_builtin_support_kit_names); do @@ -772,7 +845,7 @@ _acq_selected_builtin_kit_refs() { printf '%s\n' "$kit" done if [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] && [ -n "$agent" ] \ - && acq_is_known_agent "$agent" && acq_agent_builtin_kit_enabled "$agent"; then + && acq_is_known_agent "$agent" && acq_agent_builtin_kit_ready "$agent"; then kit=$(_acq_agent_builtin_kit_ref "$agent") || return 1 printf '%s\n' "$kit" fi @@ -785,7 +858,7 @@ acq_selected_agent_kit_summary() { entrypoint=$(acq_agent_kit_entrypoint "$agent") || entrypoint="" install_owner=$(acq_agent_kit_install_owner "$agent") || install_owner="" start_owner=$(acq_agent_kit_start_owner "$agent") || start_owner="" - acq_agent_builtin_kit_enabled "$agent" && apply_state="enabled" + acq_agent_builtin_kit_ready "$agent" && apply_state="enabled" printf 'agent=%s kit=%s entrypoint=%s install_owner=%s start_owner=%s apply=%s\n' \ "$agent" "$kit_name" "${entrypoint:-none}" "${install_owner:-none}" \ "${start_owner:-none}" "$apply_state" diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index d5bbac6e..32fce21c 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -64,6 +64,137 @@ _create_line() { printf '%s\n' "$(cat "$CALLS")" | grep "^$1 create"; } assert_equal "${KITS[7]}" "/extra/cli-two" } +_mk_opencode_agent_kit() { # PATH [schema] [kit-name] [agent-name] [entrypoint] + mkdir -p "$1" + cat >"$1/spec.yaml" </dev/null)" 'apply=deferred' +} + +@test "agent kit gate: wrong opencode kit name fails closed" { + local kit="$STUBDIR/wrong-name-kit" + _mk_opencode_agent_kit "$kit" "hybrid/v1" "not-opencode" + acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + _acq_agent_builtin_kit_ref() { printf '%s\n' "$kit"; } + + _build_kit_list opencode + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "${#KITS[@]}" "4" +} + +@test "agent kit gate: wrong opencode kit schema fails closed" { + local kit="$STUBDIR/wrong-schema-kit" + _mk_opencode_agent_kit "$kit" "sbx/v1" "opencode" + acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + _acq_agent_builtin_kit_ref() { printf '%s\n' "$kit"; } + + _build_kit_list opencode + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "${#KITS[@]}" "4" +} + +@test "agent kit gate: wrong agent metadata name fails closed" { + local kit="$STUBDIR/wrong-agent-name-kit" + _mk_opencode_agent_kit "$kit" "hybrid/v1" "opencode" "not-opencode" "opencode" + acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + _acq_agent_builtin_kit_ref() { printf '%s\n' "$kit"; } + + _build_kit_list opencode + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "${#KITS[@]}" "4" +} + +@test "agent kit gate: wrong agent metadata entrypoint fails closed" { + local kit="$STUBDIR/wrong-agent-entrypoint-kit" + _mk_opencode_agent_kit "$kit" "hybrid/v1" "opencode" "opencode" "not-opencode" + acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + _acq_agent_builtin_kit_ref() { printf '%s\n' "$kit"; } + + _build_kit_list opencode + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "${#KITS[@]}" "4" +} + +@test "agent kit gate: nested agent metadata does not satisfy direct fields" { + local kit="$STUBDIR/nested-agent-kit" + mkdir -p "$kit" + cat >"$kit/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: opencode +agent: + metadata: + name: opencode + entrypoint: opencode +SPEC + acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + _acq_agent_builtin_kit_ref() { printf '%s\n' "$kit"; } + + _build_kit_list opencode + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "${#KITS[@]}" "4" +} + +@test "agent kit gate: explicit --kit suppresses inferred opencode kit" { + local cli="$STUBDIR/cli-kit" + mkdir -p "$cli" + acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + _acq_agent_builtin_kit_ref() { printf '%s\n' "$STUBDIR/missing-opencode-kit"; } + # shellcheck disable=SC2034 # read by sourced _build_kit_list + ACQ_CLI_KITS=("$cli") + + _build_kit_list opencode + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "${#KITS[@]}" "5" + assert_equal "${KITS[4]}" "$cli" +} + +@test "agent kit gate: shell remains no-agent-kit" { + acq_agent_builtin_kit_enabled() { return 0; } + + _build_kit_list shell + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "${#KITS[@]}" "4" +} + @test "migration gate(msb): run opencode --clone preserves clone markers, image, kit order, and boot volumes" { local proj="$STUBDIR/migproj" extra="$STUBDIR/extra-kit" cli="$STUBDIR/cli-kit" _mk_repo "$proj" diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index 8ebf756a..2a6242ae 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -192,13 +192,23 @@ FACTS @test "agent-kits: enabled built-in agent kit is appended after support bundle" { load_acq + ACQ_TEST_AGENT_KIT="$STUBDIR/enabled-opencode-kit" + mkdir -p "$ACQ_TEST_AGENT_KIT" + cat >"$ACQ_TEST_AGENT_KIT/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: opencode +agent: + name: opencode + entrypoint: opencode +SPEC ACQ_EXTRA_KITS="" ACQ_CLI_KITS=() acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } - _acq_builtin_kit_ref() { printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$1"; } + _acq_agent_builtin_kit_ref() { printf '%s\n' "$ACQ_TEST_AGENT_KIT"; } _build_kit_list opencode - assert_regex "${KITS[4]}" 'acq-kits/opencode$' + assert_equal "${KITS[4]}" "$ACQ_TEST_AGENT_KIT" assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" } diff --git a/test/bats/85-kit-provenance.bats b/test/bats/85-kit-provenance.bats index 2e809072..e42d8c2c 100644 --- a/test/bats/85-kit-provenance.bats +++ b/test/bats/85-kit-provenance.bats @@ -45,12 +45,24 @@ _seed_stale_provenance() { # BACKEND NAME } @test "provenance: recorded agent can rebuild an enabled agent kit list" { + ACQ_TEST_AGENT_KIT="$STUBDIR/provenance-opencode-kit" + mkdir -p "$ACQ_TEST_AGENT_KIT" + cat >"$ACQ_TEST_AGENT_KIT/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: opencode +displayName: OpenCode Agent Kit +description: test fixture only +agent: + name: opencode + entrypoint: opencode +SPEC acq_provenance_write msb agenthealbox opencode acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } - _acq_builtin_kit_ref() { printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$1"; } + _acq_agent_builtin_kit_ref() { printf '%s\n' "$ACQ_TEST_AGENT_KIT"; } _build_kit_list "$(acq_provenance_field msb agenthealbox agent)" - assert_regex "${KITS[4]}" 'acq-kits/opencode$' + assert_equal "${KITS[4]}" "$ACQ_TEST_AGENT_KIT" assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" } From a9e53cd9618e05adece6ea7bbcabab7ecdf84f73 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 21 Sep 2026 18:40:42 +0000 Subject: [PATCH 22/37] feat(oci): add opt-in OCI-engine capability kit selection Introduce ACQ_ENABLE_OCI_KIT (off by default) to select an oci-engine capability kit into the built-in bundle, per ADR-0030's direction to move rootless podman provisioning out of backend-specific adapter code into an explicit, backend-neutral capability kit. This increment is deliberately additive and conservative: - Selection is gated on a readiness probe (acq_oci_engine_kit_ready) that fetches and validates the kit at the pinned patterns ref. The patterns-side kit body is not published yet, so opting in currently falls back with a single stderr notice and adds no ref, leaving default behavior and the msb adapter's active podman provisioning (_acq_msb_ensure_oci) unchanged. - The kit is NOT added to ACQ_KIT_NAMES, preserving the default acq kit list set and the provenance-bundle count; selection is gated purely in _acq_selected_builtin_kit_refs. - The readiness result is memoized per process (ACQ_OCI_KIT_READY_CACHE) so at most one network fetch runs per invocation, and the fallback notice is emitted once per process from _build_kit_list (process-global guard), mirroring the agent-kit readiness cache and the sbx recreate-notice guard. The msb adapter's podman provisioning is intentionally left in place; removing it, wiring the kit into the sbx forced-heal refresh, and sequencing the two OCI paths are deferred until the patterns kit publishes and the path is live-verified. Co-authored-by: OpenCode [claude_4_8_opus] --- acq.backends/common.sh | 119 ++++++++++++++++++ docs/CONCEPTS.md | 60 +++++++++ test/bats/66-oci-engine-kit.bats | 207 +++++++++++++++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 test/bats/66-oci-engine-kit.bats diff --git a/acq.backends/common.sh b/acq.backends/common.sh index c17df91c..7184ce73 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -82,6 +82,34 @@ ACQ_BUILTIN_BUNDLE="acq-builtin" # shellcheck disable=SC2034 ACQ_BUILTIN_BUNDLE_REPO="GSA-TTS/agentic-coding-patterns" +# Optional OCI-engine capability kit (ADR-0030). OFF by default. When opted in +# AND the kit is actually published/valid at the pinned patterns ref, acq appends +# an `oci-engine` capability kit to the built-in support bundle so agents get an +# OCI-run capability (podman) from a shared kit rather than from the msb adapter's +# built-in provisioning. Normalized exactly like ACQ_MSB_ENSURE_OCI below: +# unset/empty/0/false/no/off => off (case-insensitive); anything else => on. +# +# Intentionally additive and gated (ADR-0020/ADR-0030): the patterns-side kit +# body is not published yet, so even when opted in acq falls back silently (with +# a single stderr notice) unless a readiness check confirms the kit is present at +# the pinned ref. This keeps default behavior — and the msb adapter's active +# podman provisioning (`_acq_msb_ensure_oci`) — unchanged until the kit path is +# live-verified. NOT added to ACQ_KIT_NAMES (that is the default `acq kit list` +# registry and the provenance-bundle count); selection is gated purely in +# _acq_selected_builtin_kit_refs. +OCI_ENGINE_KIT_NAME="oci-engine" +ACQ_ENABLE_OCI_KIT="${ACQ_ENABLE_OCI_KIT-}" +case "$(printf '%s' "$ACQ_ENABLE_OCI_KIT" | tr '[:upper:]' '[:lower:]')" in + ""|0|false|no|off) ACQ_ENABLE_OCI_KIT="" ;; + *) ACQ_ENABLE_OCI_KIT="1" ;; +esac +# One-shot guard so the "requested but not available" fallback notice prints at +# most once per process, not once per _acq_selected_builtin_kit_refs call +# (that runs 2-3x/invocation via _build_kit_list). Mirrors sbx.sh's +# _ACQ_SBX_RECREATE_NOTICE_SHOWN. Guarded by this flag (not the readiness +# cache) so the notice still prints exactly once even when readiness is memoized. +_ACQ_OCI_KIT_NOTICE_SHOWN=0 + # Additional user-supplied kits. Set ACQ_EXTRA_KITS to a whitespace-separated # list of kit references. Set ACQ_EXTRA_KIT_SOURCES for their allowlist prefixes. ACQ_EXTRA_KITS="${ACQ_EXTRA_KITS:-}" @@ -199,6 +227,11 @@ ACQ_SESSION_KIND="" ACQ_CLI_KITS=() ACQ_BUILTIN_KIT_COUNT=0 ACQ_AGENT_KIT_READY_CACHE="" +# Per-process readiness memo for the optional OCI-engine kit (ADR-0030), +# mirroring ACQ_AGENT_KIT_READY_CACHE: acq_oci_engine_kit_ready runs multiple +# times per invocation (via _build_kit_list), so its network fetch is done once +# and the yes/no result cached here. +ACQ_OCI_KIT_READY_CACHE="" # Host-port overrides supplied on the command line via `--publish HOST:GUEST` # (repeatable) on run/create, one `HOST:GUEST` pair per element (ADR-0034). @@ -766,6 +799,56 @@ _acq_agent_builtin_kit_ref() { printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$kit_name" } +# Dedicated ref helper for the optional OCI-engine capability kit. Deliberately +# NOT routed through _acq_builtin_kit_ref (which requires membership in +# ACQ_KIT_NAMES): keeping oci-engine out of ACQ_KIT_NAMES preserves the default +# `acq kit list` set and the provenance-bundle count. Produces the same pinned +# patterns ref shape, under github.com/GSA-TTS/ so it already satisfies +# KIT_SOURCE_PREFIX without widening the allowlist. +_acq_oci_engine_kit_ref() { + printf '%s#ref=%s&dir=%s/%s\n' \ + "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$OCI_ENGINE_KIT_NAME" +} + +# Readiness check for the optional OCI-engine kit, mirroring the agent-kit +# readiness pattern (acq_agent_builtin_kit_ready): fetch the kit at the pinned +# ref and validate it is a well-formed mixin named oci-engine. Returns 0 only +# when the kit is actually present/valid. Fetch failure (the current reality — +# the patterns-side kit is unpublished) or a spec mismatch returns non-zero, so +# selection falls back silently. The yes/no result is memoized in +# ACQ_OCI_KIT_READY_CACHE so at most one network fetch runs per process even +# though _build_kit_list calls this repeatedly. Offline tests stub this to +# force ready/not-ready. +acq_oci_engine_kit_ready() { + local kitref base_dir kitdir schema kind kit_name cache_key cache_value + kitref=$(_acq_oci_engine_kit_ref) || return 1 + cache_key=$(printf '%s' "$kitref" | cksum | cut -d' ' -f1) + cache_value=$(printf '%s\n' "$ACQ_OCI_KIT_READY_CACHE" | awk -F'\t' -v key="$cache_key" '$1 == key { print $2; exit }') + case "$cache_value" in + yes) return 0 ;; + no) return 1 ;; + esac + base_dir="${ACQ_STATE_DIR:-${TMPDIR:-/tmp}/acq}/oci-engine-kit-validation" + # Best-effort presence probe: suppress kit-translate's multi-line fetch-failure + # block on stderr (the single acq-level notice from _acq_selected_builtin_kit_refs + # is the only user-facing message). stderr is NOT suppressed anywhere else. + if kitdir=$(kit_translate_fetch "$kitref" "$base_dir" 2>/dev/null) \ + && [ -f "$kitdir/spec.yaml" ]; then + schema=$(kit_spec_field "$kitdir/spec.yaml" schemaVersion) || schema="" + kind=$(kit_spec_field "$kitdir/spec.yaml" kind) || kind="" + kit_name=$(kit_spec_field "$kitdir/spec.yaml" name) || kit_name="" + if [ "$schema" = "hybrid/v1" ] && [ "$kind" = "mixin" ] \ + && [ "$kit_name" = "$OCI_ENGINE_KIT_NAME" ]; then + ACQ_OCI_KIT_READY_CACHE="${ACQ_OCI_KIT_READY_CACHE}${cache_key} yes +" + return 0 + fi + fi + ACQ_OCI_KIT_READY_CACHE="${ACQ_OCI_KIT_READY_CACHE}${cache_key} no +" + return 1 +} + kit_spec_agent_field() { local spec="$1" key="$2" [ -f "$spec" ] || return 1 @@ -844,6 +927,29 @@ _acq_selected_builtin_kit_refs() { kit=$(_acq_builtin_kit_ref "$name") || return 1 printf '%s\n' "$kit" done + # Optional OCI-engine capability kit (ADR-0030): appended AFTER the network/CA + # and provider/playbook support kits and BEFORE any agent kit / extras, only + # when the user opted in (ACQ_ENABLE_OCI_KIT) AND the kit is actually present + # at the pinned patterns ref. If opted in but not yet published/valid (the + # current reality — see ADR-0020/ADR-0030), emit one clear notice and skip the + # ref so default behavior and the msb adapter's own podman provisioning are + # unaffected. zscaler-ca-certificate stays first regardless. + # + # NOTE: The sbx forced-heal refresh path (sbx.sh) rebuilds this list on a + # fixed loop and does not yet re-run OCI selection; wiring the sbx forced-heal + # to this selection is deferred until the patterns kit publishes (ADR-0020 / + # ADR-0030). This block affects create-time kit selection only for now. + if [ -n "$ACQ_ENABLE_OCI_KIT" ]; then + if acq_oci_engine_kit_ready; then + kit=$(_acq_oci_engine_kit_ref) || return 1 + printf '%s\n' "$kit" + fi + # NOTE: the "not available" fallback notice is emitted by _build_kit_list + # (the parent shell), NOT here. This function runs inside a process + # substitution (`< <(...)`), so a once-guard set here would live in a + # subshell and reset on every call — defeating the guard. Emitting from the + # parent keeps _ACQ_OCI_KIT_NOTICE_SHOWN process-global. + fi if [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] && [ -n "$agent" ] \ && acq_is_known_agent "$agent" && acq_agent_builtin_kit_ready "$agent"; then kit=$(_acq_agent_builtin_kit_ref "$agent") || return 1 @@ -881,6 +987,19 @@ acq_print_selected_agent_kit() { _build_kit_list() { local agent="${1:-}" kit KITS=() + # Prime the OCI readiness cache in the PARENT shell first (at most one network + # fetch per process, memoized in ACQ_OCI_KIT_READY_CACHE). The subsequent + # process substitution forks a subshell that inherits this populated cache, so + # _acq_selected_builtin_kit_refs short-circuits without re-fetching. Also emit + # the "not available" fallback notice here — guarded by the process-global + # _ACQ_OCI_KIT_NOTICE_SHOWN (independent of the cache) so it prints exactly + # once across the 2-3 _build_kit_list calls per invocation. Doing this in the + # subshell would reset both the guard and the cache each call. + if [ -n "$ACQ_ENABLE_OCI_KIT" ] && ! acq_oci_engine_kit_ready \ + && [ "${_ACQ_OCI_KIT_NOTICE_SHOWN:-0}" != "1" ]; then + _ACQ_OCI_KIT_NOTICE_SHOWN=1 + printf 'acq: OCI engine kit requested but not available at the pinned patterns ref; falling back to adapter-provided OCI (if any).\n' >&2 + fi while IFS= read -r kit; do [ -n "$kit" ] && KITS+=("$kit") done < <(_acq_selected_builtin_kit_refs "$agent") diff --git a/docs/CONCEPTS.md b/docs/CONCEPTS.md index 5d795b22..1c282af4 100644 --- a/docs/CONCEPTS.md +++ b/docs/CONCEPTS.md @@ -307,6 +307,66 @@ scheme-less prefix so `acq` allowlists it: export ACQ_EXTRA_KIT_SOURCES="github.com/acme/" ``` +### Advanced: the `~/.rc.d` shell hook + +`acq` gives kits a neutral shell hook: any file a kit drops at +`~/.rc.d/*.sh` is sourced at login. Both backends apply it identically +(ADR-0030) — the delivery is a normal kit `files[]` drop, and `acq` writes the +matching login-profile bridge that sources the snippets (`sbx` writes it at +create time; `msb` writes it as part of its login-shell setup). + +What it is and how it behaves: + +- **Kit-owned, not user-editable.** The snippets come from a kit's `files[]` + tree, and the sourcing bridge is written by `acq`. Treat `~/.rc.d/*.sh` as + build output, not a place to hand-edit inside a sandbox. +- **POSIX-sh, bash bridge.** The built-in `~/.profile` bridge sources the + snippets for bash login shells. Zsh-capable base images must wire the same + directory from native zsh startup files. The files are POSIX-sh. +- **Deterministic lexical order.** Snippets are sourced in byte (C-collation) + order regardless of the guest's locale, so a `10-`, `20-`, `90-` numeric + prefix convention gives a stable, predictable order. A bare shell glob would + sort in the guest's locale collation; the bridge forces `LC_ALL=C` for the + listing to avoid that. + +Guardrails: + +- **Never put secrets in a snippet.** `~/.rc.d/*.sh` is kit content that lands + on disk and is sourced into every login shell; secrets belong in the backend + secret store (injected at runtime), never in a shipped file. +- **Do not duplicate what devenv provides** inside a devenv shell. If a tool's + environment is already established by the project's `devenv`/`.envrc`, do not + re-export it here — the hook is for shell integration that must exist + *outside* a devenv shell. +- **Kit-owned.** Ship shell behavior as a kit, not as ad-hoc edits. + +Use-cases (ADR-0030): + +- the hook itself, delivered by a neutral kit; +- agent shell integration that must exist outside a devenv shell; +- team tool environment variables and shell completions; +- personal aliases and functions (via a personal kit through `ACQ_EXTRA_KITS`); +- a direnv/devenv activation hook. + +**fish and nushell.** The `~/.rc.d/*.sh` snippets are POSIX-sh and are **not** +sourced by fish or nushell, because their shell languages are incompatible with +POSIX-sh syntax — sourcing a `.sh` file into fish/nushell would error, not work. +`acq` therefore does not wire fish/nushell to `~/.rc.d`. This is a documented +constraint, chosen over speculative wiring: fish has its own native autoload +directory (`~/.config/fish/conf.d/*.fish`), and nushell loads from its +configured config/autoload path (`$nu.config-path`). The intended future path is +for a kit to deliver shell-native snippets there when fish/nushell support is +actually needed. Until a concrete need exists, `acq` neither wires those +directories nor installs fish or nushell. + +**Migration path.** If a team kit currently establishes shell behavior by other +means (for example, appending to `~/.bashrc` from a kit `startup` command), move +that behavior into a POSIX-sh snippet under `files/home/agent/.rc.d/NN-name.sh` +in a personal or team kit, applied via `ACQ_EXTRA_KITS`. Pick a numeric prefix +to order it against other snippets (lower runs first). Drop the old +`~/.bashrc`-append step once the snippet is in place; the built-in bridge +sources the snippet for bash login shells. + --- ## Optional Integrations diff --git a/test/bats/66-oci-engine-kit.bats b/test/bats/66-oci-engine-kit.bats new file mode 100644 index 00000000..72ada08c --- /dev/null +++ b/test/bats/66-oci-engine-kit.bats @@ -0,0 +1,207 @@ +#!/usr/bin/env bats +# +# 66-oci-engine-kit.bats — optional OCI-engine capability kit selection (ADR-0030) +# +# The OCI-engine kit is off by default and opt-in via ACQ_ENABLE_OCI_KIT. Because +# the patterns-side kit body is unpublished (see ADR-0020/ADR-0030), selection is +# additionally gated on a readiness check (acq_oci_engine_kit_ready): opted in but +# not-ready must NOT add the ref and must emit one clear notice. These tests are +# fully offline — the readiness check is stubbed per case, so no network fetch or +# real kit is required. In-process (no `run` for the array assertions) mirrors +# 50-kit-list-completeness.bats; the notice-emitting case captures stderr. +# +# shellcheck shell=bats + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +@test "oci-kit: default (no opt-in) does not select the oci-engine kit" { + load_acq + ACQ_EXTRA_KITS="" + ACQ_CLI_KITS=() + ACQ_ENABLE_OCI_KIT="" + _build_kit_list + + local joined; joined=$(printf '%s\n' "${KITS[@]}") + refute_regex "$joined" 'acq-kits/oci-engine' + # Default identity is unchanged: four support kits, zscaler first. + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "${#ACQ_KIT_NAMES[@]}" "4" + assert_regex "${KITS[0]}" 'acq-kits/zscaler-ca-certificate' +} + +@test "oci-kit: default does not pollute the acq kit list registry" { + load_acq + refute_regex "$(printf '%s\n' "${ACQ_KIT_NAMES[@]}")" 'oci-engine' +} + +@test "oci-kit: opted-in but NOT ready adds no ref and emits one clear notice" { + load_acq + ACQ_EXTRA_KITS="" + ACQ_CLI_KITS=() + ACQ_ENABLE_OCI_KIT=1 + # Force NOT-ready: the real current state (kit unpublished at the pinned ref). + acq_oci_engine_kit_ready() { return 1; } + + # The ref-selection helper never emits the ref when not-ready... + run _acq_selected_builtin_kit_refs + assert_success + refute_regex "$output" 'acq-kits/oci-engine' + + # ...and _build_kit_list emits the single fallback notice (Fix A moved the + # notice to the parent shell so the once-guard survives the subshell). + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" + ACQ_EXTRA_KITS="" + ACQ_CLI_KITS=() + ACQ_ENABLE_OCI_KIT=1 + acq_oci_engine_kit_ready() { return 1; } + _build_kit_list >/dev/null + printf "%s\n" "${KITS[@]}" + ' 2>&1 + assert_success + assert_output --partial 'OCI engine kit requested but not available at the pinned patterns ref' + refute_regex "$output" 'acq-kits/oci-engine' + # The notice prints exactly once. + local n; n=$(printf '%s\n' "$output" | grep -c 'OCI engine kit requested but not available') + assert_equal "$n" "1" + + # And the kit list itself is unchanged (support-only, zscaler first). + _build_kit_list + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_regex "${KITS[0]}" 'acq-kits/zscaler-ca-certificate' + refute_regex "$(printf '%s\n' "${KITS[@]}")" 'acq-kits/oci-engine' +} + +@test "oci-kit: notice prints EXACTLY ONCE across a 2x _build_kit_list lifecycle" { + # The dispatcher calls _build_kit_list multiple times per invocation + # (acq_resolve_backend, the run/create arm, the sbx forced-heal). Guarding on + # a single call cannot catch that spam; drive two calls in ONE process and + # assert the notice appears exactly once across both. This proves Fix A's + # process-global once-guard, not just the per-call de-dup. + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" + ACQ_EXTRA_KITS="" + ACQ_CLI_KITS=() + ACQ_ENABLE_OCI_KIT=1 + # Force NOT-ready in-process; memoization must not affect the once-guard. + acq_oci_engine_kit_ready() { return 1; } + _build_kit_list >/dev/null + _build_kit_list >/dev/null + ' 2>&1 + assert_success + local n + n=$(printf '%s\n' "$output" | grep -c 'OCI engine kit requested but not available') + assert_equal "$n" "1" +} + +@test "oci-kit: opted-in AND ready appends the oci-engine ref after support kits" { + load_acq + ACQ_EXTRA_KITS="" + ACQ_CLI_KITS=() + ACQ_ENABLE_OCI_KIT=1 + # Stub readiness ready, mirroring 90-sbx-startup-kit.bats' agent-kit stubbing. + acq_oci_engine_kit_ready() { return 0; } + _build_kit_list + + # Appended after the four support kits, before extras/CLI kits. + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" + assert_regex "${KITS[0]}" 'acq-kits/zscaler-ca-certificate' + assert_regex "${KITS[4]}" 'acq-kits/oci-engine' +} + +@test "oci-kit: ready + extras keeps oci before extras in the list" { + load_acq + # shellcheck disable=SC2034 # read by sourced _build_kit_list + ACQ_EXTRA_KITS="git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=deadbeef&dir=some-extra-kit" + # shellcheck disable=SC2034 # read by sourced _build_kit_list + ACQ_CLI_KITS=() + # shellcheck disable=SC2034 # read by sourced _acq_selected_builtin_kit_refs + ACQ_ENABLE_OCI_KIT=1 + acq_oci_engine_kit_ready() { return 0; } + _build_kit_list + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" + assert_regex "${KITS[4]}" 'acq-kits/oci-engine' + assert_regex "$(printf '%s\n' "${KITS[@]}")" 'some-extra-kit' +} + +@test "oci-kit: the emitted ref is acq-kits/oci-engine under the GSA-TTS allowlist" { + load_acq + local ref; ref=$(_acq_oci_engine_kit_ref) + assert_regex "$ref" 'agentic-coding-patterns' + assert_regex "$ref" 'acq-kits/oci-engine$' + # Under the built-in source allowlist prefix (github.com/GSA-TTS/), so no + # allowlist widening is needed. KIT_SOURCE_PREFIX is scheme-less; the ref is a + # git+https URL, so match the host/org path segment. + assert_regex "$ref" "github.com/GSA-TTS/" + assert_equal "$KIT_SOURCE_PREFIX" "github.com/GSA-TTS/" +} + +@test "oci-kit: readiness TRUE for a well-formed oci-engine mixin spec (offline)" { + load_acq + # Exercise the REAL acq_oci_engine_kit_ready validation branch offline: stub + # only the network fetch to hand back a temp dir we populate with a spec.yaml. + local kitdir; kitdir=$(mktemp -d) + cat >"$kitdir/spec.yaml" <<'SPEC' +schemaVersion: hybrid/v1 +kind: mixin +name: oci-engine +SPEC + eval 'kit_translate_fetch() { printf "%s\n" "'"$kitdir"'"; }' + run acq_oci_engine_kit_ready + rm -rf "$kitdir" + assert_success +} + +@test "oci-kit: readiness FALSE for a wrong-name spec (offline)" { + load_acq + local kitdir; kitdir=$(mktemp -d) + cat >"$kitdir/spec.yaml" <<'SPEC' +schemaVersion: hybrid/v1 +kind: mixin +name: not-oci-engine +SPEC + eval 'kit_translate_fetch() { printf "%s\n" "'"$kitdir"'"; }' + run acq_oci_engine_kit_ready + rm -rf "$kitdir" + assert_failure +} + +@test "oci-kit: readiness FALSE for a wrong-kind spec (offline)" { + load_acq + local kitdir; kitdir=$(mktemp -d) + cat >"$kitdir/spec.yaml" <<'SPEC' +schemaVersion: hybrid/v1 +kind: bundle +name: oci-engine +SPEC + eval 'kit_translate_fetch() { printf "%s\n" "'"$kitdir"'"; }' + run acq_oci_engine_kit_ready + rm -rf "$kitdir" + assert_failure +} + +@test "oci-kit: env normalization treats off-ish values as off" { + local v + for v in "" 0 false no off FALSE No OFF; do + run env ACQ_ENABLE_OCI_KIT="$v" bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + printf "%s" "${ACQ_ENABLE_OCI_KIT:-EMPTY}" + ' + assert_output "EMPTY" + done +} + +@test "oci-kit: env normalization treats any other value as on" { + local v + for v in 1 true yes on YES enabled anything; do + run env ACQ_ENABLE_OCI_KIT="$v" bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + printf "%s" "$ACQ_ENABLE_OCI_KIT" + ' + assert_output "1" + done +} From 9c85b69f712b85c1d744d4998c37666000bdbede Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 21:18:44 -0700 Subject: [PATCH 23/37] docs(kits): document neutral rc snippets (#511) Document the kit-owned /home/agent/.rc.d shell snippet contract, shell-specific guardrails, and team-kit migration path. Refs: GSA-TTS/agentic-coding-quickstart#482 --- docs/BACKEND_GUIDE.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 8364326b..861d5cac 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -1066,6 +1066,24 @@ The neutral vocabulary is: `caps.network.allow`, `files[]`, `commands[]`, `environment`, `publishedPorts`, `volumes`, `agentContext`, `backend_shortcuts`, and `backend_extras`. +**Shell rc snippets.** A kit that needs shell startup behavior should deliver +readable POSIX snippets as files under `/home/agent/.rc.d/*.sh`, for example +`/home/agent/.rc.d/10-team.sh` or `/home/agent/.rc.d/90-personal.sh`. +`acq`'s built-in shell bridge sources these snippets for bash login shells in +lexical order; zsh-capable base images must wire the same directory from native +zsh startup files. The snippets are kit-owned state, not a user-editable dotfile +layer. + +Use this path for agent shell integration outside a devenv shell, team tool +environment/completions, personal aliases/functions, and an optional +direnv/devenv hook. Do not put secrets in snippets, do not print secret-bearing +environment, and do not duplicate environment already supplied by `devenv shell`. +Fish and nushell do not consume `.sh` files; kits targeting those shells must use +the shells' native configuration directories with the same constraints: kit-owned +files, deterministic names, no secrets, and no duplicate devenv setup. Team kits +that previously installed their own shell-startup loop should migrate their +payloads to `/home/agent/.rc.d/` and stop owning the hook itself. + **`environment` (guest env vars).** A flat map of `NAME → value` for **non-secret** guest environment variables (e.g. `OPENCODE_CONFIG`, `OPENCODE_TUI_CONFIG`, `GITLAB_HOST`). Names must be POSIX identifiers From 569b246f4895477ff777248d29af5372aa963f7f Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 24 Sep 2026 05:32:01 +0000 Subject: [PATCH 24/37] feat(kits): wire neutral rc snippets --- acq.backends/common.sh | 29 ++++++++++++++++++++++++++++- docs/BACKEND_GUIDE.md | 6 +++--- test/bats/72-msb-agent-user.bats | 26 ++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 7184ce73..353e953e 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -1229,6 +1229,29 @@ acq_guest_shell_script() { fi } +# acq_login_profile_rc_block — emit the guest-side POSIX-sh snippet that a login +# ~/.profile uses to source the kit-owned ~/.rc.d/*.sh drop-ins. Keep this as the +# single backend-neutral source of the bridge text: msb writes it from +# _acq_msb_ensure_agent_shell, and sbx writes the same bytes from +# _acq_sbx_ensure_rc_bridge. +# +# Emitted lines are evaluated by the guest login shell. They gate to bash, +# list snippets with C collation for deterministic order, preserve the readable +# and charset guards, and clean up the loop variable after sourcing. +acq_login_profile_rc_block() { + printf '%s\n' 'if [ -n "$BASH_VERSION" ] && [ -d "$HOME/.rc.d" ]; then' + printf '%s\n' ' for _acq_rc in $(LC_ALL=C ls "$HOME"/.rc.d 2>/dev/null); do' + printf '%s\n' ' _acq_rc="$HOME/.rc.d/$_acq_rc"' + printf '%s\n' ' case "$_acq_rc" in *.sh) ;; *) continue ;; esac' + printf '%s\n' ' [ -r "$_acq_rc" ] || continue' + printf '%s\n' ' case "$_acq_rc" in *[!A-Za-z0-9._/-]*) continue ;; esac' + printf '%s\n' ' # shellcheck disable=SC1090' + printf '%s\n' ' . "$_acq_rc"' + printf '%s\n' ' done' + printf '%s\n' ' unset _acq_rc' + printf '%s\n' 'fi' +} + # _acq_valid_vsock_port PORT — succeed (return 0) iff PORT is an integer in # 1..4294967294 and not the reserved value 123. msb rejects port 0 and reserves # 123 (see ADR-0021); we mirror that validation host-side so a bad port is @@ -3427,7 +3450,11 @@ fi hook=0 for profile in "$home/.profile" "$home/.bashrc" "$home/.zshrc" /etc/profile /etc/bash.bashrc /etc/profile.d/acq*.sh; do - if [ -f "$profile" ] && grep -Eq '(^|[[:space:]])(source|\.)[[:space:]].*\.rc\.d' "$profile" 2>/dev/null; then + # Match either an explicit `source/. …rc.d…` line OR acq's own login bridge + # marker (acq-login-profile), whose loop sources ~/.rc.d indirectly through a + # variable — so a plain `source .rc.d` grep alone would miss it. See ADR-0030. + if [ -f "$profile" ] \ + && grep -Eq '(^|[[:space:]])(source|\.)[[:space:]].*\.rc\.d|acq-login-profile' "$profile" 2>/dev/null; then hook=1 break fi diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 861d5cac..c63b2148 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -1070,9 +1070,9 @@ The neutral vocabulary is: `caps.network.allow`, `files[]`, `commands[]`, readable POSIX snippets as files under `/home/agent/.rc.d/*.sh`, for example `/home/agent/.rc.d/10-team.sh` or `/home/agent/.rc.d/90-personal.sh`. `acq`'s built-in shell bridge sources these snippets for bash login shells in -lexical order; zsh-capable base images must wire the same directory from native -zsh startup files. The snippets are kit-owned state, not a user-editable dotfile -layer. +deterministic lexical order; zsh-capable base images must wire the same directory +from native zsh startup files. The snippets are kit-owned state, not a +user-editable dotfile layer. Use this path for agent shell integration outside a devenv shell, team tool environment/completions, personal aliases/functions, and an optional diff --git a/test/bats/72-msb-agent-user.bats b/test/bats/72-msb-agent-user.bats index 93e1e986..f1f822e7 100644 --- a/test/bats/72-msb-agent-user.bats +++ b/test/bats/72-msb-agent-user.bats @@ -371,13 +371,33 @@ _attach() { # PRE_SNIPPET NAME @test "rc.d(msb): login-profile bridge sources kit-owned shell snippets lexically" { _provision rcdbox shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/rcd-secrets"' local log; log=$(cat "$CALLS") - assert_regex "$log" '\.rc.d/\*.sh' + # The bridge sources ~/.rc.d in deterministic C-collation order (ADR-0030): + # it must iterate an `LC_ALL=C ls "$HOME"/.rc.d` list, NOT a bare locale- + # dependent `*.sh` glob whose order can vary by guest locale. + assert_regex "$log" 'LC_ALL=C ls' + assert_regex "$log" '\$HOME./.rc.d' assert_regex "$log" 'for _acq_rc in' assert_regex "$log" 'SC1090' assert_regex "$log" 'unset _acq_rc' + # Non-.sh files are skipped by the in-loop suffix case guard. + assert_regex "$log" '\*.sh) ;; \*) continue' refute_regex "$log" 'direnv allow' } +@test "rc.d: generated login-profile block executes snippets in byte order" { + local home="$STUBDIR/rc-home" + mkdir -p "$home/.rc.d" + printf '%s\n' 'printf "%s\n" 2-b >> "$HOME/order"' > "$home/.rc.d/2-b.sh" + printf '%s\n' 'printf "%s\n" 10-a >> "$HOME/order"' > "$home/.rc.d/10-a.sh" + printf '%s\n' 'printf "%s\n" skipped >> "$HOME/order"' > "$home/.rc.d/30-skip.txt" + acq_login_profile_rc_block > "$home/profile" + + run env HOME="$home" bash -c '. "$HOME/profile"; cat "$HOME/order"' + + assert_success + assert_output $'10-a\n2-b' +} + @test "msb #426: the heal only rewrites a .profile acq owns outright (appended lines survive)" { # Tools like rustup append to ~/.profile below acq's bridge. The rewrite # condition must be marker-present AND still just the bridge (line-count @@ -386,7 +406,9 @@ _attach() { # PRE_SNIPPET NAME _provision profguard shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/profguard-secrets"' local log; log=$(cat "$CALLS") assert_regex "$log" 'acq-login-profile "\$profile"' - assert_regex "$log" '\-le 13' + # Bound = 3 header lines + the shared rc-block's line count (common.sh + # acq_login_profile_rc_block), computed host-side and passed as $3/max_lines. + assert_regex "$log" '\-le .\$max_lines' } @test "msb: repeated acq exec reads the workspace marker once per process (cached)" { From b55de5ce1ad3855b31069cab0b2a50d92a6cc634 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 24 Sep 2026 06:38:55 +0000 Subject: [PATCH 25/37] feat(kits): harden pending kit gates --- acq.backends/common.sh | 62 ++++++++++++++-------- acq.backends/msb.sh | 11 +++- test/bats/140-opencode-migration-gate.bats | 12 +++++ test/bats/65-doctor-image-contract.bats | 31 +++++++++++ test/bats/66-oci-engine-kit.bats | 32 ++++++++++- test/bats/71-msb-provision-secrets.bats | 21 ++++++++ 6 files changed, 146 insertions(+), 23 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 353e953e..0e123ea8 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -226,6 +226,7 @@ ACQ_SESSION_KIND="" # ACQ_EXTRA_KITS. One ref per element. ACQ_CLI_KITS=() ACQ_BUILTIN_KIT_COUNT=0 +ACQ_BUILTIN_SUPPORT_KIT_COUNT=0 ACQ_AGENT_KIT_READY_CACHE="" # Per-process readiness memo for the optional OCI-engine kit (ADR-0030), # mirroring ACQ_AGENT_KIT_READY_CACHE: acq_oci_engine_kit_ready runs multiple @@ -921,35 +922,28 @@ acq_agent_builtin_kit_ready() { return 1 } -_acq_selected_builtin_kit_refs() { - local agent="${1:-}" name kit +_acq_selected_support_kit_refs() { + local name kit for name in $(_acq_builtin_support_kit_names); do kit=$(_acq_builtin_kit_ref "$name") || return 1 printf '%s\n' "$kit" done - # Optional OCI-engine capability kit (ADR-0030): appended AFTER the network/CA - # and provider/playbook support kits and BEFORE any agent kit / extras, only - # when the user opted in (ACQ_ENABLE_OCI_KIT) AND the kit is actually present - # at the pinned patterns ref. If opted in but not yet published/valid (the - # current reality — see ADR-0020/ADR-0030), emit one clear notice and skip the - # ref so default behavior and the msb adapter's own podman provisioning are - # unaffected. zscaler-ca-certificate stays first regardless. - # - # NOTE: The sbx forced-heal refresh path (sbx.sh) rebuilds this list on a - # fixed loop and does not yet re-run OCI selection; wiring the sbx forced-heal - # to this selection is deferred until the patterns kit publishes (ADR-0020 / - # ADR-0030). This block affects create-time kit selection only for now. + # Optional OCI-engine capability kit (ADR-0030): appended after the default + # support kits and before any agent/extras/CLI kits, only when the user opted in + # and the kit is present/valid at the pinned patterns ref. if [ -n "$ACQ_ENABLE_OCI_KIT" ]; then if acq_oci_engine_kit_ready; then kit=$(_acq_oci_engine_kit_ref) || return 1 printf '%s\n' "$kit" fi - # NOTE: the "not available" fallback notice is emitted by _build_kit_list - # (the parent shell), NOT here. This function runs inside a process - # substitution (`< <(...)`), so a once-guard set here would live in a - # subshell and reset on every call — defeating the guard. Emitting from the - # parent keeps _ACQ_OCI_KIT_NOTICE_SHOWN process-global. + # The "not available" fallback notice is emitted by _build_kit_list in the + # parent shell so the process-global once guard is effective. fi +} + +_acq_selected_builtin_kit_refs() { + local agent="${1:-}" kit + _acq_selected_support_kit_refs || return 1 if [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] && [ -n "$agent" ] \ && acq_is_known_agent "$agent" && acq_agent_builtin_kit_ready "$agent"; then kit=$(_acq_agent_builtin_kit_ref "$agent") || return 1 @@ -970,6 +964,15 @@ acq_selected_agent_kit_summary() { "${start_owner:-none}" "$apply_state" } +acq_oci_engine_kit_selected() { + local kit expected + expected=$(_acq_oci_engine_kit_ref) || return 1 + for kit in ${KITS[@]+"${KITS[@]}"}; do + [ "$kit" = "$expected" ] && return 0 + done + return 1 +} + acq_print_selected_agent_kit() { local agent="${1:-}" summary summary=$(acq_selected_agent_kit_summary "$agent") || return 0 @@ -1002,7 +1005,14 @@ _build_kit_list() { fi while IFS= read -r kit; do [ -n "$kit" ] && KITS+=("$kit") - done < <(_acq_selected_builtin_kit_refs "$agent") + done < <(_acq_selected_support_kit_refs) + # shellcheck disable=SC2034 # read by adapters/tests after _build_kit_list + ACQ_BUILTIN_SUPPORT_KIT_COUNT="${#KITS[@]}" + if [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] && [ -n "$agent" ] \ + && acq_is_known_agent "$agent" && acq_agent_builtin_kit_ready "$agent"; then + kit=$(_acq_agent_builtin_kit_ref "$agent") || return 1 + KITS+=("$kit") + fi # shellcheck disable=SC2034 # read by adapters/tests after _build_kit_list ACQ_BUILTIN_KIT_COUNT="${#KITS[@]}" if [ -n "$ACQ_EXTRA_KITS" ]; then @@ -3412,13 +3422,23 @@ fi if [ -d /nix ]; then ok "/nix exists" + if [ -w /nix 2>/dev/null ]; then + ok "/nix is writable" + else + warn "/nix is not writable" "mount or bake a writable Nix store at /nix for devenv/cache reuse" + fi else warn "/nix is missing" "use a devenv-capable image with its Nix store at /nix" fi for tool in nix devenv direnv; do if command -v "$tool" >/dev/null 2>&1; then - ok "$tool is on PATH" + ver=$($tool --version 2>/dev/null | head -n1 || true) + if [ -n "$ver" ]; then + ok "$tool is on PATH ($ver)" + else + ok "$tool is on PATH" + fi else warn "$tool is not on PATH" "install $tool in the base image or a create-time kit" fi diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 9b0dae66..361fe83f 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -3133,12 +3133,19 @@ acq_backend_provision() { local kitref kitdir _acq_msb_select_kits "$agent" local kits=("${KITS[@]}") + local _oci_kit_selected=0 _oci_kit_ref="" + if command -v _acq_oci_engine_kit_ref >/dev/null 2>&1; then + _oci_kit_ref=$(_acq_oci_engine_kit_ref) || _oci_kit_ref="" + fi for kitref in "${kits[@]}"; do kitdir=$(_acq_msb_fetch_kit "$kitref") || { echo "acq(msb): error: could not fetch kit: $kitref" >&2 exit 1 } + if [ -n "$_oci_kit_ref" ] && [ "$kitref" = "$_oci_kit_ref" ]; then + _oci_kit_selected=1 + fi kitdirs+=("$kitdir") local spec="${kitdir}/spec.yaml" [ -f "$spec" ] || continue @@ -3740,7 +3747,9 @@ EOF # warning, never aborting provision) if the engine cannot be installed — e.g. # the OS package mirror is unreachable under a narrowed egress. See ADR-0020. acq_debug "msb provision: ensuring OCI engine ($name)" - if [ -n "$ACQ_MSB_ENSURE_OCI" ]; then + if [ "$_oci_kit_selected" -eq 1 ]; then + acq_debug "msb provision: OCI engine provided by selected kit; skipping adapter setup ($name)" + elif [ -n "$ACQ_MSB_ENSURE_OCI" ]; then acq_spin_start "Ensuring an OCI engine (podman)" _acq_msb_ensure_oci "$name" acq_spin_stop "Ensuring an OCI engine (podman)" diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index 32fce21c..4b5fa0aa 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -91,6 +91,18 @@ SPEC assert_regex "$(acq_selected_agent_kit_summary opencode)" 'apply=enabled' } +@test "agent kit gate: valid opencode artifact stays deferred while disabled" { + ACQ_TEST_AGENT_KIT="$STUBDIR/opencode-kit-disabled" + _mk_opencode_agent_kit "$ACQ_TEST_AGENT_KIT" + _acq_agent_builtin_kit_ref() { printf '%s\n' "$ACQ_TEST_AGENT_KIT"; } + + _build_kit_list opencode + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "${#KITS[@]}" "4" + assert_regex "$(acq_selected_agent_kit_summary opencode 2>/dev/null)" 'apply=deferred' +} + @test "agent kit gate: missing opencode artifact fails closed" { acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } _acq_agent_builtin_kit_ref() { printf '%s\n' "$STUBDIR/missing-opencode-kit"; } diff --git a/test/bats/65-doctor-image-contract.bats b/test/bats/65-doctor-image-contract.bats index 57677be9..4b600985 100644 --- a/test/bats/65-doctor-image-contract.bats +++ b/test/bats/65-doctor-image-contract.bats @@ -91,3 +91,34 @@ load 'helper' assert_success assert_output --partial 'ok: shell startup references ~/.rc.d' } + +@test "doctor script: reports tool versions when available" { + local home="$STUBDIR/agent-home-versions" script="$STUBDIR/image-doctor.sh" bin="$STUBDIR/bin" + mkdir -p "$home/.rc.d" "$bin" + printf '. "$HOME/.rc.d/10-team.sh"\n' > "$home/.profile" + printf '#!/usr/bin/env sh\nprintf "nix 2.35.2\\n"\n' > "$bin/nix" + printf '#!/usr/bin/env sh\nprintf "devenv 2.3.0\\n"\n' > "$bin/devenv" + printf '#!/usr/bin/env sh\nprintf "direnv 2.36.0\\n"\n' > "$bin/direnv" + chmod +x "$bin/nix" "$bin/devenv" "$bin/direnv" + bash -c '. "'"$REPO_ROOT"'/acq.backends/common.sh"; acq_image_contract_doctor_script' > "$script" + + run env HOME="$home" PATH="$bin:$PATH" sh "$script" + + assert_success + assert_output --partial 'ok: nix is on PATH (nix 2.35.2)' + assert_output --partial 'ok: devenv is on PATH (devenv 2.3.0)' + assert_output --partial 'ok: direnv is on PATH (direnv 2.36.0)' +} + +@test "doctor script: separately warns for missing rc.d directory and startup hook" { + local home="$STUBDIR/agent-home-no-rc" script="$STUBDIR/image-doctor.sh" + mkdir -p "$home" + bash -c '. "'"$REPO_ROOT"'/acq.backends/common.sh"; acq_image_contract_doctor_script' > "$script" + + run env HOME="$home" sh "$script" + + assert_success + assert_output --partial 'warning: ~/.rc.d is missing' + assert_output --partial 'fix: install the neutral shell hook directory at /home/agent/.rc.d' + assert_output --partial 'warning: no shell startup hook for ~/.rc.d found' +} diff --git a/test/bats/66-oci-engine-kit.bats b/test/bats/66-oci-engine-kit.bats index 72ada08c..08762a63 100644 --- a/test/bats/66-oci-engine-kit.bats +++ b/test/bats/66-oci-engine-kit.bats @@ -28,6 +28,7 @@ load 'helper' refute_regex "$joined" 'acq-kits/oci-engine' # Default identity is unchanged: four support kits, zscaler first. assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "$ACQ_BUILTIN_SUPPORT_KIT_COUNT" "4" assert_equal "${#ACQ_KIT_NAMES[@]}" "4" assert_regex "${KITS[0]}" 'acq-kits/zscaler-ca-certificate' } @@ -71,6 +72,7 @@ load 'helper' # And the kit list itself is unchanged (support-only, zscaler first). _build_kit_list assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" + assert_equal "$ACQ_BUILTIN_SUPPORT_KIT_COUNT" "4" assert_regex "${KITS[0]}" 'acq-kits/zscaler-ca-certificate' refute_regex "$(printf '%s\n' "${KITS[@]}")" 'acq-kits/oci-engine' } @@ -106,8 +108,9 @@ load 'helper' acq_oci_engine_kit_ready() { return 0; } _build_kit_list - # Appended after the four support kits, before extras/CLI kits. + # Appended after the four default support kits, before any agent/extras/CLI kits. assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" + assert_equal "$ACQ_BUILTIN_SUPPORT_KIT_COUNT" "5" assert_regex "${KITS[0]}" 'acq-kits/zscaler-ca-certificate' assert_regex "${KITS[4]}" 'acq-kits/oci-engine' } @@ -124,10 +127,37 @@ load 'helper' _build_kit_list assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" + assert_equal "$ACQ_BUILTIN_SUPPORT_KIT_COUNT" "5" assert_regex "${KITS[4]}" 'acq-kits/oci-engine' assert_regex "$(printf '%s\n' "${KITS[@]}")" 'some-extra-kit' } +@test "oci-kit: ready support kit stays outside the agent-kit boundary" { + load_acq + local agent_kit="$STUBDIR/opencode-kit" + mkdir -p "$agent_kit" + cat >"$agent_kit/spec.yaml" <<'SPEC' +hi +SPEC + # shellcheck disable=SC2034 # read by sourced _build_kit_list + ACQ_EXTRA_KITS="" + # shellcheck disable=SC2034 # read by sourced _build_kit_list + ACQ_CLI_KITS=() + # shellcheck disable=SC2034 # read by sourced _acq_selected_builtin_kit_refs + ACQ_ENABLE_OCI_KIT=1 + acq_oci_engine_kit_ready() { return 0; } + acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + acq_agent_builtin_kit_ready() { [ "$1" = "opencode" ]; } + _acq_agent_builtin_kit_ref() { printf '%s\n' "$agent_kit"; } + + _build_kit_list opencode + + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "6" + assert_equal "$ACQ_BUILTIN_SUPPORT_KIT_COUNT" "5" + assert_regex "${KITS[4]}" 'acq-kits/oci-engine' + assert_equal "${KITS[5]}" "$agent_kit" +} + @test "oci-kit: the emitted ref is acq-kits/oci-engine under the GSA-TTS allowlist" { load_acq local ref; ref=$(_acq_oci_engine_kit_ref) diff --git a/test/bats/71-msb-provision-secrets.bats b/test/bats/71-msb-provision-secrets.bats index dcf54bd9..f6820d6a 100644 --- a/test/bats/71-msb-provision-secrets.bats +++ b/test/bats/71-msb-provision-secrets.bats @@ -103,6 +103,27 @@ _provision() { # NAME PRE_SNIPPET refute_regex "$log" 'docker\.io/docker/sandbox-templates:shell-docker' } +@test "msb: selected OCI kit suppresses adapter-owned podman provisioning" { + _provision ocikitbox ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/ocikit-secrets" + export ACQ_ENABLE_OCI_KIT=1 + acq_oci_engine_kit_ready() { return 0; } + ' + local log; log=$(cat "$CALLS") + refute_regex "$log" '/var/lib/acq/oci-ready' + refute_regex "$log" 'podman build' +} + +@test "msb: stale OCI ref in KITS cannot suppress adapter podman provisioning" { + _provision staleocibox ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/staleoci-secrets" + KITS=("$(_acq_oci_engine_kit_ref)") + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" '/var/lib/acq/oci-ready' + assert_regex "$log" 'podman build' +} + @test "msb#226: a custom endpoint binds generically; usai/github still bind; no bogus flag; no leak" { _provision genbox ' export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/gen-secrets" From 4645a13da06b0990b3d2ed23b36133f67393ade4 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 2 Oct 2026 08:25:22 +0000 Subject: [PATCH 26/37] fix(acq): lazy-load provider facts --- acq | 59 +++++++++++++++++++++++++----- acq.backends/common.sh | 27 ++++++++++++++ test/bats/30-dispatch-routing.bats | 56 +++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 12 deletions(-) diff --git a/acq b/acq index 0058c5e4..7652f047 100755 --- a/acq +++ b/acq @@ -31,6 +31,11 @@ ACQ_RESOLVED_PATH="${ACQ_SCRIPT_DIR}/$(basename "${BASH_SOURCE[0]}")" # shellcheck disable=SC1091 . "${ACQ_SCRIPT_DIR}/acq.backends/common.sh" +# Test-only seam for executable dispatch tests that need a local provider kit. +if [ -n "${BATS_TEST_NAME:-}" ] && [ -n "${ACQ_TEST_USAI_KIT:-}" ]; then + USAI_KIT="$ACQ_TEST_USAI_KIT" +fi + # --------------------------------------------------------------------------- # Print version/identity # --------------------------------------------------------------------------- @@ -529,6 +534,32 @@ _acq_backend_passthrough() { command "${ACQ_RESOLVED_BACKEND}" "$@" } +_acq_secret_service_arg() { + local a1="${1:-}" a2="${2:-}" + case "$a1" in + -g|--global) printf '%s\n' "$a2" ;; + "") printf '\n' ;; + -*) printf '%s\n' "$a1" ;; + *) + case "$a2" in + ""|-*) printf '%s\n' "$a1" ;; + *) printf '%s\n' "$a2" ;; + esac + ;; + esac +} + +_acq_secret_needs_usai_facts() { + [ "$(_acq_secret_service_arg "$@")" = "usai" ] +} + +_acq_secret_import_includes_usai() { + local parsed only_service + parsed=$(_acq_import_parse_args "$@" 2>/dev/null) || return 1 + only_service=$(printf '%s' "$parsed" | cut -f3) + [ -z "$only_service" ] || [ "$only_service" = "usai" ] +} + _acq_parse_secret_scope_for_propagation() { ACQ_PROPAGATE_SCOPE="" ACQ_PROPAGATE_SERVICE="" @@ -960,13 +991,6 @@ esac # Resolve and load the backend adapter (required for all other subcommands). # --------------------------------------------------------------------------- acq_resolve_backend "${explicit_backend}" -_acq_provider_facts_rc=0 -acq_provider_facts_load_from_kit "$USAI_KIT" || _acq_provider_facts_rc=$? -case "$_acq_provider_facts_rc" in - 0|2) : ;; - *) echo "acq: invalid provider facts in pinned usai-provider kit; refusing to continue" >&2; exit 1 ;; -esac -unset _acq_provider_facts_rc # --------------------------------------------------------------------------- # acq_backend_guard_version — cheap version floor for state-touching verbs @@ -1087,6 +1111,7 @@ esac case "$subcommand" in usai-rotate-api-key) shift + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" if ! command -v acq_backend_rotate_key >/dev/null 2>&1; then echo "acq: the '${ACQ_RESOLVED_BACKEND}' backend does not implement key rotation" >&2 echo " (acq_backend_rotate_key). Rotate manually, then re-run." >&2 @@ -1098,6 +1123,7 @@ case "$subcommand" in create) shift + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" acq_backend_prepare # First-run onboarding (ADR-0031): offer `acq configure` once before we # provision, so a brand-new user picks kits up front. No-op after first run @@ -1105,15 +1131,15 @@ case "$subcommand" in maybe_first_run_configure # Apply configured-default extra kits (config.yaml), then offer a create-time # picker pre-populated with those defaults so THIS sandbox can deviate. + create_agent=$(first_positional "$@") _acq_apply_configured_extra_kits || exit 1 create_time_kit_picker - _build_kit_list + _build_kit_list "$create_agent" # Pre-flight: validate the workspace path before provisioning (#issue). ws=$(workspace_path "$@") preflight_workspace_path "$ws" || exit 1 local_name=$(derive_name "$@") || exit 1 [ -n "$local_name" ] || { echo "acq: could not determine sandbox name" >&2; exit 1; } - create_agent=$(first_positional "$@") # The USAi API key must be stored before provisioning: msb binds secrets only # at create time, so a sandbox created before the key exists carries no USAi @@ -1165,6 +1191,7 @@ case "$subcommand" in [ "${#create_args[@]}" -gt 0 ] && first=$(first_positional "${create_args[@]}") if [ -n "$first" ] && is_known_agent "$first"; then + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" # AGENT form: create if missing, then attach. local_name=$(derive_name "${create_args[@]}") || exit 1 [ -n "$local_name" ] || { echo "acq: could not determine sandbox name" >&2; exit 1; } @@ -1243,7 +1270,7 @@ case "$subcommand" in maybe_first_run_configure _acq_apply_configured_extra_kits || exit 1 create_time_kit_picker - _build_kit_list + _build_kit_list "$first" if ! ensure_key_present "$local_name"; then exit 1 @@ -1521,6 +1548,9 @@ case "$subcommand" in case "$sub2" in set) shift + if _acq_secret_needs_usai_facts "$@"; then + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + fi acq_backend_secret_set "$@" if _acq_parse_secret_scope_for_propagation "$@"; then _acq_propagate_secret_to_present_backends "$ACQ_PROPAGATE_SERVICE" "$ACQ_PROPAGATE_SCOPE" @@ -1540,6 +1570,9 @@ case "$subcommand" in ;; rm) shift + if _acq_secret_needs_usai_facts "$@"; then + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + fi # `acq secret rm [-g | SANDBOX] SERVICE` removes an acq-owned secret from # the acq store (and clears the backend's injector). If the args name a # scope + known acq service (-g or a sandbox name), route to the backend @@ -1576,6 +1609,9 @@ case "$subcommand" in # acq_key_injectable so the two can never drift. Fail closed; never forward # to the backend CLI. shift + if _acq_secret_needs_usai_facts "$@"; then + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + fi # Parse scope the same way secret set/rm do: `-g`/`--global` -> global; a # leading bare token before the service -> sandbox; a lone token -> service. secret_has_scope="" @@ -1651,6 +1687,9 @@ case "$subcommand" in # it never invokes the backend's own `secret` CLI. See acq_secret_import # in acq.backends/common.sh and docs/BACKEND_GUIDE.md "Migration". shift + if _acq_secret_import_includes_usai "$@"; then + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + fi acq_secret_import "$@" ;; --help|-h|"") diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 0e123ea8..4b929b26 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -446,6 +446,33 @@ acq_provider_facts_load_from_kit() { esac } +acq_provider_facts_use_fallback() { + USAI_PROVIDER_HOST="api.gsa.usai.gov" + USAI_PROVIDER_BASE_URL="https://${USAI_PROVIDER_HOST}/api/v1" + USAI_PROVIDER_KEY_ENV="USAI_API_KEY" + USAI_PROVIDER_MODELS_URL="${USAI_PROVIDER_BASE_URL}/models" + USAI_PROVIDER_KEY_MGMT_URL="https://gsa.usai.gov/console/key-management" + # shellcheck disable=SC2034 # consumed by adapters/tests after facts load + USAI_PROVIDER_BIND_HOSTS="${USAI_PROVIDER_HOST}" + # shellcheck disable=SC2034 # consumed by diagnostics/tests after facts load + USAI_PROVIDER_FACTS_SOURCE="fallback" +} + +acq_provider_facts_load_from_kit_or_fallback() { + if acq_provider_facts_load_from_kit "$@" 2>/dev/null; then + : + else + acq_provider_facts_use_fallback + acq_debug "provider facts unavailable from kit; using transitional fallback defaults" + fi + # msb derives this adapter-local binding host when sourced, before lazy facts load. + if [ "${ACQ_RESOLVED_BACKEND:-}" = "msb" ]; then + # shellcheck disable=SC2034 # consumed by msb adapter functions at dispatch + ACQ_MSB_USAI_HOST="$USAI_PROVIDER_BIND_HOSTS" + fi + return 0 +} + # _acq_import_detect_var SERVICE -> prints the NAME of the FIRST of SERVICE's # candidate env vars that is set and non-empty; empty output (rc 1) if none is # set. Returns the variable NAME ONLY — never the value — so the secret value is diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 6163c213..dce3e7be 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -27,6 +27,58 @@ _seed_usai() { assert_regex "$(cat "$CALLS")" 'sbx ls' } +@test "dispatch: ls does not fetch provider facts" { + run env ACQ_BACKEND=msb ACQ_DEBUG=1 GIT_ALLOW_PROTOCOL=none "$ACQ" ls + assert_success + assert_regex "$(cat "$CALLS")" 'msb list' + refute_output --partial 'provider facts' +} + +@test "dispatch: create falls back when provider facts cannot be fetched" { + local proj="$STUBDIR/provider-facts-offline"; mkdir -p "$proj" + rm -f "$STUBDIR/.msb_created" + run env ACQ_BACKEND=msb GIT_ALLOW_PROTOCOL=none USAI_API_KEY="usai-key-stub" \ + STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 "$ACQ" create opencode "$proj" + assert_success + refute_output --partial 'invalid provider facts' + assert_regex "$(cat "$CALLS")" 'msb create' + assert [ -f "$STUBDIR/.msb_created" ] +} + +@test "dispatch: run existing sandbox does not fetch provider facts" { + printf 'mybox\n' > "$STUBDIR/.msb_sandbox_list" + run env ACQ_BACKEND=msb ACQ_DEBUG=1 GIT_ALLOW_PROTOCOL=none "$ACQ" run mybox + assert_success + assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /home/agent -e SHELL=/bin/sh mybox -- /bin/sh -l' + refute_output --partial 'provider facts' +} + +@test "dispatch: start falls back when provider facts cannot be fetched" { + run env ACQ_BACKEND=msb GIT_ALLOW_PROTOCOL=none USAI_API_KEY="usai-key-stub" \ + STUB_AGENT_USER_READY=1 "$ACQ" start mybox + assert_success + refute_output --partial 'invalid provider facts' + assert_regex "$(cat "$CALLS")" 'msb start mybox' +} + +@test "secret: option-only import falls back when provider facts cannot be fetched" { + run env ACQ_BACKEND=msb GIT_ALLOW_PROTOCOL=none USAI_API_KEY="usai-key-stub" \ + ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$STUBDIR/secrets" \ + "$ACQ" secret import --all + assert_success + refute_output --partial 'provider facts' + assert_output --partial "imported 'usai'" +} + +@test "secret: scoped non-USAi command does not fetch provider facts" { + run env ACQ_BACKEND=msb ACQ_DEBUG=1 GIT_ALLOW_PROTOCOL=none \ + ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$STUBDIR/secrets" \ + GITHUB_TOKEN="github-token-stub" "$ACQ" secret set usai github + assert_success + refute_output --partial 'provider facts' + assert_output --partial "stored 'github'" +} + @test "shell: NAME -> interactive backend shell (sbx exec -it ... bash)" { run env ACQ_BACKEND=sbx "$ACQ" shell mybox assert_regex "$(cat "$CALLS")" 'sbx exec -it mybox bash' @@ -210,7 +262,7 @@ _seed_usai() { @test "create(msb): host-exported USAI_API_KEY counts as present; provision proceeds" { local proj="$STUBDIR/kc-ci"; mkdir -p "$proj" rm -f "$STUBDIR/.msb_created" - run bash -c 'printf "" | USAI_API_KEY="sk-ci-host" ACQ_BACKEND=msb "$1" create opencode "$2"' _ "$ACQ" "$proj" + run bash -c 'printf "" | USAI_API_KEY="usai-key-stub" ACQ_BACKEND=msb "$1" create opencode "$2"' _ "$ACQ" "$proj" refute_output --partial 'no USAi API key stored' assert_regex "$(cat "$CALLS")" 'msb create' assert [ -f "$STUBDIR/.msb_created" ] @@ -227,7 +279,7 @@ _seed_usai() { local ghproj="$STUBDIR/gh-mcreate"; mkdir -p "$ghproj" ( cd "$ghproj" && git init -q && git remote add origin https://github.com/GSA-TTS/quickstart.git ) rm -f "$STUBDIR/.msb_created" - run bash -c 'printf "" | USAI_API_KEY="sk-ci-host" ACQ_BACKEND=msb ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$3/.secrets" "$1" create opencode "$2"' _ "$ACQ" "$ghproj" "$ghproj" + run bash -c 'printf "" | USAI_API_KEY="usai-key-stub" ACQ_BACKEND=msb ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$3/.secrets" "$1" create opencode "$2"' _ "$ACQ" "$ghproj" "$ghproj" assert_output --partial 'no repo-scoped GitHub token' assert_regex "$(cat "$CALLS")" 'msb create' assert [ -f "$STUBDIR/.msb_created" ] From 3ce4201c2c29b7c4f6c15dad241c56fc5fce7f58 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 2 Oct 2026 09:33:51 +0000 Subject: [PATCH 27/37] test(acq): fix provider-facts lazy-load test setup Co-authored-by: OpenCode [gpt-5.5] --- test/bats/30-dispatch-routing.bats | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index dce3e7be..817d0a5a 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -47,7 +47,9 @@ _seed_usai() { @test "dispatch: run existing sandbox does not fetch provider facts" { printf 'mybox\n' > "$STUBDIR/.msb_sandbox_list" - run env ACQ_BACKEND=msb ACQ_DEBUG=1 GIT_ALLOW_PROTOCOL=none "$ACQ" run mybox + printf 'mybox\n' > "$STUBDIR/.msb_running_list" + run env TERM= ACQ_BACKEND=msb ACQ_DEBUG=1 GIT_ALLOW_PROTOCOL=none \ + STUB_AGENT_USER_READY=1 "$ACQ" run mybox assert_success assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /home/agent -e SHELL=/bin/sh mybox -- /bin/sh -l' refute_output --partial 'provider facts' @@ -71,9 +73,12 @@ _seed_usai() { } @test "secret: scoped non-USAi command does not fetch provider facts" { - run env ACQ_BACKEND=msb ACQ_DEBUG=1 GIT_ALLOW_PROTOCOL=none \ - ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$STUBDIR/secrets" \ - GITHUB_TOKEN="github-token-stub" "$ACQ" secret set usai github + run bash -c ' + printf "%s\n" "github-token-stub" \ + | ACQ_BACKEND=msb ACQ_DEBUG=1 GIT_ALLOW_PROTOCOL=none \ + ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$2/secrets" \ + "$1" secret set usai github + ' _ "$ACQ" "$STUBDIR" assert_success refute_output --partial 'provider facts' assert_output --partial "stored 'github'" From 354cc52cf16e71b84572428a566a53358f394263 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 2 Oct 2026 09:36:14 +0000 Subject: [PATCH 28/37] feat(kits): pin patterns v1.10.0 and enable the opt-in oci-engine kit Co-authored-by: OpenCode [gpt-5.5] --- acq.backends/common.sh | 28 ++++++++++------------ docs/adr/0020-msb-oci-engine-via-podman.md | 7 ++++++ test/bats/66-oci-engine-kit.bats | 23 +++++++++++------- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 4b929b26..0ec39c98 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -36,7 +36,7 @@ PATTERNS_KIT_REPO="git+https://github.com/GSA-TTS/agentic-coding-patterns.git" # the bundle-version anchor recorded in a sandbox's host-side provenance record # (see ACQ_BUILTIN_BUNDLE below + the provenance helpers) so acq can tell a # stale sandbox from a current one. -PATTERNS_KIT_REF="6c6753c60a2b24322fb2e8c0d8e8af60c56ede8f" # agentic-coding-patterns v1.9.0 +PATTERNS_KIT_REF="2dd2ad6ad5f63b842d732424bed5ba9aebeed676" # agentic-coding-patterns v1.10.0 PATTERNS_KIT_DIR="integrations/isolation/acq-kits" USAI_PROVIDER_KIT_NAME="usai-provider" @@ -89,14 +89,13 @@ ACQ_BUILTIN_BUNDLE_REPO="GSA-TTS/agentic-coding-patterns" # built-in provisioning. Normalized exactly like ACQ_MSB_ENSURE_OCI below: # unset/empty/0/false/no/off => off (case-insensitive); anything else => on. # -# Intentionally additive and gated (ADR-0020/ADR-0030): the patterns-side kit -# body is not published yet, so even when opted in acq falls back silently (with -# a single stderr notice) unless a readiness check confirms the kit is present at -# the pinned ref. This keeps default behavior — and the msb adapter's active -# podman provisioning (`_acq_msb_ensure_oci`) — unchanged until the kit path is -# live-verified. NOT added to ACQ_KIT_NAMES (that is the default `acq kit list` -# registry and the provenance-bundle count); selection is gated purely in -# _acq_selected_builtin_kit_refs. +# Intentionally additive and gated (ADR-0020/ADR-0030): even when opted in, acq +# only selects the kit after a readiness check confirms it is present and valid at +# the pinned ref. This keeps default behavior — including the msb adapter's +# podman provisioning (`_acq_msb_ensure_oci`) — unchanged unless the user opts in +# and the kit is live at the pin. NOT added to ACQ_KIT_NAMES (that is the default +# `acq kit list` registry and the provenance-bundle count); selection is gated +# purely in _acq_selected_builtin_kit_refs. OCI_ENGINE_KIT_NAME="oci-engine" ACQ_ENABLE_OCI_KIT="${ACQ_ENABLE_OCI_KIT-}" case "$(printf '%s' "$ACQ_ENABLE_OCI_KIT" | tr '[:upper:]' '[:lower:]')" in @@ -841,12 +840,11 @@ _acq_oci_engine_kit_ref() { # Readiness check for the optional OCI-engine kit, mirroring the agent-kit # readiness pattern (acq_agent_builtin_kit_ready): fetch the kit at the pinned # ref and validate it is a well-formed mixin named oci-engine. Returns 0 only -# when the kit is actually present/valid. Fetch failure (the current reality — -# the patterns-side kit is unpublished) or a spec mismatch returns non-zero, so -# selection falls back silently. The yes/no result is memoized in -# ACQ_OCI_KIT_READY_CACHE so at most one network fetch runs per process even -# though _build_kit_list calls this repeatedly. Offline tests stub this to -# force ready/not-ready. +# when the kit is actually present/valid. Fetch failure or a spec mismatch returns +# non-zero, so selection falls back without changing default behavior. The yes/no +# result is memoized in ACQ_OCI_KIT_READY_CACHE so at most one network fetch runs +# per process even though _build_kit_list calls this repeatedly. Offline tests +# stub this to force ready/not-ready. acq_oci_engine_kit_ready() { local kitref base_dir kitdir schema kind kit_name cache_key cache_value kitref=$(_acq_oci_engine_kit_ref) || return 1 diff --git a/docs/adr/0020-msb-oci-engine-via-podman.md b/docs/adr/0020-msb-oci-engine-via-podman.md index ee141989..8b1ebf7c 100644 --- a/docs/adr/0020-msb-oci-engine-via-podman.md +++ b/docs/adr/0020-msb-oci-engine-via-podman.md @@ -74,6 +74,13 @@ already wrestles with), and (c) provision a per-sandbox disk-backed ## Decision +> **Revision note (2026-10-02, ADR-0030 transition):** this adapter-owned path +> remains the default when `ACQ_ENABLE_OCI_KIT` is unset/off, but the shared +> `oci-engine` capability kit is now published in agentic-coding-patterns v1.10.0. +> When a user opts in with `ACQ_ENABLE_OCI_KIT=1`, `acq` selects that kit and +> suppresses this adapter provisioning for the sandbox. See +> [ADR-0030](0030-agent-kits-on-devenv-base.md). + Provision **podman** at provision time as the OCI engine, run it **rootless as the agent user**, and alias `docker` → `podman` so both `docker run …` and `docker compose …` work inside the sandbox. Implemented as a new idempotent, diff --git a/test/bats/66-oci-engine-kit.bats b/test/bats/66-oci-engine-kit.bats index 08762a63..5af95c6f 100644 --- a/test/bats/66-oci-engine-kit.bats +++ b/test/bats/66-oci-engine-kit.bats @@ -2,13 +2,13 @@ # # 66-oci-engine-kit.bats — optional OCI-engine capability kit selection (ADR-0030) # -# The OCI-engine kit is off by default and opt-in via ACQ_ENABLE_OCI_KIT. Because -# the patterns-side kit body is unpublished (see ADR-0020/ADR-0030), selection is -# additionally gated on a readiness check (acq_oci_engine_kit_ready): opted in but -# not-ready must NOT add the ref and must emit one clear notice. These tests are -# fully offline — the readiness check is stubbed per case, so no network fetch or -# real kit is required. In-process (no `run` for the array assertions) mirrors -# 50-kit-list-completeness.bats; the notice-emitting case captures stderr. +# The OCI-engine kit is off by default and opt-in via ACQ_ENABLE_OCI_KIT. +# Selection is additionally gated on a readiness check +# (acq_oci_engine_kit_ready): opted in but not-ready must NOT add the ref and must +# emit one clear notice. Most tests are fully offline — the readiness check is +# stubbed per case, so no network fetch or real kit is required. In-process (no +# `run` for the array assertions) mirrors 50-kit-list-completeness.bats; the +# notice-emitting case captures stderr. # # shellcheck shell=bats @@ -43,7 +43,8 @@ load 'helper' ACQ_EXTRA_KITS="" ACQ_CLI_KITS=() ACQ_ENABLE_OCI_KIT=1 - # Force NOT-ready: the real current state (kit unpublished at the pinned ref). + # Force NOT-ready to keep the fail-soft fallback covered even when the pinned + # release carries the kit. acq_oci_engine_kit_ready() { return 1; } # The ref-selection helper never emits the ref when not-ready... @@ -170,6 +171,12 @@ SPEC assert_equal "$KIT_SOURCE_PREFIX" "github.com/GSA-TTS/" } +@test "oci-kit: readiness TRUE at the pinned patterns ref" { + load_acq + run acq_oci_engine_kit_ready + assert_success +} + @test "oci-kit: readiness TRUE for a well-formed oci-engine mixin spec (offline)" { load_acq # Exercise the REAL acq_oci_engine_kit_ready validation branch offline: stub From fbf1c888ea8f17df2c0369ea15ebddca11f9735d Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 2 Oct 2026 09:37:49 +0000 Subject: [PATCH 29/37] docs(adr): resolve migration sequencing and track the open adr-0030 questions Refs: GSA-TTS/agentic-coding-quickstart#533 Co-authored-by: OpenCode [gpt-5.5] --- docs/adr/0030-agent-kits-on-devenv-base.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/adr/0030-agent-kits-on-devenv-base.md b/docs/adr/0030-agent-kits-on-devenv-base.md index f241d147..f9cf1555 100644 --- a/docs/adr/0030-agent-kits-on-devenv-base.md +++ b/docs/adr/0030-agent-kits-on-devenv-base.md @@ -379,15 +379,29 @@ as the image override; and `volumes:` mounted at boot before any exec. The login.gov team's `scripts/verify` asserts each of these against a real `--clone` sandbox on the active backend and can gate the refactor. +### Migration sequencing + +The `acq run opencode .` behavior changes in the staged sequence documented in +[`docs/BACKEND_GUIDE.md`](../BACKEND_GUIDE.md#adr-0030-migration-sequencing-agent-kitdevenv-model): +freeze the current contract first, introduce agent-kit inputs without changing +dispatch, switch OpenCode provisioning to consume the agent kit, move +remaining devenv-specific setup behind the kit boundary, then remove obsolete +wrapper code. Each increment has explicit offline and live verification commands. +The Increment 0 freeze is implemented in +[`test/bats/140-opencode-migration-gate.bats`](../../test/bats/140-opencode-migration-gate.bats), +which keeps the current backend-neutral contract green while the migration +continues. + ## Open Questions - **Merge semantics beyond egress:** "later wins" is accepted for environment and files and "union" for egress, but volume and startup-step conflicts across - built-in, team, and personal kits still need a rule. + built-in, team, and personal kits still need a rule. Tracked by + [GSA-TTS/agentic-coding-quickstart#533](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/533). - **Minimum bring-your-own-image contract:** the exact required contents and how `acq` verifies them (including `/nix`, `devenv.nix`, and the shell hook). -- **Migration sequencing:** the order in which `acq run opencode .` behavior - changes, and which regression checks gate each step. + Tracked by + [GSA-TTS/agentic-coding-quickstart#484](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/484). ## Consequences From dd45e86143104e33395646995ecb9e3f1df3da0a Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 2 Oct 2026 10:35:50 +0000 Subject: [PATCH 30/37] fix(sbx): honor provider key env in usai checks Co-authored-by: OpenCode [gpt-5.5] --- test/bats/40-secret-set.bats | 11 +++++++++++ test/bats/77-usai-rotate.bats | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/test/bats/40-secret-set.bats b/test/bats/40-secret-set.bats index 452d2217..bfb06754 100644 --- a/test/bats/40-secret-set.bats +++ b/test/bats/40-secret-set.bats @@ -288,6 +288,17 @@ LS assert_success } +@test "secret has(sbx): provider facts key env drives proxy lookup" { + load_acq + mkdir -p "$STUBDIR/secrets"; printf 'k\n' > "$STUBDIR/secrets/acq.usai" + printf 'CUSTOM SECRETS\n(global) api.gsa.usai.gov KIT_API_KEY ph-kit ****\n' > "$STUBDIR/sbx_ls" + export SBX_LS_FIXTURE="$STUBDIR/sbx_ls" + export USAI_PROVIDER_KEY_ENV=KIT_API_KEY + run acq_backend_key_present usai + + assert_success +} + @test "secret has: a missing service name is a usage error (rc 2)" { load_acq run env ACQ_BACKEND=msb "$ACQ" secret has -g diff --git a/test/bats/77-usai-rotate.bats b/test/bats/77-usai-rotate.bats index a20589aa..ffae98c7 100644 --- a/test/bats/77-usai-rotate.bats +++ b/test/bats/77-usai-rotate.bats @@ -23,6 +23,20 @@ load 'helper' assert_regex "$log" 'sbx create --name acq-keycheck-' } +@test "rotate(sbx): provider facts key env drives placeholder update" { + load_acq + printf 'CUSTOM SECRETS\n(global) api.gsa.usai.gov KIT_API_KEY ph-kit ****\n' > "$STUBDIR/sbx_ls" + export SBX_LS_FIXTURE="$STUBDIR/sbx_ls" + export USAI_PROVIDER_KEY_ENV=KIT_API_KEY + export ACQ_PROPAGATING_SECRET=1 + run acq_backend_rotate_key + + local log + log=$(cat "$CALLS") + assert_success + assert_regex "$log" 'sbx secret set-custom --host api\.gsa\.usai\.gov --env KIT_API_KEY --placeholder ph-kit' +} + @test "rotate(msb): stores the key and re-feeds via msb modify" { printf 'runningbox\n' > "$STUBDIR/.msb_sandbox_list" run env ACQ_SECRET_TEST_VALUE="new-usai-key" ACQ_BACKEND=msb "$ACQ" usai-rotate-api-key From 9258417e2bc94013b5d48cfbf089b27a8e5637cb Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 2 Oct 2026 22:43:53 +0000 Subject: [PATCH 31/37] refactor(kits): drop the bespoke oci-engine selection gate GSA-TTS/agentic-coding-quickstart#500's generic opt-in catalog (ADR-0031) supersedes the OCI-specific gate. The oci-engine kit will be added to ACQ_OPTIN_KIT_NAMES in the follow-up tracked by GSA-TTS/agentic-coding-quickstart#505. The msb adapter auto-install path is unchanged by this commit; retiring that path remains part of the follow-up. Co-authored-by: OpenCode [gpt-5.5] --- acq.backends/common.sh | 114 ---------- acq.backends/msb.sh | 11 +- docs/adr/0020-msb-oci-engine-via-podman.md | 8 +- docs/adr/0030-agent-kits-on-devenv-base.md | 4 +- test/bats/66-oci-engine-kit.bats | 244 --------------------- test/bats/71-msb-provision-secrets.bats | 21 -- 6 files changed, 7 insertions(+), 395 deletions(-) delete mode 100644 test/bats/66-oci-engine-kit.bats diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 0ec39c98..7f3bde6f 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -82,33 +82,6 @@ ACQ_BUILTIN_BUNDLE="acq-builtin" # shellcheck disable=SC2034 ACQ_BUILTIN_BUNDLE_REPO="GSA-TTS/agentic-coding-patterns" -# Optional OCI-engine capability kit (ADR-0030). OFF by default. When opted in -# AND the kit is actually published/valid at the pinned patterns ref, acq appends -# an `oci-engine` capability kit to the built-in support bundle so agents get an -# OCI-run capability (podman) from a shared kit rather than from the msb adapter's -# built-in provisioning. Normalized exactly like ACQ_MSB_ENSURE_OCI below: -# unset/empty/0/false/no/off => off (case-insensitive); anything else => on. -# -# Intentionally additive and gated (ADR-0020/ADR-0030): even when opted in, acq -# only selects the kit after a readiness check confirms it is present and valid at -# the pinned ref. This keeps default behavior — including the msb adapter's -# podman provisioning (`_acq_msb_ensure_oci`) — unchanged unless the user opts in -# and the kit is live at the pin. NOT added to ACQ_KIT_NAMES (that is the default -# `acq kit list` registry and the provenance-bundle count); selection is gated -# purely in _acq_selected_builtin_kit_refs. -OCI_ENGINE_KIT_NAME="oci-engine" -ACQ_ENABLE_OCI_KIT="${ACQ_ENABLE_OCI_KIT-}" -case "$(printf '%s' "$ACQ_ENABLE_OCI_KIT" | tr '[:upper:]' '[:lower:]')" in - ""|0|false|no|off) ACQ_ENABLE_OCI_KIT="" ;; - *) ACQ_ENABLE_OCI_KIT="1" ;; -esac -# One-shot guard so the "requested but not available" fallback notice prints at -# most once per process, not once per _acq_selected_builtin_kit_refs call -# (that runs 2-3x/invocation via _build_kit_list). Mirrors sbx.sh's -# _ACQ_SBX_RECREATE_NOTICE_SHOWN. Guarded by this flag (not the readiness -# cache) so the notice still prints exactly once even when readiness is memoized. -_ACQ_OCI_KIT_NOTICE_SHOWN=0 - # Additional user-supplied kits. Set ACQ_EXTRA_KITS to a whitespace-separated # list of kit references. Set ACQ_EXTRA_KIT_SOURCES for their allowlist prefixes. ACQ_EXTRA_KITS="${ACQ_EXTRA_KITS:-}" @@ -227,11 +200,6 @@ ACQ_CLI_KITS=() ACQ_BUILTIN_KIT_COUNT=0 ACQ_BUILTIN_SUPPORT_KIT_COUNT=0 ACQ_AGENT_KIT_READY_CACHE="" -# Per-process readiness memo for the optional OCI-engine kit (ADR-0030), -# mirroring ACQ_AGENT_KIT_READY_CACHE: acq_oci_engine_kit_ready runs multiple -# times per invocation (via _build_kit_list), so its network fetch is done once -# and the yes/no result cached here. -ACQ_OCI_KIT_READY_CACHE="" # Host-port overrides supplied on the command line via `--publish HOST:GUEST` # (repeatable) on run/create, one `HOST:GUEST` pair per element (ADR-0034). @@ -826,55 +794,6 @@ _acq_agent_builtin_kit_ref() { printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$kit_name" } -# Dedicated ref helper for the optional OCI-engine capability kit. Deliberately -# NOT routed through _acq_builtin_kit_ref (which requires membership in -# ACQ_KIT_NAMES): keeping oci-engine out of ACQ_KIT_NAMES preserves the default -# `acq kit list` set and the provenance-bundle count. Produces the same pinned -# patterns ref shape, under github.com/GSA-TTS/ so it already satisfies -# KIT_SOURCE_PREFIX without widening the allowlist. -_acq_oci_engine_kit_ref() { - printf '%s#ref=%s&dir=%s/%s\n' \ - "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$OCI_ENGINE_KIT_NAME" -} - -# Readiness check for the optional OCI-engine kit, mirroring the agent-kit -# readiness pattern (acq_agent_builtin_kit_ready): fetch the kit at the pinned -# ref and validate it is a well-formed mixin named oci-engine. Returns 0 only -# when the kit is actually present/valid. Fetch failure or a spec mismatch returns -# non-zero, so selection falls back without changing default behavior. The yes/no -# result is memoized in ACQ_OCI_KIT_READY_CACHE so at most one network fetch runs -# per process even though _build_kit_list calls this repeatedly. Offline tests -# stub this to force ready/not-ready. -acq_oci_engine_kit_ready() { - local kitref base_dir kitdir schema kind kit_name cache_key cache_value - kitref=$(_acq_oci_engine_kit_ref) || return 1 - cache_key=$(printf '%s' "$kitref" | cksum | cut -d' ' -f1) - cache_value=$(printf '%s\n' "$ACQ_OCI_KIT_READY_CACHE" | awk -F'\t' -v key="$cache_key" '$1 == key { print $2; exit }') - case "$cache_value" in - yes) return 0 ;; - no) return 1 ;; - esac - base_dir="${ACQ_STATE_DIR:-${TMPDIR:-/tmp}/acq}/oci-engine-kit-validation" - # Best-effort presence probe: suppress kit-translate's multi-line fetch-failure - # block on stderr (the single acq-level notice from _acq_selected_builtin_kit_refs - # is the only user-facing message). stderr is NOT suppressed anywhere else. - if kitdir=$(kit_translate_fetch "$kitref" "$base_dir" 2>/dev/null) \ - && [ -f "$kitdir/spec.yaml" ]; then - schema=$(kit_spec_field "$kitdir/spec.yaml" schemaVersion) || schema="" - kind=$(kit_spec_field "$kitdir/spec.yaml" kind) || kind="" - kit_name=$(kit_spec_field "$kitdir/spec.yaml" name) || kit_name="" - if [ "$schema" = "hybrid/v1" ] && [ "$kind" = "mixin" ] \ - && [ "$kit_name" = "$OCI_ENGINE_KIT_NAME" ]; then - ACQ_OCI_KIT_READY_CACHE="${ACQ_OCI_KIT_READY_CACHE}${cache_key} yes -" - return 0 - fi - fi - ACQ_OCI_KIT_READY_CACHE="${ACQ_OCI_KIT_READY_CACHE}${cache_key} no -" - return 1 -} - kit_spec_agent_field() { local spec="$1" key="$2" [ -f "$spec" ] || return 1 @@ -953,17 +872,6 @@ _acq_selected_support_kit_refs() { kit=$(_acq_builtin_kit_ref "$name") || return 1 printf '%s\n' "$kit" done - # Optional OCI-engine capability kit (ADR-0030): appended after the default - # support kits and before any agent/extras/CLI kits, only when the user opted in - # and the kit is present/valid at the pinned patterns ref. - if [ -n "$ACQ_ENABLE_OCI_KIT" ]; then - if acq_oci_engine_kit_ready; then - kit=$(_acq_oci_engine_kit_ref) || return 1 - printf '%s\n' "$kit" - fi - # The "not available" fallback notice is emitted by _build_kit_list in the - # parent shell so the process-global once guard is effective. - fi } _acq_selected_builtin_kit_refs() { @@ -989,15 +897,6 @@ acq_selected_agent_kit_summary() { "${start_owner:-none}" "$apply_state" } -acq_oci_engine_kit_selected() { - local kit expected - expected=$(_acq_oci_engine_kit_ref) || return 1 - for kit in ${KITS[@]+"${KITS[@]}"}; do - [ "$kit" = "$expected" ] && return 0 - done - return 1 -} - acq_print_selected_agent_kit() { local agent="${1:-}" summary summary=$(acq_selected_agent_kit_summary "$agent") || return 0 @@ -1015,19 +914,6 @@ acq_print_selected_agent_kit() { _build_kit_list() { local agent="${1:-}" kit KITS=() - # Prime the OCI readiness cache in the PARENT shell first (at most one network - # fetch per process, memoized in ACQ_OCI_KIT_READY_CACHE). The subsequent - # process substitution forks a subshell that inherits this populated cache, so - # _acq_selected_builtin_kit_refs short-circuits without re-fetching. Also emit - # the "not available" fallback notice here — guarded by the process-global - # _ACQ_OCI_KIT_NOTICE_SHOWN (independent of the cache) so it prints exactly - # once across the 2-3 _build_kit_list calls per invocation. Doing this in the - # subshell would reset both the guard and the cache each call. - if [ -n "$ACQ_ENABLE_OCI_KIT" ] && ! acq_oci_engine_kit_ready \ - && [ "${_ACQ_OCI_KIT_NOTICE_SHOWN:-0}" != "1" ]; then - _ACQ_OCI_KIT_NOTICE_SHOWN=1 - printf 'acq: OCI engine kit requested but not available at the pinned patterns ref; falling back to adapter-provided OCI (if any).\n' >&2 - fi while IFS= read -r kit; do [ -n "$kit" ] && KITS+=("$kit") done < <(_acq_selected_support_kit_refs) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 361fe83f..9b0dae66 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -3133,19 +3133,12 @@ acq_backend_provision() { local kitref kitdir _acq_msb_select_kits "$agent" local kits=("${KITS[@]}") - local _oci_kit_selected=0 _oci_kit_ref="" - if command -v _acq_oci_engine_kit_ref >/dev/null 2>&1; then - _oci_kit_ref=$(_acq_oci_engine_kit_ref) || _oci_kit_ref="" - fi for kitref in "${kits[@]}"; do kitdir=$(_acq_msb_fetch_kit "$kitref") || { echo "acq(msb): error: could not fetch kit: $kitref" >&2 exit 1 } - if [ -n "$_oci_kit_ref" ] && [ "$kitref" = "$_oci_kit_ref" ]; then - _oci_kit_selected=1 - fi kitdirs+=("$kitdir") local spec="${kitdir}/spec.yaml" [ -f "$spec" ] || continue @@ -3747,9 +3740,7 @@ EOF # warning, never aborting provision) if the engine cannot be installed — e.g. # the OS package mirror is unreachable under a narrowed egress. See ADR-0020. acq_debug "msb provision: ensuring OCI engine ($name)" - if [ "$_oci_kit_selected" -eq 1 ]; then - acq_debug "msb provision: OCI engine provided by selected kit; skipping adapter setup ($name)" - elif [ -n "$ACQ_MSB_ENSURE_OCI" ]; then + if [ -n "$ACQ_MSB_ENSURE_OCI" ]; then acq_spin_start "Ensuring an OCI engine (podman)" _acq_msb_ensure_oci "$name" acq_spin_stop "Ensuring an OCI engine (podman)" diff --git a/docs/adr/0020-msb-oci-engine-via-podman.md b/docs/adr/0020-msb-oci-engine-via-podman.md index 8b1ebf7c..a317fd64 100644 --- a/docs/adr/0020-msb-oci-engine-via-podman.md +++ b/docs/adr/0020-msb-oci-engine-via-podman.md @@ -75,11 +75,9 @@ already wrestles with), and (c) provision a per-sandbox disk-backed ## Decision > **Revision note (2026-10-02, ADR-0030 transition):** this adapter-owned path -> remains the default when `ACQ_ENABLE_OCI_KIT` is unset/off, but the shared -> `oci-engine` capability kit is now published in agentic-coding-patterns v1.10.0. -> When a user opts in with `ACQ_ENABLE_OCI_KIT=1`, `acq` selects that kit and -> suppresses this adapter provisioning for the sandbox. See -> [ADR-0030](0030-agent-kits-on-devenv-base.md). +> remains the default while the shared `oci-engine` capability kit is introduced +> through the generic opt-in catalog described by ADR-0031. The catalog entry and +> adapter retirement are tracked by GSA-TTS/agentic-coding-quickstart#505. Provision **podman** at provision time as the OCI engine, run it **rootless as the agent user**, and alias `docker` → `podman` so both `docker run …` and diff --git a/docs/adr/0030-agent-kits-on-devenv-base.md b/docs/adr/0030-agent-kits-on-devenv-base.md index f9cf1555..7978e226 100644 --- a/docs/adr/0030-agent-kits-on-devenv-base.md +++ b/docs/adr/0030-agent-kits-on-devenv-base.md @@ -352,7 +352,9 @@ wins; `shell` selects no agent kit. Rootless podman setup moves out of the msb adapter into an explicit OCI engine kit usable by both backends, but it is **optional and not part of the default bundle**: it is a create-time install plus egress for every sandbox, and many -kits never need it. +kits never need it. ADR-0031's generic opt-in catalog is the intended selection +mechanism; adding the catalog entry and retiring the adapter-owned default path +are tracked by GSA-TTS/agentic-coding-quickstart#505. ### Capabilities available through passwordless sudo diff --git a/test/bats/66-oci-engine-kit.bats b/test/bats/66-oci-engine-kit.bats deleted file mode 100644 index 5af95c6f..00000000 --- a/test/bats/66-oci-engine-kit.bats +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env bats -# -# 66-oci-engine-kit.bats — optional OCI-engine capability kit selection (ADR-0030) -# -# The OCI-engine kit is off by default and opt-in via ACQ_ENABLE_OCI_KIT. -# Selection is additionally gated on a readiness check -# (acq_oci_engine_kit_ready): opted in but not-ready must NOT add the ref and must -# emit one clear notice. Most tests are fully offline — the readiness check is -# stubbed per case, so no network fetch or real kit is required. In-process (no -# `run` for the array assertions) mirrors 50-kit-list-completeness.bats; the -# notice-emitting case captures stderr. -# -# shellcheck shell=bats - -setup() { acq_setup_stubs; } -teardown() { acq_teardown_stubs; } - -load 'helper' - -@test "oci-kit: default (no opt-in) does not select the oci-engine kit" { - load_acq - ACQ_EXTRA_KITS="" - ACQ_CLI_KITS=() - ACQ_ENABLE_OCI_KIT="" - _build_kit_list - - local joined; joined=$(printf '%s\n' "${KITS[@]}") - refute_regex "$joined" 'acq-kits/oci-engine' - # Default identity is unchanged: four support kits, zscaler first. - assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" - assert_equal "$ACQ_BUILTIN_SUPPORT_KIT_COUNT" "4" - assert_equal "${#ACQ_KIT_NAMES[@]}" "4" - assert_regex "${KITS[0]}" 'acq-kits/zscaler-ca-certificate' -} - -@test "oci-kit: default does not pollute the acq kit list registry" { - load_acq - refute_regex "$(printf '%s\n' "${ACQ_KIT_NAMES[@]}")" 'oci-engine' -} - -@test "oci-kit: opted-in but NOT ready adds no ref and emits one clear notice" { - load_acq - ACQ_EXTRA_KITS="" - ACQ_CLI_KITS=() - ACQ_ENABLE_OCI_KIT=1 - # Force NOT-ready to keep the fail-soft fallback covered even when the pinned - # release carries the kit. - acq_oci_engine_kit_ready() { return 1; } - - # The ref-selection helper never emits the ref when not-ready... - run _acq_selected_builtin_kit_refs - assert_success - refute_regex "$output" 'acq-kits/oci-engine' - - # ...and _build_kit_list emits the single fallback notice (Fix A moved the - # notice to the parent shell so the once-guard survives the subshell). - run bash -c ' - ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" - ACQ_EXTRA_KITS="" - ACQ_CLI_KITS=() - ACQ_ENABLE_OCI_KIT=1 - acq_oci_engine_kit_ready() { return 1; } - _build_kit_list >/dev/null - printf "%s\n" "${KITS[@]}" - ' 2>&1 - assert_success - assert_output --partial 'OCI engine kit requested but not available at the pinned patterns ref' - refute_regex "$output" 'acq-kits/oci-engine' - # The notice prints exactly once. - local n; n=$(printf '%s\n' "$output" | grep -c 'OCI engine kit requested but not available') - assert_equal "$n" "1" - - # And the kit list itself is unchanged (support-only, zscaler first). - _build_kit_list - assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" - assert_equal "$ACQ_BUILTIN_SUPPORT_KIT_COUNT" "4" - assert_regex "${KITS[0]}" 'acq-kits/zscaler-ca-certificate' - refute_regex "$(printf '%s\n' "${KITS[@]}")" 'acq-kits/oci-engine' -} - -@test "oci-kit: notice prints EXACTLY ONCE across a 2x _build_kit_list lifecycle" { - # The dispatcher calls _build_kit_list multiple times per invocation - # (acq_resolve_backend, the run/create arm, the sbx forced-heal). Guarding on - # a single call cannot catch that spam; drive two calls in ONE process and - # assert the notice appears exactly once across both. This proves Fix A's - # process-global once-guard, not just the per-call de-dup. - run bash -c ' - ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" - ACQ_EXTRA_KITS="" - ACQ_CLI_KITS=() - ACQ_ENABLE_OCI_KIT=1 - # Force NOT-ready in-process; memoization must not affect the once-guard. - acq_oci_engine_kit_ready() { return 1; } - _build_kit_list >/dev/null - _build_kit_list >/dev/null - ' 2>&1 - assert_success - local n - n=$(printf '%s\n' "$output" | grep -c 'OCI engine kit requested but not available') - assert_equal "$n" "1" -} - -@test "oci-kit: opted-in AND ready appends the oci-engine ref after support kits" { - load_acq - ACQ_EXTRA_KITS="" - ACQ_CLI_KITS=() - ACQ_ENABLE_OCI_KIT=1 - # Stub readiness ready, mirroring 90-sbx-startup-kit.bats' agent-kit stubbing. - acq_oci_engine_kit_ready() { return 0; } - _build_kit_list - - # Appended after the four default support kits, before any agent/extras/CLI kits. - assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" - assert_equal "$ACQ_BUILTIN_SUPPORT_KIT_COUNT" "5" - assert_regex "${KITS[0]}" 'acq-kits/zscaler-ca-certificate' - assert_regex "${KITS[4]}" 'acq-kits/oci-engine' -} - -@test "oci-kit: ready + extras keeps oci before extras in the list" { - load_acq - # shellcheck disable=SC2034 # read by sourced _build_kit_list - ACQ_EXTRA_KITS="git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=deadbeef&dir=some-extra-kit" - # shellcheck disable=SC2034 # read by sourced _build_kit_list - ACQ_CLI_KITS=() - # shellcheck disable=SC2034 # read by sourced _acq_selected_builtin_kit_refs - ACQ_ENABLE_OCI_KIT=1 - acq_oci_engine_kit_ready() { return 0; } - _build_kit_list - - assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" - assert_equal "$ACQ_BUILTIN_SUPPORT_KIT_COUNT" "5" - assert_regex "${KITS[4]}" 'acq-kits/oci-engine' - assert_regex "$(printf '%s\n' "${KITS[@]}")" 'some-extra-kit' -} - -@test "oci-kit: ready support kit stays outside the agent-kit boundary" { - load_acq - local agent_kit="$STUBDIR/opencode-kit" - mkdir -p "$agent_kit" - cat >"$agent_kit/spec.yaml" <<'SPEC' -hi -SPEC - # shellcheck disable=SC2034 # read by sourced _build_kit_list - ACQ_EXTRA_KITS="" - # shellcheck disable=SC2034 # read by sourced _build_kit_list - ACQ_CLI_KITS=() - # shellcheck disable=SC2034 # read by sourced _acq_selected_builtin_kit_refs - ACQ_ENABLE_OCI_KIT=1 - acq_oci_engine_kit_ready() { return 0; } - acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } - acq_agent_builtin_kit_ready() { [ "$1" = "opencode" ]; } - _acq_agent_builtin_kit_ref() { printf '%s\n' "$agent_kit"; } - - _build_kit_list opencode - - assert_equal "$ACQ_BUILTIN_KIT_COUNT" "6" - assert_equal "$ACQ_BUILTIN_SUPPORT_KIT_COUNT" "5" - assert_regex "${KITS[4]}" 'acq-kits/oci-engine' - assert_equal "${KITS[5]}" "$agent_kit" -} - -@test "oci-kit: the emitted ref is acq-kits/oci-engine under the GSA-TTS allowlist" { - load_acq - local ref; ref=$(_acq_oci_engine_kit_ref) - assert_regex "$ref" 'agentic-coding-patterns' - assert_regex "$ref" 'acq-kits/oci-engine$' - # Under the built-in source allowlist prefix (github.com/GSA-TTS/), so no - # allowlist widening is needed. KIT_SOURCE_PREFIX is scheme-less; the ref is a - # git+https URL, so match the host/org path segment. - assert_regex "$ref" "github.com/GSA-TTS/" - assert_equal "$KIT_SOURCE_PREFIX" "github.com/GSA-TTS/" -} - -@test "oci-kit: readiness TRUE at the pinned patterns ref" { - load_acq - run acq_oci_engine_kit_ready - assert_success -} - -@test "oci-kit: readiness TRUE for a well-formed oci-engine mixin spec (offline)" { - load_acq - # Exercise the REAL acq_oci_engine_kit_ready validation branch offline: stub - # only the network fetch to hand back a temp dir we populate with a spec.yaml. - local kitdir; kitdir=$(mktemp -d) - cat >"$kitdir/spec.yaml" <<'SPEC' -schemaVersion: hybrid/v1 -kind: mixin -name: oci-engine -SPEC - eval 'kit_translate_fetch() { printf "%s\n" "'"$kitdir"'"; }' - run acq_oci_engine_kit_ready - rm -rf "$kitdir" - assert_success -} - -@test "oci-kit: readiness FALSE for a wrong-name spec (offline)" { - load_acq - local kitdir; kitdir=$(mktemp -d) - cat >"$kitdir/spec.yaml" <<'SPEC' -schemaVersion: hybrid/v1 -kind: mixin -name: not-oci-engine -SPEC - eval 'kit_translate_fetch() { printf "%s\n" "'"$kitdir"'"; }' - run acq_oci_engine_kit_ready - rm -rf "$kitdir" - assert_failure -} - -@test "oci-kit: readiness FALSE for a wrong-kind spec (offline)" { - load_acq - local kitdir; kitdir=$(mktemp -d) - cat >"$kitdir/spec.yaml" <<'SPEC' -schemaVersion: hybrid/v1 -kind: bundle -name: oci-engine -SPEC - eval 'kit_translate_fetch() { printf "%s\n" "'"$kitdir"'"; }' - run acq_oci_engine_kit_ready - rm -rf "$kitdir" - assert_failure -} - -@test "oci-kit: env normalization treats off-ish values as off" { - local v - for v in "" 0 false no off FALSE No OFF; do - run env ACQ_ENABLE_OCI_KIT="$v" bash -c ' - ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 - printf "%s" "${ACQ_ENABLE_OCI_KIT:-EMPTY}" - ' - assert_output "EMPTY" - done -} - -@test "oci-kit: env normalization treats any other value as on" { - local v - for v in 1 true yes on YES enabled anything; do - run env ACQ_ENABLE_OCI_KIT="$v" bash -c ' - ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 - printf "%s" "$ACQ_ENABLE_OCI_KIT" - ' - assert_output "1" - done -} diff --git a/test/bats/71-msb-provision-secrets.bats b/test/bats/71-msb-provision-secrets.bats index f6820d6a..dcf54bd9 100644 --- a/test/bats/71-msb-provision-secrets.bats +++ b/test/bats/71-msb-provision-secrets.bats @@ -103,27 +103,6 @@ _provision() { # NAME PRE_SNIPPET refute_regex "$log" 'docker\.io/docker/sandbox-templates:shell-docker' } -@test "msb: selected OCI kit suppresses adapter-owned podman provisioning" { - _provision ocikitbox ' - export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/ocikit-secrets" - export ACQ_ENABLE_OCI_KIT=1 - acq_oci_engine_kit_ready() { return 0; } - ' - local log; log=$(cat "$CALLS") - refute_regex "$log" '/var/lib/acq/oci-ready' - refute_regex "$log" 'podman build' -} - -@test "msb: stale OCI ref in KITS cannot suppress adapter podman provisioning" { - _provision staleocibox ' - export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/staleoci-secrets" - KITS=("$(_acq_oci_engine_kit_ref)") - ' - local log; log=$(cat "$CALLS") - assert_regex "$log" '/var/lib/acq/oci-ready' - assert_regex "$log" 'podman build' -} - @test "msb#226: a custom endpoint binds generically; usai/github still bind; no bogus flag; no leak" { _provision genbox ' export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/gen-secrets" From fdb7171f001a2be3915c4be6f906b9a166e518ee Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 3 Oct 2026 00:22:57 +0000 Subject: [PATCH 32/37] fix(acq): surface invalid provider facts instead of silently falling back Co-authored-by: OpenCode [gpt-5.5] --- acq.backends/common.sh | 49 ++++++++++++++++++-------- test/bats/30-dispatch-routing.bats | 55 ++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 7f3bde6f..7e783ce8 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -398,11 +398,11 @@ acq_provider_facts_load_from_kit() { if [ -z "$base_dir" ]; then base_dir="${ACQ_PROVIDER_FACTS_CACHE_DIR:-${ACQ_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/acq}/provider-facts}" fi - mkdir -p "$base_dir" || return 1 + mkdir -p "$base_dir" || return 3 if ! command -v kit_translate_fetch >/dev/null 2>&1; then - return 2 + return 3 fi - kitdir=$(kit_translate_fetch "$kitref" "$base_dir/usai-provider") || return 1 + kitdir=$(kit_translate_fetch "$kitref" "$base_dir/usai-provider") || return 3 facts="$kitdir/provider-facts/usai.env" acq_provider_facts_load "$facts" rc=$? @@ -426,12 +426,32 @@ acq_provider_facts_use_fallback() { } acq_provider_facts_load_from_kit_or_fallback() { - if acq_provider_facts_load_from_kit "$@" 2>/dev/null; then - : + local provider_facts_err provider_facts_rc + if provider_facts_err=$(acq_provider_facts_load_from_kit "$@" 2>&1); then + provider_facts_rc=0 else - acq_provider_facts_use_fallback - acq_debug "provider facts unavailable from kit; using transitional fallback defaults" + provider_facts_rc=$? fi + case "$provider_facts_rc" in + 0) : ;; + 1) + [ -z "$provider_facts_err" ] || printf '%s\n' "$provider_facts_err" >&2 + return 1 + ;; + 2) + acq_provider_facts_use_fallback + acq_debug "provider facts unavailable from kit; using transitional fallback defaults" + ;; + 3) + acq_provider_facts_use_fallback + echo "acq: warning: provider facts could not be fetched; using transitional fallback defaults" >&2 + acq_debug "provider facts fetch failed; using transitional fallback defaults" + ;; + *) + [ -z "$provider_facts_err" ] || printf '%s\n' "$provider_facts_err" >&2 + return "$provider_facts_rc" + ;; + esac # msb derives this adapter-local binding host when sourced, before lazy facts load. if [ "${ACQ_RESOLVED_BACKEND:-}" = "msb" ]; then # shellcheck disable=SC2034 # consumed by msb adapter functions at dispatch @@ -774,14 +794,13 @@ split_noglob() { } _acq_builtin_kit_ref() { - local name="${1:-}" known - for known in "${ACQ_KIT_NAMES[@]}"; do - if [ "$known" = "$name" ]; then - printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$name" - return 0 - fi - done - return 1 + case "${1:-}" in + zscaler-ca-certificate) printf '%s\n' "$ZSCALER_KIT" ;; + "$USAI_PROVIDER_KIT_NAME") printf '%s\n' "$USAI_KIT" ;; + agentic-coding-playbook) printf '%s\n' "$PLAYBOOK_KIT" ;; + git-ssh-sign) printf '%s\n' "$GITSSHSIGN_KIT" ;; + *) return 1 ;; + esac } _acq_builtin_support_kit_names() { diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 817d0a5a..2f5d5bc9 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -34,17 +34,67 @@ _seed_usai() { refute_output --partial 'provider facts' } -@test "dispatch: create falls back when provider facts cannot be fetched" { +@test "dispatch: create falls back silently when provider facts artifact is absent" { + local kit="$STUBDIR/provider-kit-empty" proj="$STUBDIR/provider-facts-absent" + mkdir -p "$kit" "$proj" + cat > "$kit/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: usai-provider +displayName: USAi Provider +description: test fixture +SPEC + rm -f "$STUBDIR/.msb_created" + run env ACQ_BACKEND=msb USAI_API_KEY="usai-key-stub" ACQ_TEST_USAI_KIT="$kit" \ + STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 "$ACQ" create opencode "$proj" + assert_success + refute_output --partial 'provider facts' + assert_regex "$(cat "$CALLS")" 'msb create' + assert [ -f "$STUBDIR/.msb_created" ] +} + +@test "dispatch: create warns and falls back when provider facts cannot be fetched" { local proj="$STUBDIR/provider-facts-offline"; mkdir -p "$proj" rm -f "$STUBDIR/.msb_created" run env ACQ_BACKEND=msb GIT_ALLOW_PROTOCOL=none USAI_API_KEY="usai-key-stub" \ STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 "$ACQ" create opencode "$proj" assert_success + assert_output --partial 'provider facts could not be fetched; using transitional fallback defaults' refute_output --partial 'invalid provider facts' assert_regex "$(cat "$CALLS")" 'msb create' assert [ -f "$STUBDIR/.msb_created" ] } +@test "dispatch: create fails closed when provider facts artifact is malformed" { + local kit="$STUBDIR/provider-kit-bad" proj="$STUBDIR/provider-facts-bad" + mkdir -p "$kit/provider-facts" "$proj" + cat > "$kit/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: usai-provider +displayName: USAi Provider +description: test fixture +SPEC + cat > "$kit/provider-facts/usai.env" <<'FACTS' +ACQ_PROVIDER_FACTS_SCHEMA=1 +ACQ_PROVIDER_ID=usai +ACQ_PROVIDER_HOST=api.bad.gov +ACQ_PROVIDER_BASE_URL=https://api.bad.gov/api/v1 +ACQ_PROVIDER_MODELS_URL=https://api.bad.gov/api/v1/models +ACQ_PROVIDER_KEY_ENV=BAD-KEY +ACQ_PROVIDER_KEY_MGMT_URL=https://bad.gov/keys +ACQ_PROVIDER_BIND_HOSTS=api.bad.gov +FACTS + rm -f "$STUBDIR/.msb_created" + + run env ACQ_BACKEND=msb USAI_API_KEY="usai-key-stub" ACQ_TEST_USAI_KIT="$kit" \ + STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 "$ACQ" create opencode "$proj" + assert_failure + assert_output --partial "acq: invalid provider facts artifact: $kit/provider-facts/usai.env" + refute_regex "$(cat "$CALLS")" 'msb create' + refute [ -f "$STUBDIR/.msb_created" ] +} + @test "dispatch: run existing sandbox does not fetch provider facts" { printf 'mybox\n' > "$STUBDIR/.msb_sandbox_list" printf 'mybox\n' > "$STUBDIR/.msb_running_list" @@ -59,6 +109,7 @@ _seed_usai() { run env ACQ_BACKEND=msb GIT_ALLOW_PROTOCOL=none USAI_API_KEY="usai-key-stub" \ STUB_AGENT_USER_READY=1 "$ACQ" start mybox assert_success + assert_output --partial 'provider facts could not be fetched; using transitional fallback defaults' refute_output --partial 'invalid provider facts' assert_regex "$(cat "$CALLS")" 'msb start mybox' } @@ -68,7 +119,7 @@ _seed_usai() { ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$STUBDIR/secrets" \ "$ACQ" secret import --all assert_success - refute_output --partial 'provider facts' + assert_output --partial 'provider facts could not be fetched; using transitional fallback defaults' assert_output --partial "imported 'usai'" } From c9342c0b253d2b74235053378e3ac0fc09e05881 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 4 Oct 2026 01:17:36 +0000 Subject: [PATCH 33/37] ci(markdown): switch to auditable markdownlint cli Replace markdownlint-cli2 with markdownlint-cli because the current cli2 dependency graph depends on braces 3.0.3, which has no fixed release for GHSA-vfj7-8cjw-p6xm. Keep the same markdown rules and exclusions through markdownlint config and ignore files, and install the pinned linter dependencies before the pre-commit workflow invokes the local hook. Refs: GSA-TTS/agentic-coding-quickstart#505 Co-authored-by: OpenCode [gpt-5.5] --- .github/workflows/pre-commit.yml | 2 ++ .pre-commit-config.yaml | 29 ++++++++++++++++++++++++++--- package.json | 1 + 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index c37bae9c..7cfc10ef 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -64,5 +64,7 @@ jobs: pre-commit-${{ runner.os }}- - name: Install pre-commit run: python -m pip install pre-commit + - name: Install markdown linter dependencies + run: npm ci --prefix .github/linters - name: Run pre-commit run: pre-commit run --all-files --show-diff-on-failure --color=always diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c49200d8..74b00088 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,13 +47,13 @@ repos: hooks: - id: markdownlint name: markdownlint (npm-pinned, check only) - entry: bash -c 'npm ci --prefix .github/linters --prefer-offline --no-audit --no-fund >/dev/null && npm --prefix .github/linters exec -- markdownlint --config .markdownlint.jsonc --ignore-path .markdownlintignore --dot "**/*.md"' -- + entry: .github/linters/node_modules/.bin/markdownlint --config .markdownlint.jsonc --ignore-path .markdownlintignore --dot "**/*.md" language: system types: [markdown] pass_filenames: false - id: markdownlint-fix name: markdownlint (npm-pinned, --fix) - entry: bash -c 'npm ci --prefix .github/linters --prefer-offline --no-audit --no-fund >/dev/null && npm --prefix .github/linters exec -- markdownlint --config .markdownlint.jsonc --ignore-path .markdownlintignore --dot --fix "**/*.md"' -- + entry: .github/linters/node_modules/.bin/markdownlint --config .markdownlint.jsonc --ignore-path .markdownlintignore --dot --fix "**/*.md" language: system types: [markdown] pass_filenames: false @@ -110,7 +110,7 @@ repos: entry: bash scripts/test-acq-bats language: system pass_filenames: false - files: '^(acq|acq\.backends/.*|scripts/test-acq-lib\.sh|scripts/test-acq-bats|test/bats/.*)$' + files: '^(acq|acq\.backends/.*|scripts/test-acq-lib\.sh|scripts/test-acq-bats|scripts/fix-exec-bits|scripts/verify-backends|test/bats/.*)$' # Lint the .bats files with ShellCheck (it treats .bats as a shell dialect). # The main shellcheck hook's `types: [shell]` does not reliably tag .bats, @@ -121,3 +121,26 @@ repos: entry: bash -c 'printf "%s\n" "$@" | xargs -r -n8 shellcheck --severity=warning' -- language: system files: '^test/bats/.*\.(bats|bash)$' + + # Guard the executable bit the git index records for entry points. + # + # The repo is routinely checked out onto a container/host shared mount + # (virtiofs, Docker Desktop sharing, WSL drvfs) where the host clone sets + # core.fileMode=false to suppress spurious mode churn. That makes git BLIND to + # a LOST exec bit: a 100755 -> 0644 regression with identical content produces + # an empty `git status` and an empty `git diff --summary`. Since nearly every + # editor/formatter/agent writes a temp file and renames it (new inode, ambient + # umask), entry points silently stop being executable and the next live run + # fails with rc=126 — or, worse, with a misleading downstream error. + # + # --check reports and fails; `scripts/fix-exec-bits` repairs. It compares + # against the INDEX, so sourced libraries (acq.backends/*.sh) and bats files + # stay correctly non-executable; a shebang is not the criterion. + - repo: local + hooks: + - id: fix-exec-bits + name: executable bits match the git index + entry: bash scripts/fix-exec-bits --check + language: system + pass_filenames: false + always_run: true diff --git a/package.json b/package.json index afa50897..876d9d53 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "lint:md": ".github/linters/node_modules/.bin/markdownlint --config .markdownlint.jsonc --ignore-path .markdownlintignore --dot \"**/*.md\"", "lint:secrets": "pre-commit run gitleaks --all-files", "lint": "npm run lint:md", + "fix:exec-bits": "bash scripts/fix-exec-bits", "check": "pre-commit run --all-files" }, "repository": { From b4ec8f06ed9511774a407ca8b1c61c689b7adc46 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 4 Oct 2026 05:42:45 +0000 Subject: [PATCH 34/37] fix(verify): skip the first-run config prompt in verify-backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-backends exports a hermetic XDG_CONFIG_HOME so a run cannot read the user's real acq config. _acq_config_file resolves under that, so acq never finds a config file and EVERY verify run looks like a first run to maybe_first_run_configure, which blocks on an interactive confirm. The symptom is a verifier that appears to hang: run_acq captures output by default, so the create step emits nothing and never returns while acq waits on stdin. create_time_kit_picker is a second gate with the same cause. Export ACQ_NO_PROMPT=1 alongside the other hermetic-state settings. That forces the non-interactive path for both config widgets (and for acq configure, which then reports "configuration unchanged" and makes no writes), so the configured defaults apply — which is what a verifier wants. Secret VALUE reads are gated on [ -t 0 ] rather than on the prompt helper, so seeding is unaffected. The underlying gap is on main: the first-run prompt arrived with GSA-TTS/agentic-coding-quickstart#500 and the verifier was never taught to opt out. Fixed here because this is the PR whose own ADR-0030 live verification gate is blocked by it. Co-authored-by: OpenCode [claude-opus-5] --- scripts/verify-backends | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/verify-backends b/scripts/verify-backends index d2cb1736..dcaee5d7 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -209,6 +209,17 @@ export ACQ_PROVENANCE_DIR="$VERIFY_STATE/provenance" # (no `msb modify` against running VMs); the throwaway sandboxes this script # creates still bind the key at create time, which is all the checks need. export ACQ_SECRET_NO_LIVE_REFEED=1 +# The hermetic XDG_CONFIG_HOME above means acq never finds a config file here, so +# EVERY verify run looks like a first run to `maybe_first_run_configure` — which +# then blocks on an interactive confirm and hangs the verifier forever (the +# observed symptom: a create step that never returns, with no output because +# run_acq captures it). `create_time_kit_picker` is a second such gate. Neither +# prompt has anything to ask a verifier: the configured defaults are exactly what +# we want. ACQ_NO_PROMPT=1 forces the non-interactive path for both (and for +# `acq configure`, which then reports "configuration unchanged" and makes no +# writes). This only affects the three config widgets — secret VALUE reads are +# gated on `[ -t 0 ]`, not on this, so seeding still works. +export ACQ_NO_PROMPT=1 mkdir -p "$XDG_CONFIG_HOME" "$ACQ_SECRET_STORE_DIR" "$ACQ_STATE_DIR" "$ACQ_PROVENANCE_DIR" # Fixture kit for the neutral `volumes:` vocabulary (ADR-0023): a tiny local kit From 4c5812a3f2d1a99b0f9855b60a1c13f688ced0a2 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 4 Oct 2026 06:02:59 +0000 Subject: [PATCH 35/37] fix(verify): make the sbx usai seed guard scope-aware Step 2 of seed_usai_secret decides whether to create the global sbx custom secret by grepping the whole `sbx secret ls` listing for USAI_API_KEY. That also matches a SANDBOX-SCOPED row, including one left behind for a sandbox that no longer exists. When such a row is present the global row is never created, step 3's `acq secret has -g usai` returns 1, and every sbx check skips with "verifier did not seed sandbox-scoped USAi key". Observed live against sbx v0.46.0 with a stale row: CUSTOM SECRETS SCOPE TARGETS ENV PLACEHOLDER nonexistent api.gsa.usai.gov USAI_API_KEY sbx-cs-naRspB5KyWAPCxB1 The bare grep matches that row, so the seed is skipped and the run reports 1 passed / 2 skipped instead of exercising the kit contract. Match only a row whose SCOPE column is "(global)", which is the scope step 3 asks about. A genuine pre-existing global row still no-ops as before. Co-authored-by: OpenCode [claude-opus-5] --- .pre-commit-config.yaml | 4 +- CONTRIBUTING.md | 35 +++ docs/KNOWN_FAILURE_MODES.md | 176 +++++++++++++++ scripts/fix-exec-bits | 152 +++++++++++++ scripts/test-acq-bats | 15 +- scripts/verify-backends | 171 +++++++++++--- test/bats/145-verify-capture.bats | 364 ++++++++++++++++++++++++++++++ test/bats/150-exec-bits.bats | 169 ++++++++++++++ 8 files changed, 1055 insertions(+), 31 deletions(-) create mode 100755 scripts/fix-exec-bits create mode 100644 test/bats/145-verify-capture.bats create mode 100644 test/bats/150-exec-bits.bats diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 74b00088..930abfcc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,13 +47,13 @@ repos: hooks: - id: markdownlint name: markdownlint (npm-pinned, check only) - entry: .github/linters/node_modules/.bin/markdownlint --config .markdownlint.jsonc --ignore-path .markdownlintignore --dot "**/*.md" + entry: bash -c 'npm ci --prefix .github/linters --prefer-offline --no-audit --no-fund >/dev/null && npm --prefix .github/linters exec -- markdownlint --config .markdownlint.jsonc --ignore-path .markdownlintignore --dot "**/*.md"' -- language: system types: [markdown] pass_filenames: false - id: markdownlint-fix name: markdownlint (npm-pinned, --fix) - entry: .github/linters/node_modules/.bin/markdownlint --config .markdownlint.jsonc --ignore-path .markdownlintignore --dot --fix "**/*.md" + entry: bash -c 'npm ci --prefix .github/linters --prefer-offline --no-audit --no-fund >/dev/null && npm --prefix .github/linters exec -- markdownlint --config .markdownlint.jsonc --ignore-path .markdownlintignore --dot --fix "**/*.md"' -- language: system types: [markdown] pass_filenames: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4fa80aa0..6d93f4eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,6 +107,7 @@ npm ci --prefix .github/linters | `npm run lint` | Run all linters | | `npm run lint:secrets` | Run gitleaks at the version pinned in `.pre-commit-config.yaml` (pre-commit fetches it; no separate install) | | `npm run check` | Run the full pre-commit suite (gitleaks, shellcheck, YAML/JSON validation, whitespace, markdown lint) | +| `npm run fix:exec-bits` | Restore executable bits the git index records (see below) | > [!NOTE] > `npm run check` auto-fixes some issues (markdown, whitespace, EOF) — review and stage the changes it makes. @@ -118,6 +119,40 @@ kits it applies — and their tests (permission-matrix, model-sync, per-kit repo under `integrations/isolation/acq-kits/`. Changes to provider config, rules, skills, or CA trust belong there. +### If a script suddenly says "Permission denied" + +Run: + +```bash +npm run fix:exec-bits # or: scripts/fix-exec-bits +``` + +Clones on a **container/host shared mount** (virtiofs, Docker Desktop file +sharing, WSL drvfs) do not round-trip POSIX permissions reliably, so such clones +typically set `core.fileMode = false` to stop git reporting a storm of spurious +mode changes. That is the right call, and it creates a blind spot: + +- git applies the index mode on **checkout**, so a fresh clone is correct; +- but almost every editor, formatter, and coding agent saves by writing a temp + file and renaming it. That is a **new inode** with the ambient umask (0644), + so the executable bit is gone; +- with `core.fileMode = false`, git is **blind** to that. A 100755 → 0644 + regression with identical content yields an empty `git status` *and* an empty + `git diff --summary`. + +The symptom is an entry point that stops working with no visible cause — +`./scripts/verify-backends` exiting 126, or the vendored bats failing several +execs deep (`bin/bats` re-execs `libexec/bats-core/bats`, which execs +`bats-exec-suite` and a formatter, so a stripped bit surfaces as a confusing +downstream error rather than an obvious permission one). + +`scripts/fix-exec-bits` compares every tracked file against its **index** mode +and restores the bit. The index is the criterion, not the presence of a shebang: +`acq.backends/*.sh` are *sourced* and `test/bats/*.bats` are read *by bats*, so +both are correctly non-executable. It covers initialized submodules too, and +`--check` makes it report-only (that form runs as a pre-commit hook, and +`scripts/verify-backends` self-heals at startup). + There is one offline unit suite (stubbed `sbx`/`msb`/`opencode`, no Docker or network), built on [bats-core](https://github.com/bats-core/bats-core) (ADR-0025): diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index fa426239..3d34b183 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -2532,6 +2532,182 @@ accepts `0.7.3` or newer. Remove stale copies or adjust `PATH` so the intended --- +## 44. `verify-backends` Stalls Right After "provision complete" (No `DONE` Trace Line) + +**Status:** Fixed — `scripts/verify-backends` captures to a file instead of a pipe. + +### Symptoms + +A live `./scripts/verify-backends --only msb -x` run streams normal progress, +reaches the end of provisioning, and then never returns: + +```text +acq[debug]: msb provision: all kits applied; provision complete (shell-ws-xxxxxx) +acq[debug]: msb: ssh-agent bridge started at /home/agent/.acq/ssh-agent.sock ... +acq[debug]: provenance: recorded msb/shell-ws-xxxxxx applied_ref=... +acq[debug]: cli-kits: recorded msb/shell-ws-xxxxxx (kit= ) +``` + +There is no `[trace ...] DONE create: rc=0` line, no further check output, and no +error. The sandbox itself is healthy — `msb list` shows it running, and +`acq exec -- true` succeeds from another terminal. + +### Root Cause + +The verifier captured each step with command substitution: + +```sh +LAST_OUT=$("$ACQ" "$@" 2>&1 | tee /dev/stderr) +``` + +Command substitution reads the capture pipe until **EOF**, which arrives only +when *every* writer closes it — not when `acq` exits. Two things in `acq` hold a +descriptor on that pipe past exit: + +- `acq.backends/secret-store.sh` dups the process's stderr to fd 9 (the warning + channel used so migration warnings survive `2>/dev/null` read paths). fd 9 is + inherited by children. +- the msb adapter launches **daemonized** guest-side helpers, notably the + ssh-agent `socat` bridge started at the end of provisioning. + +So `acq` finished successfully, while the verifier blocked on a pipe that a +long-lived descendant kept open. The stall therefore appeared at the *last* +thing provisioning logs, making a completed create look like a hung one. + +Because the hang is in the harness, not the backend, it masqueraded as a backend +failure and cost several live runs. Writing to a **file** removes the EOF +dependency entirely: the wait ends when `acq` exits. + +### Fix + +`run_acq` redirects `acq` to a temp file in the hermetic `VERIFY_STATE` dir and +reads the file after `acq` returns. Verbose mode gets its live view from a +`tail -f` on that file rather than from `tee` in the capture pipeline. `acq` stays +in the **foreground**, so pipelines that feed it a secret on stdin +(`printf key | run_acq ... secret set -g usai`) are unchanged. + +### Prevention + +`test/bats/145-verify-capture.bats` extracts the real `run_acq` out of +`scripts/verify-backends` and drives it against a stand-in that daemonizes a +child and exits 0 — the exact shape that deadlocks a pipe capture. Each case runs +under `timeout`, so reintroducing a pipe-EOF dependency fails as a test timeout +instead of hanging a human's live verification. The guard was confirmed to fail +against the old `$( ... | tee /dev/stderr )` implementation before being kept +against the new one. + +### Also Fixed in the Same Pass + +- **`markdownlint` never linted `.github/**/*.md`.** markdownlint's globber skips + dot-directories unless `--dot` is passed, so `.github/pull_request_template.md` + was silently excluded from CI, `npm run lint:md`, and the pre-commit hook. All + three now pass `--dot`. +- **The "did it lint anything?" assertion counted a different set than the + linter.** It hand-translated `.markdownlintignore` into `git ls-files :!` + pathspecs, but those matchers differ: a bare `test/vendor` pathspec excludes + only that exact path, not the tree under it, so the count ran high. The + assertion now evaluates the ignore file with git's own gitignore engine + (`git ls-files -c -i --exclude-from=.markdownlintignore`), which agrees with + markdownlint's semantics. + +### Second Instance of the Same Bug Class + +The grammar-acceptance preflight (`verify_sbx_grammar_acceptance`) had its own +`out=$(bats ... | tee /dev/stderr)` capture — the identical idiom, in a second +place a `run_acq`-only fix would not have reached. Both now go through one shared +`_run_captured` helper, and a test asserts the idiom appears nowhere in the +script, so a third instance cannot be added quietly. + +### The Lost-Executable-Bit Class (and the systemic fix) + +**Status:** Fixed systemically — `scripts/fix-exec-bits`, a pre-commit hook, and +self-healing in `scripts/verify-backends`. + +Three separate symptoms in this one investigation were the same root cause: + +```text +zsh: permission denied: ./scripts/verify-backends +``` + +```text +./scripts/verify-backends: line 259: .../acq: Permission denied # rc=126 +``` + +```text + FAIL sbx: real sbx rejected acq's translated kit grammar + | env: .../libexec/bats-core/bats: Permission denied +``` + +That last one is the dangerous presentation: a **file-permission artifact +reported as kit-grammar incompatibility**. + +#### Why it keeps happening + +A clone on a container/host shared mount (virtiofs, Docker Desktop file sharing, +WSL drvfs) cannot round-trip POSIX permissions, so the clone sets +`core.fileMode = false` to suppress a storm of spurious mode churn. That is +correct, and it opens a blind spot: + +1. git applies the index mode on **checkout**, so a fresh clone is right; +2. nearly every editor, formatter, and coding agent saves by writing a temp file + and renaming it — a **new inode** with the ambient umask (0644), so the exec + bit is dropped; +3. with `core.fileMode = false` git is **blind** to it. Measured: a 100755 → + 0644 drift with byte-identical content yields an empty `git status` *and* an + empty `git diff --summary`. With `core.fileMode = true` the same drift reports + `mode change 100755 => 100644`. + +So the regression is invisible to every normal check and only surfaces as a +confusing runtime failure, often far from the real cause. + +#### Why `bash