diff --git a/integrations/isolation/acq-kits/README.md b/integrations/isolation/acq-kits/README.md index 36ecaf3a..b717d662 100644 --- a/integrations/isolation/acq-kits/README.md +++ b/integrations/isolation/acq-kits/README.md @@ -37,14 +37,18 @@ The [`kits.yaml`](kits.yaml) registry is the human-readable parity summary ## Templates -Copy-and-rename starting points under `examples/`. They are **not kits `acq` -applies**: `hybrid/v1` has no parameters, so a template carries one live, -harmless value per extension point for you to replace, plus a `scripts/verify` -that asserts how the kit composes with the global layer. CI validates them like -kits (schema, `files[]` sources, env names, README) but they are exempt from — -and must stay out of — the `kits.yaml` registry. See +Copy-and-rename starting points under [`examples/`](examples/). They are **not +kits `acq` applies**: `hybrid/v1` has no parameters, so a template carries one +live, harmless value per extension point for you to replace, plus a +`scripts/verify` that asserts how the kit composes with the global layer. CI +validates them like kits (schema, `files[]` sources, env names, README) but +they are exempt from — and must stay out of — the `kits.yaml` registry. See [`../docs/decisions/0005-kit-templates.md`](../docs/decisions/0005-kit-templates.md). +| Template | Purpose | +|----------|---------| +| [`examples/team-kit/`](examples/team-kit/) | A team's scope-layer mixin: conventions and agent settings through the OpenCode config tier, applied via `ACQ_EXTRA_KITS` after the global kits. Pattern and composition rules: [`../docs/scope-layers.md`](../docs/scope-layers.md). | + ## What is a hybrid/v1 kit? A kit is a directory with a `spec.yaml` (`schemaVersion: "hybrid/v1"`). A diff --git a/integrations/isolation/acq-kits/examples/team-kit/README.md b/integrations/isolation/acq-kits/examples/team-kit/README.md new file mode 100644 index 00000000..29bb09c7 --- /dev/null +++ b/integrations/isolation/acq-kits/examples/team-kit/README.md @@ -0,0 +1,158 @@ +# team-kit (template for a team's acq mixin kit, `hybrid/v1`) + +A **template, not a deployable kit**: the starting point for a team's own +scope-layer mixin, the layer between the global kits `acq` applies for +everyone and each repo's own agent config. `acq` never applies this directory; +you copy it into a repo your team owns, rename it, and grow it. The pattern, +the per-field composition rules, the authoring gotchas, the backend +differences, and the parser rules to check against your `acq` are in +`integrations/isolation/docs/scope-layers.md` in +[agentic-coding-patterns](https://github.com/GSA-TTS/agentic-coding-patterns) +(the pattern doc); the placement decision is ADR 0005 beside it, in +`docs/decisions/`. + +> **Tested on msb only.** This template and its `scripts/verify` have not been +> tested on sbx and may not work there. Run `scripts/verify` on sbx before +> relying on it. + +```text +global zscaler-ca-certificate · usai-provider · agentic-coding-playbook · git-ssh-sign (acq, everyone) +agent the agent's own kit (opencode for `acq run opencode`) (acq, per agent) +team your copy of this kit (ACQ_EXTRA_KITS) +personal a teammate's own kit (ACQ_EXTRA_KITS, later) +repo the repo's AGENTS.md / .opencode/ (read by the agent) +``` + +## Use + +1. Copy this directory into your team's repo and **rename both the directory + and `name:` in `spec.yaml`** (kebab-case; keep them equal). Update + `displayName:` and `description:`. +2. Replace every value marked `REPLACE` in `spec.yaml`, and rewrite + `files/home/team-config/team-conventions.md` for your team. Each extension + point ships one live, harmless value so the schema and `scripts/verify` + exercise it; leave a value in place only if you actually want it. +3. Apply it after the global kits. Each teammate sources the kit's `env.sh` + from their shell rc, then starts sandboxes as usual: + + ```bash + # shell rc (bash or zsh) + export TEAM_KIT=/path/to/your-team-repo/acq-kits/your-team + . "$TEAM_KIT/env.sh" + ``` + + ```bash + acq run opencode /path/to/project + ``` + + `env.sh` puts the team kit first in `ACQ_EXTRA_KITS`, and sets `ACQ_IMAGE` + if you enable it. It is host-side: keep it out of `files[]`. How it works, + and how it interacts with `acq configure`: "Host-side defaults" in the + pattern doc. + +4. Validate on every change, and run the live check when the spec or a payload + changes: + + ```bash + acq kit validate /path/to/your-team-kit + /path/to/your-team-kit/scripts/verify + ``` + +Teammates stack their own kits *after* yours by listing them in +`PERSONAL_KITS` before sourcing `env.sh` (a catalog kit's `git+https` ref +goes here too): + +```bash +PERSONAL_KITS="/path/to/personal-kit" +``` + +Later kits win wherever a field is last-wins (`environment`, `files[]` by +path). Say in this README which of your settings a personal kit may override. + +## What it carries (as shipped) + +| Extension point | Live value | Replace with | +|-----------------|------------|--------------| +| `caps.network.allow` | `example.org` (an IANA-reserved example domain the global layer does not allow, so `scripts/verify` can observe it) | your team's VCS, package-mirror, and MCP hosts | +| `files[]` | `team-config/opencode.jsonc`, `team-config/team-conventions.md`, a non-empty `.agents/skills/.gitkeep` placeholder (msb rejects empty files) | your conventions, settings, and skills (one `files[]` record per file) | +| `commands[]` | `git config --global push.autoSetupRemote true` at startup | your idempotent per-sandbox setup steps | +| `environment` | `OPENCODE_CONFIG` → the team `opencode.jsonc` | more non-secret settings; never a credential | + +The OpenCode wiring gives the settings tier global → team → repo: the global +`usai-provider` kit merges into OpenCode's global config path and leaves +`OPENCODE_CONFIG` free for this layer. The rest of the kit is agent-agnostic. +The conventions file reaches the agent through OpenCode's `instructions` +field, which only OpenCode 1.x loads; `scripts/verify` fails when sandbox +shells run 2.x (see "Check against your acq" in the pattern doc). + +## What it does not carry, on purpose + +- **Tools and toolchains.** Bake them into the image and point `ACQ_IMAGE` at + it; the published devenv image in this repo is the precedent. +- **Secrets.** `acq secret set -g --host --env ` on the + host; the sandbox only ever sees a placeholder. +- **Whole shared files** (`~/.bashrc`, `~/.gitconfig`) or **binary files**. +- **Anything the global layer already owns.** + +## Writing a skill + +A skill is a directory under `files/home/.agents/skills//` holding a +`SKILL.md` with two frontmatter fields, `name` and `description`, followed by +the procedure. The agent picks a skill by matching its description to the +request. + +- **Pick a name the global playbook kit does not use.** The playbook symlinks + its own skills into the same directory at every start, so a same-named team + skill collides with one of them (use `team-code-review`, not + `code-review`). +- **Put the trigger phrases in the description**, worded the way a teammate + would ask, plus one sentence on what the skill produces. +- **Write only the delta:** team conventions, sandbox constraints, the output + contract. Generic advice on how to do the task is padding. +- **Say what the agent must never do on its own** (post, approve, push). The + team `opencode.jsonc` can gate the commands; the skill is where the agent + learns the rule. +- **Keep it small.** Every session loads the description, so keep it under + 1024 characters; keep the body under 500 lines and move reference material + into sibling files the body points to. +- **Register every file** in the skill directory as a `files[]` record. +- **Check that the description wins.** Several playbook skills cover nearby + tasks. In a sandbox, ask for the task three ways, one without naming the + skill, and confirm yours loads. Repeat when the playbook pin changes. + +## Verifying + +```bash +./scripts/verify # needs acq on PATH (or ACQ_DIR) and a sandbox-capable host +KEEP=1 ./scripts/verify # keep the sandbox and work dir for inspection +``` + +It validates the spec, then creates a throwaway sandbox through `acq`, which +applies the pinned built-in bundle plus, via `ACQ_EXTRA_KITS`, a generated +**lower fixture kit** and then this kit. It asserts that the global layer is +intact, that every live value above landed, and that the composition rules from +the pattern doc hold against the fixture: `environment` last-wins, `files[]` +last-wins by path, `commands[]` append in order, `caps.network.allow` union. +Run it on each backend your team uses; select the backend with `ACQ_BACKEND`. +The values at the top of `scripts/verify` (`TEAM_CONFIG`, `TEAM_CONVENTIONS`, +`TEAM_EGRESS_HOST`) mirror `spec.yaml`: update them whenever you change the +matching path or host. If you replace the git startup step, also update the +startup poll and the `commands[]` check, which both read its git key. Extend its clearly marked **TEAM-SPECIFIC** section as your kit +grows, one assertion per mechanism you add. + +## Backend parity + +The kit uses no backend-specific features: it is declarative only (`caps`, +`files[]`, `commands[]`, `environment`), with no `backend_shortcuts` or +`backend_extras`, and `scripts/verify` uses only `acq` verbs. The backends +still behave differently (who runs a command with no `user:`, `agentContext`, +how kits re-apply, unlisted files, SSH egress, secret rotation); "Backend +differences" in the pattern doc is the list. Keep this section in your copy +and add anything backend-specific your kit introduces. + +## Troubleshooting + +See [`TROUBLESHOOTING.md`](TROUBLESHOOTING.md). Record your team's non-obvious +choices as ADRs next to the kit, in [`docs/decisions/`](docs/decisions/). The +sample there records the template's own choice of `OPENCODE_CONFIG`; keep it, +rewrite it, or replace it with your first real decision. diff --git a/integrations/isolation/acq-kits/examples/team-kit/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/examples/team-kit/TROUBLESHOOTING.md new file mode 100644 index 00000000..c9501830 --- /dev/null +++ b/integrations/isolation/acq-kits/examples/team-kit/TROUBLESHOOTING.md @@ -0,0 +1,158 @@ +# Troubleshooting — team kit (template) + +Failure modes any team's copy inherits. Keep this file in your copy and grow it +with your team's own entries. Every command is an `acq` command. The rules +behind these entries live in the pattern doc (`scope-layers.md`, see the +README); each entry names the section. + +## Kit content missing (`OPENCODE_CONFIG` empty, files absent) + +Almost always: the shell that *created* the sandbox did not source `env.sh` +(or `TEAM_KIT` pointed at the wrong path), so only the global kits applied. +Check: + +```bash +acq exec -- printenv OPENCODE_CONFIG # empty => the kit did not apply +echo "$ACQ_EXTRA_KITS" # on the host: the team kit should come first +``` + +Fix the rc, then recreate: + +```bash +acq rm && acq run opencode /path/to/project +``` + +Re-running `acq run` alone is not enough: `acq` records a sandbox's extra kits +at creation and reuses that record on reattach. See "Recreating a sandbox" in +the pattern doc. + +## Create fails: the backend rejects the kit / unsupported `schemaVersion` + +The kit is neutral `hybrid/v1`, which no backend parses natively; `acq` +translates it. If the raw spec reaches the backend (parse errors naming your +spec's fields), the `agentic-coding-quickstart` checkout predates acq's +kit-translate layer. Update it with `git pull`. A bare backend command that +bypasses `acq` fails for the same reason. + +## Create fails fetching a global kit over SSH + +The create output shows a `git@github.com:` URL and `Permission denied +(publickey)` for one of the pinned kits. A host `url.*.insteadOf` rewrite (for +example `url.git@github.com:.insteadOf https://github.com/`) sends acq's +anonymous kit fetch over SSH, which fails whenever your SSH key is not +available. Check for one: + +```bash +git config --get-regexp 'url\..*insteadOf' +``` + +Make the key available (load it into your agent) and retry the create. + +## A needed host is blocked + +On msb, a blocked fetch fails at acq's egress proxy and `acq` prints a diagnosis +naming the host. Add it to `caps.network.allow` and recreate the sandbox: +egress is fixed at creation on every backend. Under org governance, org network +rules override kit rules; a host that stays blocked despite the kit entry must +be allowed at the org level. + +## A team setting has the wrong value, or is missing + +A kit applied *after* yours (a personal kit) can shadow a team value +silently (see the composition table in the pattern doc). Check what the +sandbox actually has, then look for the same variable name or file path in the +kits listed after yours: + +```bash +acq exec -- printenv OPENCODE_CONFIG +acq kit list # the pinned kits plus THIS shell's ACQ_EXTRA_KITS +``` + +`acq kit list` shows neither `--kit` refs nor the extras recorded for an +existing sandbox, so compare against the `ACQ_EXTRA_KITS` the sandbox was +created with. Move the override, or document it as an allowed personal +override. + +## The agent ignores the team conventions + +`opencode debug config` lists `team-conventions.md` under `instructions`, but +the agent does not follow it or cannot quote it. Check which OpenCode the +sandbox runs: + +```bash +acq exec -- opencode --version +``` + +OpenCode 2.x accepts `instructions` but never loads the files it lists. See +"Check against your acq" in the pattern doc. + +## A daemon still uses the old team settings + +A background process that a startup step launched (a web UI serving agent +sessions, for example) read its configuration when it started, so kit changes +that reached the sandbox since then do not reach it. Restart the sandbox: + +```bash +acq restart +``` + +## A startup command did nothing, and validate said OK + +`acq kit validate` passes several spec shapes that run nothing or run the +wrong thing: a comment after `phase:` or `user:` (the command is skipped with +only a warning in the create output), a comment after an argv item (it becomes +part of the argument), and flow-style argv (`command: [a, b]`, parsed as +empty). See "Check against your acq" in the pattern doc for the exact rules. + +## Files landed, but the env var and startup effects are missing (msb) + +One of the kit's `files[]` drops failed, which stops the rest of that kit's +apply on msb. The create output names the file ("could not place kit file at +..."); the usual cause is an empty payload. See "Check against your acq" in the +pattern doc. + +## A shipped file is present on sbx but missing on msb + +The file is under `files/` but has no `files[]` record in `spec.yaml`. Add the +record with the in-guest absolute path. See "Backend differences" in the +pattern doc. + +## git push/fetch hangs or is refused on an SSH remote (msb) + +The remote is `git@host:…` or `ssh://git@host/…`, and msb does not carry SSH +to allowed hosts (see "Backend differences"). Adding `host:22` to the allow +list does not help. A `--clone` workspace inherits the host checkout's SSH +origin, so this is the default case for a team VCS and for GitHub. Check what +git will actually contact: + +```bash +acq exec -- git -C /path/in/guest/to/repo ls-remote --get-url origin +``` + +Fix: add the startup step from "Git over HTTPS" in the pattern doc. + +## OpenCode: `The socket connection was closed unexpectedly` (msb) + +Every request in one session fails the same way, on any model, and reopening +the sandbox does not help, while `curl` to the model endpoint from the same +sandbox succeeds. The session transcript contains a secret placeholder, +usually from a command that printed the environment (`env`, `printenv`), and +msb's proxy fails closed on it (see "Backend differences"). + +Fix: start a new session; the affected one cannot be sent as-is. Prevent it by +keeping "never print the environment" in the team conventions file. + +## Rotating a team token breaks existing sandboxes + +The new secret got a new placeholder; existing sandboxes still hold the old +one. Rotate with the same placeholder: see "Backend differences" in the +pattern doc for the command on each backend. + +## Inspecting startup state + +Startup-command output only prints during `acq run` or `acq create`. Inspect +later with a probe, and recreate to watch the output again: + +```bash +acq exec -- sh -c 'git config --global push.autoSetupRemote' +``` diff --git a/integrations/isolation/acq-kits/examples/team-kit/docs/decisions/0001-team-settings-through-opencode-config.md b/integrations/isolation/acq-kits/examples/team-kit/docs/decisions/0001-team-settings-through-opencode-config.md new file mode 100644 index 00000000..b409430a --- /dev/null +++ b/integrations/isolation/acq-kits/examples/team-kit/docs/decisions/0001-team-settings-through-opencode-config.md @@ -0,0 +1,31 @@ +# Decision: Deliver team agent settings through `OPENCODE_CONFIG` + +**Status:** accepted (sample: keep, rewrite, or replace it in your copy) + +## Context + +The team needs settings every teammate's agent shares: team instructions, +permission rules for team tools. OpenCode reads config from three places, in +order: its global config path, the file `OPENCODE_CONFIG` names, and the +repo's own config. Later sources override earlier ones key by key. + +The global `usai-provider` kit merges its provider settings into the global +config path at every start, and leaves `OPENCODE_CONFIG` unset. Writing the +team's settings to the global path as well would mean merging with that kit, +or overwriting it. + +## Decision + +Ship the team's settings as `files/home/team-config/opencode.jsonc` and set +`OPENCODE_CONFIG` to its in-guest path in `environment`. The team kit owns +that variable; a personal kit does not override it. + +## Consequences + +- Settings tier global → team → repo, with no merge script in this kit. +- `OPENCODE_CONFIG` is single-valued and last-wins across kits, so any later + kit that sets it hides the team settings without a warning. +- The conventions reach the agent through OpenCode's `instructions` field, + which OpenCode 2.x ignores. On an image whose sandbox shells run 2.x, the + team settings still apply but the conventions do not load; `scripts/verify` + fails there. diff --git a/integrations/isolation/acq-kits/examples/team-kit/env.sh b/integrations/isolation/acq-kits/examples/team-kit/env.sh new file mode 100644 index 00000000..bca3cd14 --- /dev/null +++ b/integrations/isolation/acq-kits/examples/team-kit/env.sh @@ -0,0 +1,48 @@ +# env.sh — host-side shell defaults for this team kit (POSIX sh, bash, zsh). +# +# Not a kit payload: acq never reads it, so it has no files[] record. Each +# teammate sources it from their shell rc. Paths must not contain whitespace +# (ACQ_EXTRA_KITS is whitespace-separated): +# +# export TEAM_KIT=/path/to/your-team-repo/acq-kits/your-team +# PERSONAL_KITS="/path/to/personal-kit" # optional, in order +# . "$TEAM_KIT/env.sh" +# +# It rebuilds ACQ_EXTRA_KITS as: the team kit, then any kits already listed, +# then PERSONAL_KITS (later kits win on overlap), dropping repeats, so +# re-sourcing the rc in a nested shell adds nothing twice. Defaults change for +# everyone with `git pull`. See "Host-side defaults" in the pattern doc. + +if [ -n "${TEAM_KIT:-}" ]; then + _team_env_kits= + # Unquoted $(...) splits on any whitespace in sh, bash, and zsh alike, but + # it can also glob-expand an entry against the current directory, so turn + # globbing off for the loop and restore the caller's setting afterwards. + _team_env_noglob= + case $- in + *f*) ;; + *) + if [ -n "${ZSH_VERSION:-}" ] && eval '[[ -o noglob ]]'; then :; else + _team_env_noglob=1 + set -o noglob + fi + ;; + esac + for _team_env_k in "$TEAM_KIT" $(printf '%s\n' "${ACQ_EXTRA_KITS:-}") \ + $(printf '%s\n' "${PERSONAL_KITS:-}"); do + case " $_team_env_kits " in + *" $_team_env_k "*) ;; + *) _team_env_kits="${_team_env_kits:+$_team_env_kits }$_team_env_k" ;; + esac + done + [ -z "$_team_env_noglob" ] || set +o noglob + ACQ_EXTRA_KITS=$_team_env_kits + export ACQ_EXTRA_KITS + unset _team_env_kits _team_env_k _team_env_noglob + # REPLACE or delete: the team's devenv image, if it publishes one. Applies + # only when the teammate has not set ACQ_IMAGE. + # : "${ACQ_IMAGE:=registry.example.gov/team/devenv:latest}" + # export ACQ_IMAGE +else + echo "env.sh: set TEAM_KIT to the team kit's directory first" >&2 +fi diff --git a/integrations/isolation/acq-kits/examples/team-kit/files/home/.agents/skills/.gitkeep b/integrations/isolation/acq-kits/examples/team-kit/files/home/.agents/skills/.gitkeep new file mode 100644 index 00000000..59eb5c0d --- /dev/null +++ b/integrations/isolation/acq-kits/examples/team-kit/files/home/.agents/skills/.gitkeep @@ -0,0 +1,10 @@ +# Team skills: one directory per skill (/SKILL.md), each file +# listed in spec.yaml files[]. This placeholder stands in for them. Once a real +# skill is added, delete this file AND its files[] record in spec.yaml. +# +# Name team skills so they cannot collide with the global playbook kit's: it +# symlinks its own skills into this same directory at every start +# (`ln -sfn`), so a same-named team directory shadows one of them and collects +# a stray nested symlink. See README.md "Writing a skill". +# +# Deliberately not empty: see "Check against your acq" in the pattern doc. diff --git a/integrations/isolation/acq-kits/examples/team-kit/files/home/team-config/opencode.jsonc b/integrations/isolation/acq-kits/examples/team-kit/files/home/team-config/opencode.jsonc new file mode 100644 index 00000000..cf807e08 --- /dev/null +++ b/integrations/isolation/acq-kits/examples/team-kit/files/home/team-config/opencode.jsonc @@ -0,0 +1,87 @@ +// Team OpenCode settings — loaded via OPENCODE_CONFIG (set in spec.yaml). +// This is the TEAM tier: it overrides the global config (where the USAi +// provider lives) for the keys it sets, and a repo's own opencode.jsonc still +// wins over it. Prefer keys the global config does not set; do not redeclare +// the provider. +// +// `instructions` below needs OpenCode 1.x: OpenCode 2.x accepts the field but +// never loads the files it lists ("Check against your acq" in the pattern +// doc). scripts/verify fails when 2.x is what sandbox sessions run. +// +// Deliberately NOT set here: +// - model / small_model. The global provider kit owns them and updates its +// defaults as the provider's model catalog changes; a team pin overrides +// that and can outlive the model it names. +// - Any "allow" rule that overrides a global ask or deny. OpenCode uses the +// LAST matching rule. On 1.x a rule with the same pattern as a global one +// replaces it in place, but a NEW pattern goes after every global rule, +// so `"git *": "allow"` here would beat the global `"git push *": "ask"`. +// - theme / keybinds / tui. OpenCode drops those top-level keys from +// opencode.json(c) on load. TUI settings go in their own tui.jsonc, with +// OPENCODE_TUI_CONFIG pointing at it. +// +// Gating a team CLI (for example a VCS CLI): keep the global kit's +// default-allow posture. Reads fall through to its "*": "allow"; add `ask` +// for anything that posts or opens a merge request, and `deny` for merge and +// approve, pipeline runs, CI variables, destructive admin commands, and the +// `api` subcommand's write flags and GraphQL. Any write verb you do not list +// is allowed: review close, edit/update, delete, label, assign, release, and +// CI retry/cancel for your CLI, and add a rule for each one you want gated. +// +// These rules are a guardrail, not a boundary: `env vcs ...`, a full path to +// the binary, or a direct HTTP call to the VCS API all get past them. The real +// control is the scope of the token you set with `acq secret set`; give the +// sandbox a least-privilege token. +// +// Rules match the WHOLE command text, inline bodies and heredocs included, +// and `*` spans spaces. So: +// - write each rule from the exact command prefix ("vcs mr merge *"), never +// a loose pattern ("vcs*pipe* delete*" also matches an issue body that +// mentions both words, and blocks a legitimate `vcs issue create`); +// - pair each rule with its bare form ("vcs pipeline run" and +// "vcs pipeline run *"), or the command with no arguments slips past; +// - anchor the api flags on the space before them, with one rule for the +// first position and one for any later position ("vcs api -f*" and +// "vcs api * -f*"); a bare "*-f*" would also deny a read of +// `projects/team%2Flogin-frontend`. An explicit `--method GET` is denied +// too: leave the method off for reads; +// - tell the agent (in team-conventions.md) to pass post bodies from a file +// (--body-file, or -d "$(cat /tmp/body.md)"), so body text never reaches +// the matcher. +// Try each rule against a real command before relying on it. Sketch, not a +// full list: +// "permission": { "bash": { +// "vcs mr create": "ask", +// "vcs mr create *": "ask", +// "vcs mr note *": "ask", +// "vcs issue create": "ask", +// "vcs issue create *": "ask", +// "vcs issue note *": "ask", +// "vcs mr merge": "deny", +// "vcs mr merge *": "deny", +// "vcs mr approve": "deny", +// "vcs mr approve *": "deny", +// "vcs pipeline run": "deny", +// "vcs pipeline run *": "deny", +// "vcs variable *": "deny", +// "vcs repo delete *": "deny", +// "vcs api *graphql*": "deny", +// "vcs api -X*": "deny", +// "vcs api * -X*": "deny", +// "vcs api --method*": "deny", +// "vcs api * --method*": "deny", +// "vcs api -F*": "deny", +// "vcs api * -F*": "deny", +// "vcs api --field*": "deny", +// "vcs api * --field*": "deny", +// "vcs api -f*": "deny", +// "vcs api * -f*": "deny", +// "vcs api --raw-field*": "deny", +// "vcs api * --raw-field*": "deny", +// "vcs api --input*": "deny", +// "vcs api * --input*": "deny" +// } } +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["/home/agent/team-config/team-conventions.md"] +} diff --git a/integrations/isolation/acq-kits/examples/team-kit/files/home/team-config/team-conventions.md b/integrations/isolation/acq-kits/examples/team-kit/files/home/team-config/team-conventions.md new file mode 100644 index 00000000..e7f285d8 --- /dev/null +++ b/integrations/isolation/acq-kits/examples/team-kit/files/home/team-config/team-conventions.md @@ -0,0 +1,21 @@ +# Team conventions (template) + +Replace this file with your team's conventions. It is loaded into every agent +session in every sandbox the kit applies to, so keep it short and operational: +the agent pays a context cost for every line. + +Good content, in rough priority order: + +- The team's repos and how they relate (which one is the source of truth). +- Tooling rules the agent cannot infer ("use `glab`, not `gh`, for our GitLab + repos"). +- Hard boundaries ("never X"; what needs a human, such as merging). +- Known traps in your environment, each with the safe alternative. +- Never print the environment (`env`, `printenv`): it contains secret + placeholders that must not enter a transcript. +- Pass the body of anything you post (issue, merge request, comment) from a + file (`--body-file`), not inline, so permission rules see the command, not + the text. + +Keep repo-specific instructions in each repo's own `AGENTS.md`. This file is +for what is true across all of the team's sandboxes. diff --git a/integrations/isolation/acq-kits/examples/team-kit/scripts/verify b/integrations/isolation/acq-kits/examples/team-kit/scripts/verify new file mode 100755 index 00000000..aa65266f --- /dev/null +++ b/integrations/isolation/acq-kits/examples/team-kit/scripts/verify @@ -0,0 +1,349 @@ +#!/usr/bin/env bash +# +# verify — host-side, end-to-end check for the team-kit TEMPLATE, and for any +# copy of it. Backend-neutral: only `acq` verbs (kit validate, create, exec, +# rm), so it runs the same on sbx and msb. +# +# It creates a throwaway sandbox through `acq`, which applies its pinned +# built-in bundle (zscaler-ca-certificate, usai-provider, +# agentic-coding-playbook, git-ssh-sign) and then, via ACQ_EXTRA_KITS, a +# generated LOWER fixture kit followed by this kit. It then asserts: +# 1. the global layer is intact (USAi merged into OpenCode's global config); +# 2. every live extension-point value in spec.yaml landed, and the OpenCode +# that sandbox shells run is 1.x (2.x ignores `instructions`); +# 3. the lower fixture itself was applied (its own env var, file, and +# startup command landed), so the next checks are not vacuous; +# 4. the composition-table rows it can observe (environment, files[], +# commands[], caps.network.allow; see the pattern doc) hold against the +# lower fixture. +# Extend the TEAM-SPECIFIC section as your kit grows: one assertion per +# mechanism the kit adds. +# +# The sandbox is named "-verify-" and removed at the end (and on +# interrupt). Nothing here prints secret values. +# +# Usage (from anywhere): +# path/to/team-kit/scripts/verify +# KEEP=1 …/scripts/verify # keep the sandbox + work dir +# ACQ_DIR=/path/to/quickstart …/verify # acq not on PATH: use this checkout +# SKIP_NETWORK=1 …/verify # skip the egress probes (offline host) +# +# set -e is omitted on purpose: accumulate pass/fail rather than abort. + +set -uo pipefail + +KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +KIT_NAME="$(basename "$KIT_DIR")" +SBX_NAME="${KIT_NAME}-verify-$$" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/${KIT_NAME}-verify.XXXXXX")" +WORKSPACE="$WORK/workspace" +LOWER_KIT="$WORK/lower-kit" +CREATE_LOG="$WORK/acq-create.log" +KEEP="${KEEP:-0}" +SKIP_NETWORK="${SKIP_NETWORK:-0}" + +# acq: on PATH, or from an agentic-coding-quickstart checkout named by ACQ_DIR. +if [ -n "${ACQ_DIR:-}" ]; then ACQ="$ACQ_DIR/acq"; else ACQ="$(command -v acq || true)"; fi + +# Values this template ships (mirror spec.yaml; update both when you replace +# one). The lower fixture sets the SAME keys to different values so the +# assertions distinguish "landed" from "landed because nothing competed". +TEAM_CONFIG=/home/agent/team-config/opencode.jsonc +TEAM_CONVENTIONS=/home/agent/team-config/team-conventions.md +TEAM_EGRESS_HOST=example.org +LOWER_CONFIG=/home/agent/lower-fixture/opencode.jsonc +LOWER_MARKER_FILE=/home/agent/lower-fixture/applied +LOWER_MARKER_CMD=/home/agent/lower-fixture/startup-ran +LOWER_EGRESS_HOST=example.com +# Allowed by no kit: the egress probe's negative control. +UNLISTED_EGRESS_HOST=example.net + +pass=0 +fail=0 +skipped=0 +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass+1)); } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); } +skip() { printf ' \033[2mSKIP\033[0m %s\n' "$1"; skipped=$((skipped+1)); } +info() { printf '\n\033[1m%s\033[0m\n' "$1"; } + +cleanup() { + if [ "$KEEP" = "1" ]; then + printf '\nKEEP=1 set; leaving sandbox %s and %s in place.\n' "$SBX_NAME" "$WORK" >&2 + return + fi + [ -n "$ACQ" ] && "$ACQ" rm "$SBX_NAME" /dev/null 2>&1 || true + rm -rf "$WORK" >/dev/null 2>&1 || true +} +trap cleanup EXIT +# On a signal, exit non-zero (the EXIT trap then cleans up) rather than falling +# through: the probes below swallow errors, so continuing would keep "testing" +# a removed sandbox and print a cascade of misleading FAILs. +trap 'exit 130' INT TERM + +# Run a command in the sandbox; stderr dropped, failures collapse to empty +# output. Pair every negative assertion with a positive one that would fail +# loudly, or a broken exec passes it vacuously. +in_sbx() { "$ACQ" exec "$SBX_NAME" -- sh -c "$1" /dev/null || true; } + +# HTTP status code seen from the sandbox for https://HOST/ ("000" = no +# connection). msb denies with 000, sbx's proxy with its own 403 page; the +# unlisted control host confirms one of those is the deny answer. +probe_host() { in_sbx "curl -sS -o /dev/null -m 20 -w '%{http_code}' 'https://$1/' 2>/dev/null"; } + +info "0. Preconditions" +if [ -z "$ACQ" ] || [ ! -x "$ACQ" ]; then + echo " acq not found. Put acq on PATH, or set ACQ_DIR to an agentic-coding-quickstart checkout." >&2 + exit 1 +fi +# `acq kit validate` exists only in an acq with the kit-translate layer (acq +# v2.0.0+). Checked through acq itself: PATH usually holds a symlink into the +# quickstart clone, so a path-relative file check would look in the wrong place. +kit_usage="$("$ACQ" kit &1 || true)" +case "$kit_usage" in + *validate*) ;; + *) echo " $ACQ has no 'kit validate' verb — update the quickstart checkout (git pull)." >&2; exit 1 ;; +esac +"$ACQ" version 2>/dev/null | sed 's/^/ /' +if [ -f "$KIT_DIR/spec.yaml" ]; then + ok "kit spec.yaml present" +else + bad "spec.yaml missing"; exit 1 +fi +# Both paths go into ACQ_EXTRA_KITS, which is whitespace-separated. +case "$WORK" in + *[[:space:]]*) bad "work dir path contains whitespace; ACQ_EXTRA_KITS is whitespace-separated (set TMPDIR)"; exit 1 ;; +esac +case "$KIT_DIR" in + *[[:space:]]*) bad "kit path contains whitespace; ACQ_EXTRA_KITS is whitespace-separated (move the kit)"; exit 1 ;; +esac + +info "1. Kit validates (hybrid/v1)" +if "$ACQ" kit validate "$KIT_DIR" >/dev/null 2>&1; then + ok "kit validates" +else + bad "kit failed validation — run: $ACQ kit validate $KIT_DIR" +fi + +info "2. Generate the lower fixture kit" +# Sits BELOW the kit under test in ACQ_EXTRA_KITS and competes on every +# composed field: same env NAME, same file path, a command that sets the +# opposite value, and an egress host of its own. It also leaves its own +# uncontested marks (env var, file, startup artifact) so section 6 can prove +# the fixture was applied at all; otherwise a skipped fixture would make every +# "last wins" check pass trivially. +mkdir -p "$LOWER_KIT/files/home/team-config" "$LOWER_KIT/files/home/lower-fixture" +printf 'lower-fixture: this content must be replaced by the kit under test\n' \ + > "$LOWER_KIT/files/home/team-config/team-conventions.md" +printf 'lower-fixture: applied\n' > "$LOWER_KIT/files/home/lower-fixture/applied" +printf '# lower fixture\n' > "$LOWER_KIT/README.md" +cat > "$LOWER_KIT/spec.yaml" < + Generated by scripts/verify. Applied just below the kit under test so the + composition rules (union / last-wins / append) can be asserted. +caps: + network: + allow: + - ${LOWER_EGRESS_HOST} +files: + - path: ${TEAM_CONVENTIONS} + mode: "0644" + source: files/home/team-config/team-conventions.md + - path: ${LOWER_MARKER_FILE} + mode: "0644" + source: files/home/lower-fixture/applied +commands: + - phase: startup + user: "1000" + description: Set the value the kit under test must override, and record that it ran + command: + - sh + - -c + - git config --global push.autoSetupRemote false && touch ${LOWER_MARKER_CMD} +environment: + OPENCODE_CONFIG: ${LOWER_CONFIG} + LOWER_FIXTURE_APPLIED: "1" +EOF +if "$ACQ" kit validate "$LOWER_KIT" >/dev/null 2>&1; then + ok "lower fixture kit validates" +else + bad "lower fixture kit failed validation"; exit 1 +fi + +info "3. Create a sandbox through acq (built-in bundle + lower fixture + this kit)" +mkdir -p "$WORKSPACE" && git init -q "$WORKSPACE" +echo " creating $SBX_NAME (workspace $WORKSPACE)..." +ACQ_EXTRA_KITS="$LOWER_KIT $KIT_DIR" ACQ_UPDATE_CHECK=0 \ + "$ACQ" create opencode --name "$SBX_NAME" "$WORKSPACE" "$CREATE_LOG" 2>&1 +rc=$? +if [ "$rc" -eq 0 ]; then + ok "sandbox created (kits via ACQ_EXTRA_KITS)" +else + bad "acq create failed (exit $rc)" + sed 's/^/ | /' "$CREATE_LOG" >&2 + exit 1 +fi +# Startup steps can run for a while after create returns. Poll for the +# fixture's startup marker AND this kit's git key (the LAST startup command), +# rather than an env var: environment is materialized by the backend and can +# be visible before any startup command has run, so it fences nothing. If you +# replace this kit's git step, poll for your last step's artifact instead. +printf ' waiting for startup steps to settle' +settled=0 +for _ in $(seq 1 120); do + [ "$(in_sbx "test -f $LOWER_MARKER_CMD && git config --global push.autoSetupRemote 2>/dev/null")" = "true" ] && { settled=1; break; } + printf '.'; sleep 1 +done +printf '\n' +[ "$settled" = 1 ] || echo " note: startup did not settle within 120s; later checks may run before it finished. If you replaced the git step, update this poll." >&2 + +info "4. Global layer intact" +if [ "$(in_sbx '{ [ -f "$HOME/.config/opencode/opencode.json" ] || [ -f "$HOME/.config/opencode/opencode.jsonc" ]; } && echo y')" = "y" ]; then + ok "USAi merged into OpenCode's global config" +else + bad "global OpenCode config missing (USAi merge did not run?)" +fi +eff="$("$ACQ" exec "$SBX_NAME" -- sh -c 'opencode debug config' "$WORK/debug-config.err")" \ + || sed 's/^/ stderr| /' "$WORK/debug-config.err" >&2 +if printf '%s' "$eff" | grep -q '"usai"'; then + ok "USAi provider present in the effective config" +else + bad "USAi provider missing from the effective config" +fi + +info "5. Live values landed" +# OPENCODE_CONFIG and the startup command are asserted once, in section 7, +# where the fixture's competing values make the check meaningful. +if [ "$(in_sbx "test -f $TEAM_CONFIG && echo y")" = "y" ]; then + ok "team config file present" +else + bad "team config file missing" +fi +if [ "$(in_sbx "test -f $TEAM_CONVENTIONS && echo y")" = "y" ]; then + ok "team conventions file present" +else + bad "team conventions file missing" +fi +if printf '%s' "$eff" | grep -q 'team-conventions.md'; then + ok "team conventions registered in the effective config instructions" +else + bad "team-conventions.md missing from the effective config instructions" +fi +# Registered is not read: OpenCode 2.x accepts `instructions` but never loads +# the files it lists, so the conventions would silently not reach the agent. +# Check the OpenCode that sandbox shells resolve on PATH: an image that bakes +# 1.x puts it ahead of the agent kit's 2.x, while an image with no OpenCode of +# its own (such as sandbox-templates:shell-docker) runs only 2.x, and this +# fails. Relax it once a 2.x release loads `instructions` files. +oc_version="$(in_sbx 'opencode --version 2>/dev/null' | tail -n1)" +oc_version="${oc_version#opencode }" +oc_major="$(printf '%s\n' "$oc_version" | sed -n 's/^[^0-9]*\([0-9][0-9]*\)\..*/\1/p' | head -n1)" +case "$oc_major" in + "") bad "could not read the OpenCode version in the sandbox ('${oc_version:-no output}')" ;; + 1) ok "OpenCode $oc_version loads instructions files" ;; + *) bad "OpenCode $oc_version ignores the instructions field: team conventions will not reach the agent (pattern doc, Check against your acq)" ;; +esac +# The file, not the directory: the global playbook kit creates +# ~/.agents/skills itself, so a directory check would pass without this kit. +# Once you replace the placeholder with real skills, this skips; add a check +# per skill in the TEAM-SPECIFIC section. +if grep -qF 'files/home/.agents/skills/.gitkeep' "$KIT_DIR/spec.yaml"; then + if [ "$(in_sbx 'test -s "$HOME/.agents/skills/.gitkeep" && echo y')" = "y" ]; then + ok "team skills placeholder present" + else + bad "team skills placeholder missing" + fi +else + skip "team skills placeholder (removed from spec.yaml)" +fi + +info "6. Lower fixture applied" +# Without these, the composition checks below could pass with the fixture +# silently skipped (e.g. a failed drop aborting its apply on msb). +if [ "$(in_sbx 'printf "%s" "${LOWER_FIXTURE_APPLIED:-}"')" = "1" ]; then + ok "fixture environment applied (LOWER_FIXTURE_APPLIED=1)" +else + bad "fixture environment not applied" +fi +if [ "$(in_sbx "test -s $LOWER_MARKER_FILE && echo y")" = "y" ]; then + ok "fixture files[] applied" +else + bad "fixture files[] not applied" +fi +if [ "$(in_sbx "test -f $LOWER_MARKER_CMD && echo y")" = "y" ]; then + ok "fixture startup command ran" +else + bad "fixture startup command did not run" +fi + +info "7. Composition rules (docs/scope-layers.md), against the lower fixture" +# environment: last wins by name. The fixture set OPENCODE_CONFIG first. +cfg="$(in_sbx 'printf "%s" "${OPENCODE_CONFIG:-}"')" +if [ "$cfg" = "$TEAM_CONFIG" ]; then + ok "environment: last wins (this kit's OPENCODE_CONFIG replaced the fixture's)" +else + bad "environment: fixture's OPENCODE_CONFIG survived ('${cfg:-}')" +fi +# files[]: last wins by path. Both kits drop team-conventions.md. +guest_sum="$(in_sbx "cksum < $TEAM_CONVENTIONS")" +kit_sum="$(cksum < "$KIT_DIR/files/home/team-config/team-conventions.md")" +if [ -n "$guest_sum" ] && [ "$guest_sum" = "$kit_sum" ]; then + ok "files[]: last wins by path (this kit's team-conventions.md replaced the fixture's)" +elif [ -z "$guest_sum" ]; then + bad "files[]: team-conventions.md missing from the guest (neither kit's drop landed)" +elif [ "$(in_sbx "grep -c lower-fixture $TEAM_CONVENTIONS")" = "1" ]; then + bad "files[]: the fixture's team-conventions.md survived" +else + bad "files[]: team-conventions.md content matches neither kit" +fi +# commands[]: append in kit order. The fixture set push.autoSetupRemote=false +# first; this kit's later command must have set it to true. +if [ "$(in_sbx 'git config --global push.autoSetupRemote 2>/dev/null')" = "true" ]; then + ok "commands[]: append in order (this kit's startup command ran after the fixture's)" +else + bad "commands[]: fixture's command ran last, or this kit's did not run" +fi +# caps.network.allow: union. The backends deny with 000 (no connection, msb) +# or 403 (proxy, sbx). A host listed by no kit must get one of those (the +# negative control); each kit's host must get neither. The kit's host must be +# one the global layer does not already allow, or this proves nothing about +# the kit's own entry, and it must answer at / with neither code (a host that +# answers 403 to an anonymous request reads as denied; pick another). +if [ "$SKIP_NETWORK" = "1" ]; then + skip "caps.network.allow union (SKIP_NETWORK=1)" +else + code_denied="$(probe_host "$UNLISTED_EGRESS_HOST")" + case "$code_denied" in + 000|403) ok "caps.network.allow: unlisted control host denied ($UNLISTED_EGRESS_HOST -> $code_denied)" ;; + "") bad "caps.network.allow: probe of the unlisted control host failed (no answer from acq exec)" ;; + *) bad "caps.network.allow: unlisted control host answered $code_denied, not a deny code (000/403): something admits it, so the union cannot be observed" ;; + esac + if [ "$code_denied" = 000 ] || [ "$code_denied" = 403 ]; then + for side in fixture kit; do + if [ "$side" = fixture ]; then host="$LOWER_EGRESS_HOST"; else host="$TEAM_EGRESS_HOST"; fi + code="$(probe_host "$host")" + case "$code" in + 000|403|"") bad "caps.network.allow: $side's host denied ($host -> ${code:-none}); union broken, or the host is offline" ;; + *) ok "caps.network.allow: $side's host admitted ($host -> $code)" ;; + esac + done + fi +fi + +info "8. TEAM-SPECIFIC checks (extend as your kit grows)" +# One assertion per mechanism your kit adds: an env var, a shipped file, a +# startup command's effect, an entry in the effective agent config, a skill... +skip "no team-specific checks yet" + +info "Summary" +printf ' %d passed, %d failed, %d skipped\n' "$pass" "$fail" "$skipped" +if [ "$fail" -eq 0 ]; then + printf ' \033[32mAll checks passed.\033[0m\n' +else + printf ' \033[31mSome checks failed — see notes above.\033[0m\n' +fi + +[ "$fail" -eq 0 ] diff --git a/integrations/isolation/acq-kits/examples/team-kit/spec.yaml b/integrations/isolation/acq-kits/examples/team-kit/spec.yaml new file mode 100644 index 00000000..7b6a278f --- /dev/null +++ b/integrations/isolation/acq-kits/examples/team-kit/spec.yaml @@ -0,0 +1,104 @@ +# spec.yaml — team-kit: a TEMPLATE for a team's own acq mixin kit (hybrid/v1). +# +# Not applied by acq. Copy this directory into a repo your team owns, rename +# BOTH the directory and `name:` below, replace every value marked REPLACE, +# and grow it. Teammates apply it through env.sh (see README.md). The rules +# the comments below summarize live in the pattern doc, +# integrations/isolation/docs/scope-layers.md in agentic-coding-patterns. +# +# Two parser rules for this whole file (pattern doc, "Check against your acq"): +# never put a comment after a value in a command or a top-level field (put it +# on its own line), and avoid `#` in environment values. +# +# Every extension point ships ONE live, harmless value, which scripts/verify +# asserts. Commented YAML is never schema-checked, so a template made of +# comments would rot silently. +schemaVersion: "hybrid/v1" +kind: mixin +# REPLACE name (kebab-case, equal to the directory name), displayName, and +# description. +name: team-kit +displayName: Team kit (template) +description: > + Starting point for a team scope-layer kit: team conventions and agent + settings wired through the OpenCode config tier, plus one live value per + extension point (egress, files, startup command, environment) to replace. + +# --- Egress your team's tools need ------------------------------------------- +# Hosts beyond the global layer. Lists UNION across kits; egress is fixed at +# sandbox creation, so recreate after changing it. +caps: + network: + allow: + # REPLACE with your team's hosts (VCS, package mirrors, MCP servers): + # - git.example.gov # team VCS over HTTPS + # - mcp.example.gov:443 # team MCP server + # The live value is an IANA-reserved example domain that no global kit + # allows, so scripts/verify can prove this entry took effect. Keep your + # replacement off the global list for the same reason. + - example.org + +# --- Files ------------------------------------------------------------------- +# List EVERY payload under files/ with its in-guest absolute path, and never +# ship an empty file (msb places only listed, non-empty files). Files are +# whole-file and LAST WINS by path across kits, so never ship a file another +# layer or the image owns (~/.bashrc, ~/.gitconfig). +files: + - path: /home/agent/team-config/opencode.jsonc + mode: "0644" + source: files/home/team-config/opencode.jsonc + description: Team OpenCode settings (read via OPENCODE_CONFIG below) + - path: /home/agent/team-config/team-conventions.md + mode: "0644" + source: files/home/team-config/team-conventions.md + description: Team instructions (registered in opencode.jsonc `instructions`) — REPLACE the content + # Shared skills live under files/home/.agents/skills//, named so they + # cannot collide with the global playbook kit's skills (README, "Writing a + # skill"). This placeholder stands in for them; add one files[] record per + # skill file, and when you delete the placeholder, delete this record too: + # - path: /home/agent/.agents/skills/my-skill/SKILL.md + # mode: "0644" + # source: files/home/.agents/skills/my-skill/SKILL.md + - path: /home/agent/.agents/skills/.gitkeep + mode: "0644" + source: files/home/.agents/skills/.gitkeep + description: Placeholder (non-empty) that creates the team skills directory + +# --- Commands ---------------------------------------------------------------- +# Commands APPEND across kits, in application order; `startup` re-runs at every +# sandbox start, so keep each step idempotent. ALWAYS set `user:` ("1000" for +# the agent user). Write argv as a block list (one `- arg` per line) or one +# `- |` block scalar, never `[a, b]`. No installs: bake tools into the image. +commands: + # Live value: a harmless, idempotent git preference (first push of a new + # branch otherwise fails with "no upstream branch" in a disposable clone). + # Keep it or REPLACE it; verify asserts it ran AFTER a lower kit's command. + - phase: startup + user: "1000" + description: Auto-create the upstream on first push + command: + - git + - config + - --global + - push.autoSetupRemote + - "true" + +# --- Environment ------------------------------------------------------------- +# Non-secret, single-line strings only; secrets go through `acq secret set`. +# LAST WINS by name across kits, so a single-valued variable has one owner in +# the stack. No `~` expansion: write /home/agent/... OPENCODE_CONFIG gives the +# settings tier global -> team -> repo (docs/decisions/ explains the choice). +# Say in your README which variables a personal kit may override. +environment: + OPENCODE_CONFIG: /home/agent/team-config/opencode.jsonc + +# --- agentContext ------------------------------------------------------------ +# Markdown surfaced to the agent, on sbx only. This template routes team +# instructions through OpenCode's `instructions` mechanism instead; do not use +# both for the same content or the agent reads it twice. +# agentContext: | +# ## Your team +# One or two operational paragraphs. + +# No backend_shortcuts or backend_extras. If you add one, record it under +# "Backend parity" in README.md. diff --git a/integrations/isolation/docs/decisions/0005-kit-templates.md b/integrations/isolation/docs/decisions/0005-kit-templates.md index 54c33801..29e6b3ed 100644 --- a/integrations/isolation/docs/decisions/0005-kit-templates.md +++ b/integrations/isolation/docs/decisions/0005-kit-templates.md @@ -26,8 +26,8 @@ shared rc files, binary payloads, create-time installs through the proxy, secret rotation changing the placeholder, `environment` last-wins shadowing) that the next team would hit again. -The pattern is documented in `../scope-layers.md`. This record covers the two -structural decisions that document depends on. +The pattern is documented in [`../scope-layers.md`](../scope-layers.md). This +record covers the two structural decisions that document depends on. ## Decision Drivers @@ -136,8 +136,8 @@ Adopt **templates (option 1)** and **placement (B)**. ## Links -- Pattern doc: `../scope-layers.md`. -- Template: `../../acq-kits/examples/team-kit/`. +- Pattern doc: [`../scope-layers.md`](../scope-layers.md). +- Template: [`../../acq-kits/examples/team-kit/`](../../acq-kits/examples/team-kit/). - Validator: [`../../acq-kits/validate-kits.py`](../../acq-kits/validate-kits.py) (`TEMPLATES_DIR`). - ADR 0001 (isolation): the neutral `hybrid/v1` acq-kits area this extends. diff --git a/integrations/isolation/docs/scope-layers.md b/integrations/isolation/docs/scope-layers.md new file mode 100644 index 00000000..86612369 --- /dev/null +++ b/integrations/isolation/docs/scope-layers.md @@ -0,0 +1,417 @@ +# Scope layers: global, team, and personal kits + +How a team, and each person on it, layers sandbox configuration on top of the +global kits `acq` applies for everyone. Nothing here needs a change to `acq` or +to this repo: the mechanism is `ACQ_EXTRA_KITS`, and the vocabulary is the +neutral `hybrid/v1` kit spec +([ADR 0001](decisions/0001-neutral-hybrid-v1-acq-kits.md)). Every command below +is an `acq` command, so the steps are the same on every backend. The backends +still behave differently in places; [Backend differences](#backend-differences) +lists them. + +The rules here describe `acq`'s current behavior. A few depend on `acq` +parser details that may change; [Check against your +acq](#check-against-your-acq) lists those. + +## The layer model + +```text + applied by acq, in this order (later kits win where a field is last-wins) + ┌────────────────────────────────────────────────────────────────────────┐ + │ global zscaler-ca-certificate · usai-provider · │ pinned built-in bundle + │ agentic-coding-playbook · git-ssh-sign │ (everyone) + │ agent the agent's own kit (opencode for `acq run opencode`) │ pinned by acq, per agent + │ team your team's kit │ ACQ_EXTRA_KITS, first + │ personal your own kit │ ACQ_EXTRA_KITS, later + └────────────────────────────────────────────────────────────────────────┘ + repo AGENTS.md · .opencode/ · the repo's own agent config read by the agent, + not applied by acq +``` + +- **Global.** `acq` applies its pinned built-in bundle first, in the order + shown. Everyone gets it; a lower layer never redeclares it. +- **Agent.** For an agent `acq` ships a kit for (OpenCode), `acq` applies that + pinned kit next; it installs the agent's CLI. It is not applied when `--kit` + refs are given. +- **Team.** One more `hybrid/v1` mixin kit, kept in a repo the team owns, + carrying only the *team delta*: extra egress, conventions, shared agent + settings, skills, a few idempotent startup steps. +- **Personal.** The same shape, kept in a repo you own (a dotfiles repo is + ideal): shell drop-ins, git preferences, a TUI theme, tools you want. +- **Repo.** The workspace's own `AGENTS.md`, `.opencode/`, or equivalent. It + sits above the kits in precedence *for the agent*, but it is not a kit: + the agent reads it from the mounted workspace; `acq` never applies it. + +`ACQ_EXTRA_KITS` is whitespace-separated and ordered. Each entry is a local +path or a `git+https://…#ref=&dir=` ref. `acq` does not remove +duplicates: a kit listed twice is applied twice, startup commands included. + +### Host-side defaults + +Keep the team's host-side defaults in a small POSIX-sh `env.sh` inside the +team kit, and have each teammate's shell rc source it: + +```bash +# in your shell rc (bash or zsh) +export TEAM_KIT=/path/to/team-repo/acq-kits/team-kit +PERSONAL_KITS="/path/to/dotfiles/personal-kit" # optional, in order +. "$TEAM_KIT/env.sh" +``` + +```bash +# then, as usual +acq run opencode /path/to/project +``` + +- It rebuilds `ACQ_EXTRA_KITS` as the team kit, then any kits already in it, + then `PERSONAL_KITS`, dropping repeats. Later kits win on overlap, so every + kit a teammate adds, a catalog kit's `git+https` ref included, lands after + the team kit. Nested shells (a tmux pane, `exec zsh`) re-source the rc, so + a plain `ACQ_EXTRA_KITS="$ACQ_EXTRA_KITS …"` would add the kit again each + time. +- Other defaults, such as `ACQ_IMAGE`, apply only when the teammate has not + set them. Defaults change for everyone with `git pull`. +- The rc names the kit directory because a sourced file cannot find its own + directory the same way in bash and zsh. Paths must not contain whitespace. +- It is not a kit payload: list it in neither `files[]` nor the spec. +- An exported `ACQ_EXTRA_KITS` turns off the kits chosen with `acq + configure` (see [Check against your acq](#check-against-your-acq)). + +The team-kit template ships an `env.sh` to start from. + +### Recreating a sandbox + +At creation, `acq` records the sandbox's extra kits (`ACQ_EXTRA_KITS` and any +`--kit` refs) on the host. When `acq run` reattaches to that sandbox and the +record lists extras, the record replaces the current shell's +`ACQ_EXTRA_KITS`. So a change to the extras set, or to a +`git+https://…#ref=` pin, never reaches an existing sandbox: `acq rm ` +and recreate. A local clone path is recorded as a path, so content changes in +that clone are picked up wherever kits are re-applied. + +A recreate discards everything the guest holds outside a host mount, +including the agent's session history (OpenCode keeps it under +`~/.local/share/opencode`). To keep it, copy it into a host mount first, for +example with SQLite's `VACUUM INTO` run through `acq exec`. + +Whether new kit content reaches an existing sandbox without a recreate +depends on the backend; see [Backend differences](#backend-differences). + +A background daemon that a startup step launches (a web UI serving agent +sessions, for example) reads its configuration once, when it starts. After a +kit change reaches the sandbox, run `acq restart ` so the daemon +starts again with the new files and environment. + +## How stacked kits compose, field by field + +On msb, `acq`'s adapter composes the kits itself, so the rules below are +`acq`'s code. On sbx, `acq` translates each kit to a native sbx kit and +**sbx** composes them; where a row says "sbx: composed natively", `acq` does +not decide the outcome. The team-kit template's `scripts/verify` asserts the +`environment`, `files[]`, `commands[]`, and `caps.network.allow` rows live +against a competing kit, on whichever backend runs it, so run it on sbx to +confirm those rows there. The `volumes[]` and `commands[]` rows describe +current behavior; the rule for conflicts between stacked kits' volumes and +startup steps is still under discussion upstream in `acq`. + +| Field | Rule | What it means when you stack | +|-------|------|-------------------------------| +| `caps.network.allow` | **union** | Every kit's hosts are allowed. Overlap with the global layer is harmless. Under org governance, org rules still win. msb strips a `:port` suffix, so `host:443` allows the whole host there. | +| `caps.network.tier` | **not per kit** | A kit's `tier` field is not read when kits are applied. msb: the tier is sandbox-wide, from `ACQ_NETWORK_TIER` (default `balanced`). sbx: `ACQ_NETWORK_TIER` is not read; egress posture is sbx's own policy. | +| `files[]` | **msb: last wins, by path** | msb: a whole-file overlay. A later kit's file at the same in-guest `path` replaces the earlier one, silently and in full. Nothing merges. sbx: composed natively; `acq` copies each kit's whole `files/` tree, placement follows where a file sits in that tree, and `files[].path` is used only for its mode. | +| `commands[]` | **append, in kit order** | Each kit's commands run in spec order; kits run in application order. `startup` re-runs at every start; on msb that means every re-apply (`acq run` on an existing sandbox, `acq start`, `acq restart`). `install` runs once per sandbox; on msb its once-marker is a hash of the argv alone, so identical install argv in two kits runs once and a changed argv runs again. | +| `environment` | **msb: last wins, by name** | msb: a later kit's value for the same `NAME` replaces the earlier one, silently. Sessions and every kit's `commands[]` (daemons they start included) see the merged result. sbx: each kit emits its variables and sbx merges them. Either way, give single-valued variables such as `OPENCODE_CONFIG` exactly one owner in the stack. | +| `volumes[]` | **msb: union, last wins by path** | msb: two kits declaring the same mount path, the later kit's entry is used. sbx: composed natively (not verified). | +| `publishedPorts[]` | **msb: union, last wins by guest port** | msb: two kits publishing the same guest port, the later kit's entry is used. A port without `host:` gets a free host port per sandbox; a `host:` port already in use fails the create. sbx: composed natively (not verified); the host port is ephemeral. | +| `agentContext` | **per kit; sbx only** | The msb adapter does not surface it. Prefer the agent's own instructions mechanism (the team-kit template shows OpenCode's). | +| `backend_shortcuts` / `backend_extras` | **per kit** | Never compose. A shortcut skips that kit's generic path on that backend. | + +Two consequences drive most of the guidance below: + +- **Anything last-wins is a silent shadow.** A personal kit that sets + `OPENCODE_CONFIG` hides the team's settings with no warning. The global + `usai-provider` kit deliberately merges into OpenCode's *global config path* + and leaves `OPENCODE_CONFIG` free for the team layer (see its ADR 0004); + keep it that way in your kits. +- **Files are whole-file.** Never ship a file another layer or the image also + owns (`~/.bashrc`, `~/.gitconfig`). Ship drop-ins and includes instead. + +## Kit, image, or host-side secret? + +**Config in kits; toolchains in the image; credentials host-side.** + +| Put it in | When | Because | +|-----------|------|---------| +| **a kit** | declarative config: egress, files, env, agent instructions, small idempotent startup steps | cheap to change, versioned with the team, re-applied on run (msb) | +| **the image** (`ACQ_IMAGE`) | anything *installed*: language toolchains, package managers, CLIs the team depends on | create-time installs run through the sandbox proxy and a blip fails the whole create; a baked image is deterministic. The published devenv image under [`../images/devenv/`](../images/devenv/) is the precedent. | +| **`acq secret set`** | every credential: VCS tokens, API keys | the proxy injects the real value in transit; the sandbox only ever holds a placeholder. Never in a spec, not even in `environment`. | + +```bash +acq secret set -g --host --env # once per machine, host-side +``` + +## What belongs in each layer + +### Team kit + +| Need | Mechanism | +|------|-----------| +| Egress for team hosts (VCS, package mirrors, MCP servers) | `caps.network.allow` (union) | +| Conventions the agent must follow across the team's repos | A markdown file under `files/`, registered in the agent's instructions path (OpenCode: an `instructions` entry in a team config file). OpenCode 2.x does not load `instructions` files (see [Check against your acq](#check-against-your-acq)). | +| Team agent settings that override the global defaults | Ship a config file and point the agent's config env var at it (OpenCode: `OPENCODE_CONFIG` in `environment`), giving the tier global → team → repo. Never pin `model` / `small_model` there, and never add an `allow` rule that overrides a global ask or deny (see the gotchas). | +| Team TUI settings (OpenCode theme, keybinds) | A separate `tui.jsonc`, with `OPENCODE_TUI_CONFIG` pointing at it: OpenCode drops `theme` / `keybinds` / `tui` from `opencode.json(c)` on load. The variable is single-valued, so say in the team README whether a personal kit may override it. | +| Gating a team CLI (for example the VCS CLI) | Permission rules in the team config, in the same default-allow posture as the global kit: reads fall through to its `"*": "allow"`; `ask` before anything that posts or opens a merge request; `deny` merge and approve, pipeline runs, CI variables, destructive admin commands, and the `api` subcommand's write flags and GraphQL. Any write verb you do not list stays allowed, so review your CLI's close, edit, delete, label, release, and CI retry verbs. Rules match the whole command text, so write each from the exact command prefix and see the gotchas. The team-kit template's `opencode.jsonc` sketches the shape. | +| Shared skills | Directories under `files/home/.agents/skills//`, each file listed in `files[]`, named so they cannot collide with the playbook's skills (see the gotchas) | +| Small per-sandbox setup that must *run* | `commands[]` with `phase: startup`, idempotent (for example `git config --global` keys) | +| A team VCS token | **Not in the kit.** `acq secret set -g --host --env `, plus HTTPS routing for git (below) | + +#### Git over HTTPS (team VCS and GitHub) + +Route git over HTTPS and let the proxy supply the token. SSH remotes do not +work through msb's egress (see [Backend differences](#backend-differences)), and a team checkout +usually carries one: `--clone` inherits the host's `git@host:` origin, and +some tools (flake inputs, for example) use the `ssh://git@host/` form. One +idempotent startup step (`user: "1000"`) rewrites both forms and adds a +credential helper that reads the proxy-injected variable. For a team VCS: + +```bash +git config --global url."https://git.example.gov/".insteadOf "git@git.example.gov:" +git config --global url."https://git.example.gov".insteadOf "ssh://git@git.example.gov" +git config --global credential."https://git.example.gov".helper \ + '!f() { echo username=oauth2; echo "password=${TEAM_VCS_TOKEN:-}"; }; f' +``` + +The two `url.` keys differ by the trailing slash on purpose: `git config` +replaces a single-valued key, so one key cannot carry both rewrites. The +helper only ever sees the placeholder in `TEAM_VCS_TOKEN`; the proxy swaps in +the real value in transit, for the host the secret is scoped to. Use the +username your VCS expects for token auth. + +GitHub needs the same routing. acq binds the GitHub token +(`acq secret set -g github`) to `github.com` for HTTPS git on msb, but no +global kit rewrites GitHub's SSH remotes or registers a helper, so an +SSH origin fails in an msb guest. The same step covers it: + +```bash +git config --global url."https://github.com/".insteadOf "git@github.com:" +git config --global url."https://github.com".insteadOf "ssh://git@github.com" +git config --global credential."https://github.com".helper \ + '!f() { echo username=x-access-token; echo "password=${GITHUB_TOKEN:-}"; }; f' +``` + +GitHub routing is the same for every team, so it belongs in the global layer +(the kit or `acq` itself that binds the GitHub token) rather than in each team +kit. Until it is there, a team kit carries it, and should drop it once the +global layer does. + +### Personal kit + +| Need | Mechanism | +|------|-----------| +| Aliases, prompt, shell functions | `files/home/.rc.d/NN-name.sh` drop-ins. `acq` writes `~/.profile` so that every bash login shell sources `~/.rc.d/*.sh` in lexical order, on both backends, so a kit only delivers drop-ins. Never add a loop of your own: a kit that also appends one to `~/.bashrc` makes every drop-in run twice. The loop has no interactive guard, so drop-ins also run in scripted `bash -lc` runs: keep them POSIX and safe without a terminal, and wrap interactive-only lines in `case $- in *i*) ... esac`. A non-login bash (typing `bash` at a prompt) sources none. | +| Your working shell | An `exec zsh` line in a `99-` drop-in, so it sorts last. Wrap it in `case $- in *i*)`, or it hijacks scripted `bash -lc` runs. The `acq` loop runs only in bash: give `~/.zshrc` its own loop with an append-if-absent startup step, and make the `exec` line skip when it is already in zsh (`[ -z "${ZSH_VERSION:-}" ]`), or zsh sourcing it through that loop re-execs forever. | +| Terminfo for your terminal | Ship the *source* (`infocmp -x`) and compile it in a startup step (`tic -x`) | +| Git preferences | One startup command per key, or ship a file and add it with `include.path` | +| Overriding a team setting | `environment` (last wins) or a later file at the same path (last wins) — deliberately, and only for settings the team marks as personal | +| Your own agent instructions (OpenCode) | A file under `files/`, added through `OPENCODE_CONFIG_CONTENT` in `environment`, for example `{"instructions":["/home/agent/personal/instructions.md"]}`. Do not append to the global rules file: the playbook kit symlinks `~/.config/opencode/AGENTS.md` and `~/.claude/CLAUDE.md` into its managed checkout, so an append edits that checkout. In a local test on OpenCode 1.18, the `instructions` lists combine in order (global, then team through `OPENCODE_CONFIG`, then this one); OpenCode's docs do not say how lists merge, and OpenCode 2.x does not load `instructions` files (see [Check against your acq](#check-against-your-acq)). Put nothing else in it: inline config loads after the repo's own config, so any other key overrides the repo. The variable is single-valued (last kit wins). | +| Extra egress for your tools | `caps.network.allow` (union) | + +### Neither + +- **Tools and toolchains.** Bake them into the image (`ACQ_IMAGE`). +- **Secrets.** Host-side only. +- **Whole shared files** (`~/.bashrc`, `~/.zshrc`, `~/.gitconfig`). +- **Binary files.** Kit file delivery is for text. Ship sources and generate + at startup, or download in a guarded, non-fatal startup command. +- **Anything the global layer already owns**: provider config, the playbook, + CA trust, commit signing. Carry only the delta. +- **Credentials.** `hybrid/v1` has no credentials vocabulary; credentials are + host-side via `acq secret set`. + +## Authoring gotchas + +- **Always set `user:` on every command.** Use `"1000"` for the agent user. + An omitted `user:` does not mean the agent user: `acq` passes no user to the + backend, so on msb the command runs as root. +- **Startup commands re-run at every start.** Make them idempotent, and + non-fatal where the feature is optional: guard on the tool or file + existing (`command -v tool >/dev/null 2>&1 || exit 0`), so a stock image + without the tool skips the step. One failing step can fail the create. +- **Guard `.` on the file existing** in a `sh -c` script. `.` is a POSIX + special builtin: when the file is missing, dash exits the whole script, and + `|| true` does not catch it. Write `[ -r f ] && . f`. +- **Do not assume the working directory.** Startup commands do not run in + the workspace. `acq` exports `ACQ_WORKSPACE` (the primary workspace's mount + root) and, under `--clone` only, `ACQ_CLONE=1` into the guest on both + backends. A step that writes into the repo must require `ACQ_CLONE=1`, so it + never touches a host checkout mounted in passthrough mode, and should list + what it adds in `.git/info/exclude` so it cannot be committed. That guard + covers only the primary: acq clones nothing else. Other writable repos in + a multi-repo sandbox are either host checkouts (never write) or disposable + clones the user made on the host (safe), and from inside the guest they + look alike. Have the host mark a clone when it makes one, for example + `git -C config sandbox.disposable true`, and write only to the + `--clone` primary and to writable repos carrying that mark. Never write to + an unmarked repo. +- **`environment` values are verbatim, single-line strings.** No `~` + expansion: write `/home/agent/...`. `acq kit validate` rejects a block + scalar. +- **List every shipped file in `files[]`** with its in-guest absolute path, + and never ship an empty one. msb places only the listed records and rejects + an empty payload (see [Backend differences](#backend-differences) and + [Check against your acq](#check-against-your-acq)). Give a placeholder such + as `.gitkeep` a comment line. +- **Rotate secrets without changing the placeholder.** Existing sandboxes + captured the placeholder at creation; a new one breaks them until they are + recreated. How to rotate depends on the backend (see [Backend + differences](#backend-differences)). +- **One owner per single-valued env var.** Decide in the team kit's README + which variables a personal kit may override. +- **No `allow` rule that overrides a global ask or deny.** OpenCode uses the + last matching rule. On OpenCode 1.x the team config is merged key by key: a + rule with the same pattern as a global one replaces it in the global rule's + place, and a new pattern goes after all global rules. So a new team pattern + such as `"git *": "allow"` beats the global `"git push *": "ask"`. OpenCode + 2.x keeps each config file's rules as a separate list, global first, then + team. Either way, add `ask` and `deny` rules, not `allow`. +- **Permission rules match the whole command text.** That includes inline + bodies and heredocs, and OpenCode's `*` spans spaces. A loose deny such as + `vcs*pipe* delete*` can match an issue body that mentions both words and + block a legitimate `vcs issue create`. Write each rule from the exact + command prefix (`vcs pipeline delete *`), and have the agent pass post + bodies from a file (`--body-file`, or `-d "$(cat /tmp/body.md)"`), so the + body text never reaches the matcher. +- **Permission rules are a guardrail, not a boundary.** `env vcs …`, a full + path to the binary, or a direct HTTP call to the VCS API all get past them. + The real control for team-CLI writes is the scope of the token set with + `acq secret set`: give the sandbox a least-privilege token. +- **Do not pin `model` or `small_model` in the team config.** The global + provider kit updates its defaults as the provider's model catalog changes; a + team pin overrides that and can outlive the model it names. +- **TUI settings need their own file.** OpenCode drops top-level `theme`, + `keybinds`, and `tui` from `opencode.json(c)` on load, so they vanish from a + team `opencode.jsonc` without an error. Ship a `tui.jsonc` and set + `OPENCODE_TUI_CONFIG`. +- **Name team skills so they cannot collide with the playbook's.** The global + playbook kit symlinks its skills into the same `~/.agents/skills` directory + at every start (`ln -sfn`). A team skill with the same name as a playbook + skill (`code-review`, say) shadows it and collects a stray nested symlink. + Prefix team skills (`team-code-review`). +- **Never print the environment in an agent session.** `env` or `printenv` + puts secret placeholders into the transcript, which the agent replays to the + model endpoint on every request. On msb that breaks the session (see + [Backend differences](#backend-differences)). +- **`agentContext` or instructions, not both** for the same content, or the + agent reads it twice. +- **Do not put the team's version pin in prose.** Point `ACQ_EXTRA_KITS` at a + clone and let `git pull` or a detached checkout select the version. A clone + path suits this: the sandbox records the path, so a changed checkout reaches + it wherever kits are re-applied (msb). A `git+https://…#ref=` pin is + recorded as the ref itself, so changing it needs `acq rm` and recreate. + +## Backend differences + +A kit that uses no `backend_shortcuts` or `backend_extras` still runs on two +backends that behave differently. This is the canonical list; the composition +table above covers the per-field differences (`:port` in `caps.network.allow`, +the network tier, and the rows sbx composes natively). + +| Behavior | msb | sbx | +|----------|-----|-----| +| A command with no `user:` | Runs as root: `acq` passes no user to the backend. | Runs as sbx's default exec user (not verified). Set `user:` either way. | +| `agentContext` | Not surfaced. | Surfaced to the agent. | +| New kit content on an existing sandbox | `acq run`, `acq start`, and `acq restart` re-apply every recorded kit: files refresh, `environment` is rebuilt, startup steps re-run. Egress and volumes are fixed at creation. A daemon a startup step started keeps its old configuration until `acq restart`. | `acq run` adds only kits the sandbox has not recorded, and skips a recorded kit even if its content changed. sbx refuses to add a kit with startup commands to a live sandbox and prints a recreate notice, so adding or changing a team kit means `acq rm ` and recreate. | +| A file under `files/` with no `files[]` record | Missing: only listed records are placed. | Delivered: the whole `files/` tree is copied. | +| SSH to a host in `caps.network.allow` | Not carried: port 22 times out or is refused, so SSH git remotes hang or fail at once. Use HTTPS (see [Git over HTTPS](#git-over-https-team-vcs-and-github)). | Has worked, but not as a documented contract. | +| A request carrying another host's secret placeholder | The proxy fails closed and drops the connection. A transcript holding a placeholder (from `env` or `printenv`) breaks every later request in that session; start a new one. | Not verified. | +| Rotating a secret | Re-run `acq secret set` for the same service. | `acq` refuses to overwrite an existing secret and prints the `sbx secret rm` command. Remove it, then set it again with the same placeholder. | +| A published port with no `host:` | `acq` picks a free host port per sandbox. | sbx picks an ephemeral host port. | + +## Check against your acq + +`acq` reads kit specs with a line-based parser, not a full YAML parser, and a +few of its rules are easy to break without an error. The rules below hold for +the current `acq`; after upgrading it, run `acq kit validate` and +`scripts/verify` again, and treat a rule here that no longer matches as out of +date. + +- **Comments.** A trailing `# comment` is removed from values in + `caps.network.allow`, `files[]`, `environment`, `volumes[]`, and + `publishedPorts[]`. It is kept as part of the value in every top-level field + (`name:`, `displayName:`, `description:`, …) and everywhere in + `commands[]`: after `phase:` or `user:` it makes `acq` skip the whole + command with only a warning, and after an argv item it becomes part of that + argument. `acq kit validate` catches only a bad `name:`. **Never put a + comment after a value in a command or a top-level field;** put it on its own + line. +- **`#` in `environment` values.** A value is cut at its first `#`, even + inside quotes: `FOO: "a#b"` arrives as `a`. Avoid `#` in values (URL + fragments, `#ref=` pins, hex colors); ship such a value in a file instead. +- **Flow-style argv.** `command: [git, config, --global, alias.st, status]` + parses as an empty argv, runs nothing, and passes `acq kit validate`. Write + argv as a block list, one `- arg` per line, or a single `- |` block scalar. +- **OpenCode 2.x ignores `instructions`.** OpenCode 2.x accepts the + `instructions` config field but never loads the files it lists, so team + conventions and personal instructions delivered that way silently do not + reach the agent. The global `~/.config/opencode/AGENTS.md` still loads. + Which OpenCode runs depends on the image. The agent kit installs 2.x, but an + image that bakes OpenCode 1.x puts it earlier on `PATH`, so 1.x runs and the + files load: `acq`'s own template image for `acq run opencode` + (`sandbox-templates:opencode-docker`) and this repo's `devenv-opencode` both + do. An image with no OpenCode of its own, such as + `sandbox-templates:shell-docker`, runs only the kit's 2.x, and the files + are ignored. Check with `acq exec -- opencode --version`; the + team-kit template's `scripts/verify` fails when 2.x runs. +- **Empty files and failed drops (msb).** msb checks each file drop with + `test -s`, so a zero-byte payload never counts as placed. A failed drop + stops the rest of that kit's apply: the files placed before it stay, but + the kit's `environment` is not recorded and its `commands[]` do not run. The + create output names the file ("could not place kit file"). +- **The `~/.rc.d` loop.** `acq` writes the loop into `~/.profile` only when + that file is missing, empty, or already its own. An image that ships its own + `~/.profile` keeps it, and drop-ins then load only if that file sources + `~/.rc.d`. Check with `acq exec -- cat ~/.profile`. +- **`ACQ_EXTRA_KITS` and `acq configure`.** `acq configure` stores default + extra kits as `extra_kits:` in `~/.config/acq/config.yaml`. + - `extra_kits:` takes only catalog names (`openchamber`, `paseo`, + `oci-engine`). A path or a `git+https` ref fails the create with "config + extra_kits contains unknown kit", so a team kit cannot go there. + - A catalog kit resolves to the patterns commit `acq` pins for its built-in + bundle, so a team cannot choose a newer version of it there. + - When `ACQ_EXTRA_KITS` is set in the environment, `acq` ignores the + configured kits and skips the create-time kit picker, with no notice. A + team `env.sh` therefore turns off every `acq configure` kit choice on that + host. A teammate who also wants a catalog kit lists its + `git+https://…#ref=` entry in `PERSONAL_KITS`, so it lands after the team + kit (see [Host-side defaults](#host-side-defaults)). + - The config is per host (per user account), so its kits also apply to + sandboxes that do not use the team kit. + +## Validating and verifying + +```bash +acq kit validate /path/to/your-kit # static: spec shape, files[] sources, env names +/path/to/your-kit/scripts/verify # live: throwaway sandbox through acq, asserts the composition +``` + +Run `scripts/verify` on each backend your team uses. Inside this repo, +`acq-kits/validate-kits.py` additionally checks every kit and template +against the JSON Schema. It validates this repo's layout, not a standalone +kit directory, so in your own repo `acq kit validate` is the gate. Record +your team's non-obvious choices as ADRs next to the kit (`docs/decisions/`): +future teammates inherit the *why*, not just the YAML. + +## Template and reference implementation + +- [`../acq-kits/examples/team-kit/`](../acq-kits/examples/team-kit/) is the + copy-and-rename starting point for the team layer. Every extension point + carries one live, harmless value that the schema checks and its + `scripts/verify` asserts, together with the composition rules in the table + above. A personal kit has the same shape; [Personal kit](#personal-kit) + above says what goes in it. +- The reference implementation is login.gov Team Data's team kit, with a + personal-kit example beside it. The gotchas above are the ones that team + hit; this doc is the generalization. + +See [ADR 0005](decisions/0005-kit-templates.md) for why these are templates +rather than a parameterized kit, and where they live.