diff --git a/integrations/isolation/acq-kits/README.md b/integrations/isolation/acq-kits/README.md index b717d662..ba96a979 100644 --- a/integrations/isolation/acq-kits/README.md +++ b/integrations/isolation/acq-kits/README.md @@ -48,6 +48,7 @@ they are exempt from — and must stay out of — the `kits.yaml` registry. See | 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). | +| [`examples/personal-kit/`](examples/personal-kit/) | A person's own mixin, stacked last: shell drop-ins, git preferences, and an OpenCode TUI theme. Its verify proves the global, team, and personal stack. | ## What is a hybrid/v1 kit? diff --git a/integrations/isolation/acq-kits/examples/personal-kit/README.md b/integrations/isolation/acq-kits/examples/personal-kit/README.md new file mode 100644 index 00000000..d0a74538 --- /dev/null +++ b/integrations/isolation/acq-kits/examples/personal-kit/README.md @@ -0,0 +1,133 @@ +# personal-kit (template for your own acq mixin kit, `hybrid/v1`) + +A **template, not a deployable kit**: the starting point for your personal +scope-layer mixin, the last kit in the stack, on top of the global kits and +your team's kit. `acq` never applies this directory; you copy it into a repo +you own (a dotfiles repo is ideal), 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 team's kit (ACQ_EXTRA_KITS, first) +personal your copy of this kit (ACQ_EXTRA_KITS, last) +repo the repo's AGENTS.md / .opencode/ (read by the agent) +``` + +## Use + +1. Copy this directory into a repo you own 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 in the files under + `files/`. 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 last, after your team's kit. List it in `PERSONAL_KITS` before + your shell rc sources the team kit's `env.sh`, which puts your kits after + the team's (see "Host-side defaults" in the pattern doc), then start + sandboxes as usual: + + ```bash + # shell rc (bash or zsh) + export TEAM_KIT=/path/to/team-repo/acq-kits/team-kit + PERSONAL_KITS="/path/to/dotfiles/personal-kit" + . "$TEAM_KIT/env.sh" + ``` + + ```bash + acq run opencode /path/to/project + ``` + +4. Validate on every change, and run the live check when the spec or a payload + changes: + + ```bash + acq kit validate /path/to/dotfiles/personal-kit + TEAM_KIT=/path/to/team-repo/acq-kits/team-kit /path/to/dotfiles/personal-kit/scripts/verify + ``` + +Your kit is last, so wherever a field is last-wins (`environment`, `files[]` +by path) yours silently shadows the team's. Override a team setting only where +the team kit's README says a personal kit may. + +## What it carries (as shipped) + +| Extension point | Live value | Replace with | +|-----------------|------------|--------------| +| `caps.network.allow` | `example.net` (an IANA-reserved example domain no other layer allows, so `scripts/verify` can observe it) | the hosts your own tools need, or nothing | +| `files[]` | `.rc.d/50-example.sh` (one alias), `personal/tui.jsonc` (OpenCode TUI theme) | your drop-ins and personal config files (one `files[]` record per file) | +| `commands[]` | `git config --global alias.st status` | your own idempotent steps | +| `environment` | `OPENCODE_TUI_CONFIG` → your `tui.jsonc` (this overrides a team `tui.jsonc`; keep it only if the team kit's README allows that, and `scripts/verify` skips its checks once you remove it) | more non-secret personal settings | + +## What to put where + +| You want | Do this | +|----------|---------| +| Aliases, prompt, shell functions | A `files/home/.rc.d/NN-name.sh` drop-in plus its `files[]` record. `acq`'s `~/.profile` sources them in lexical order in every bash login shell, scripted `bash -lc` runs included, so add no loop of your own. Keep them POSIX and safe without a terminal; wrap interactive-only lines in `case $- in *i*) ... esac`. | +| zsh as your working shell | An `exec zsh` line in a `99-` drop-in, so it sorts last, guarded so it runs only in an interactive shell on a terminal, never swallows a `bash -lic '...'` command string (`BASH_EXECUTION_STRING`), and never re-fires in a `bash` you start from zsh (see `files/home/.rc.d/50-example.sh`), plus a startup step that gives `~/.zshrc` its own `~/.rc.d` loop (commented example in `spec.yaml`), since `acq`'s loop runs only in bash | +| Terminfo for your terminal | Ship the source (`infocmp -x`) and compile it in a startup step (`tic -x`); the commented example in `spec.yaml` shows how | +| Git preferences | One startup step per key, or ship `files/home/personal/gitconfig` and register it once with `include.path`; read its keys back with `git config --global --includes KEY`. A per-remote identity goes in a fragment behind `includeIf "hasconfig:remote.*.url:…"` (commented example in `spec.yaml`) | +| An OpenCode theme or keybinds | Edit `files/home/personal/tui.jsonc` | +| Config for your own tools | One `files[]` record per file under `files/home/.config//`. These are copies of your host config kept in sync by hand; expect sandbox-specific trims, because the sandbox runs the image's pinned version of the tool (see TROUBLESHOOTING). | +| Personal CLI tools | The image first: anything the team needs belongs there. Git config that points at such a tool (`core.pager = delta`, say) goes in its own fragment, registered with `include.path` inside the install step only while `command -v` finds the tool; otherwise a skipped install leaves git broken. For a tool only you want, and only if the image ships Nix, a guarded, non-fatal `nix profile install` startup step pinned to the image's nixpkgs rev (commented in `spec.yaml`). It is a startup step, not `phase: install`, so a failed download skips that tool instead of failing the create. | +| Extra egress | `caps.network.allow` (union across kits) | + +## What it does not carry, on purpose + +- **Whole shared files** (`~/.bashrc`, `~/.zshrc`, `~/.gitconfig`). Files are + whole-file and last-wins, so shipping one clobbers the image's copy and any + earlier layer's. Append-if-absent startup steps and includes compose. +- **Binary files.** Kit file delivery is for text; ship sources and generate + at startup. A compiled terminfo entry in `files[]`, for example, fails the + create with "Unterminated quoted string". +- **Secrets.** `acq secret set -g --host --env ` on the + host; the sandbox only ever sees a placeholder. +- **Team or global settings.** Whatever your team or the global layer already + owns stays there; carry only your own delta. + +## Verifying + +```bash +./scripts/verify # stacks on ../team-kit/ by default +TEAM_KIT=/path/to/team-kit ./scripts/verify +KEEP=1 ./scripts/verify # keep the sandbox and work dir for inspection +``` + +It validates both kits, then creates a throwaway sandbox through `acq`, which +applies the pinned built-in bundle plus, via `ACQ_EXTRA_KITS`, the team kit and +then this kit. It asserts that the global and team layers are intact under your +kit, that every live value above landed, that a bash login shell sources the +drop-in exactly once (twice means some layer added its own `~/.rc.d` loop), +that a scripted `bash -lc` still runs its command, and that both kits' +variables, files, and egress hosts coexist. When you point +`TEAM_KIT` at your team's kit, also set the `TEAM_*` variables +(`TEAM_CONFIG`, `TEAM_CONVENTIONS`, `TEAM_GIT_KEY`, `TEAM_GIT_VALUE`, +`TEAM_EGRESS_HOST`) to what it ships, and `UNLISTED_EGRESS_HOST` if your team +kit allows `example.com`. Each egress host must answer at `/` with a code +other than the backend's deny response (000 on msb, 403 on sbx). Extend +its clearly marked **PERSONAL** 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:`, how kits +re-apply, unlisted files, SSH egress); "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). diff --git a/integrations/isolation/acq-kits/examples/personal-kit/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/examples/personal-kit/TROUBLESHOOTING.md new file mode 100644 index 00000000..d2e09274 --- /dev/null +++ b/integrations/isolation/acq-kits/examples/personal-kit/TROUBLESHOOTING.md @@ -0,0 +1,129 @@ +# Troubleshooting — personal kit (template) + +Failure modes any copy inherits. Keep this file in your copy and grow it with +your 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. For team-layer problems, see the team kit's own +TROUBLESHOOTING. + +## Kit content missing (drop-ins and personal files absent) + +The shell that *created* the sandbox did not have your kit in +`ACQ_EXTRA_KITS` (missing from `PERSONAL_KITS`, or the rc did not source the +team kit's `env.sh`), so it never applied. Probe something only your kit +ships. An environment variable is not a reliable probe here: a team kit may +set the same one (`OPENCODE_TUI_CONFIG`, for example), so it can be non-empty +without your kit. + +```bash +acq exec -- sh -c 'ls ~/.rc.d/50-example.sh; git config --global alias.st' # both missing => the kit did not apply +``` + +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. + +## A team setting has the wrong value (your kit shadows it) + +Your kit is last, and `environment` (by name) and `files[]` (by path) are +last-wins (see the composition table in the pattern doc), so a variable +or file of yours with the same name or path as the team's replaces it +silently. Compare what the sandbox has with what the team kit ships: + +```bash +acq exec -- printenv OPENCODE_CONFIG +acq kit list # the pinned kits plus THIS shell's ACQ_EXTRA_KITS +``` + +`acq kit list` does not show what an existing sandbox was created with, so +compare against the `ACQ_EXTRA_KITS` you used at creation. + +Rename your file or variable, or drop the override unless the team kit's +README allows it. + +## Aliases or prompt missing in the shell + +`acq`'s `~/.profile` sources the drop-ins, so only bash **login** shells see +them: `acq shell` and `bash -l`, not a plain `bash` typed at a prompt. Check +that the loop is there and the drop-in landed: + +```bash +acq exec -- sh -c 'cat ~/.profile; ls ~/.rc.d' +``` + +`acq` writes that loop only when `~/.profile` is missing, empty, or its own; an +image that ships its own `~/.profile` must source `~/.rc.d` itself. A drop-in +present in `files/` but absent from `~/.rc.d` has no `files[]` record (see +"Backend differences" in the pattern doc). If you switched to zsh, `acq`'s loop +runs only in bash; give `~/.zshrc` its own loop. + +## Drop-ins run twice + +Some layer still appends its own `~/.rc.d` loop to `~/.bashrc`, and `acq`'s +`~/.profile` sources `~/.bashrc` before running its own loop. Find it and +remove that kit's startup step; `acq` already wires the directory: + +```bash +acq exec -- sh -c 'grep -n rc.d ~/.bashrc' +``` + +## A scripted command hangs or runs in the wrong shell + +A drop-in that runs `exec zsh` replaced a shell it should not have. `acq`'s +loop sources drop-ins in scripted `bash -lc` runs too, so the line needs its +own guards: keep it in a `99-` drop-in, wrap it in `case $- in *i*) ... esac`, +require a terminal (`[ -t 0 ]`), and require `BASH_EXECUTION_STRING` to be +empty: bash sets that variable whenever it runs a command string, so +`bash -lic '...'` still runs its command. The example in +`files/home/.rc.d/50-example.sh` carries all the guards. + +## 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 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. + +## Commits in the sandbox have the wrong author + +`acq` carries your host's git identity into the sandbox, but not when +`user.name` and `user.email` come from a file your host gitconfig includes +(`include.path` or `includeIf`). Check what the sandbox has: + +```bash +acq exec -- git config --global --includes --get user.email +``` + +Set the identity in your kit instead: a startup step per key, or the gitconfig +your kit ships and includes (see "Git preferences" in the README). + +## A tool warns about config keys that work on the host + +The sandbox runs the tool version from the image's pin, not the host's, so a +config file copied from your dotfiles can carry keys that version does not +know yet (or no longer knows). The tool then warns on every run. Trim the +sandbox copy to what the pinned version accepts, and note in the file that it +is a sandbox-specific copy of your host config. + +## 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 alias.st' +``` diff --git a/integrations/isolation/acq-kits/examples/personal-kit/files/home/.rc.d/50-example.sh b/integrations/isolation/acq-kits/examples/personal-kit/files/home/.rc.d/50-example.sh new file mode 100644 index 00000000..eeef813c --- /dev/null +++ b/integrations/isolation/acq-kits/examples/personal-kit/files/home/.rc.d/50-example.sh @@ -0,0 +1,33 @@ +# shellcheck shell=sh +# Personal shell drop-in. acq's ~/.profile sources it in every bash login +# shell, scripted `acq exec ... bash -lc` runs included, so keep it POSIX and +# safe without a terminal, and wrap interactive-only lines in +# `case $- in *i*) ... esac`. +# +# Drop-ins run in lexical order, hence the two-digit NN- prefix (10- sorts +# before 9-). Convention: 00-09 foundations (PATH, env others read), 10-49 +# tool setup, 50 independent items like this file, 90-99 must run last. +# Space by tens so inserting never renumbers. +# +# If a loop that zsh also runs sources these files, give its glob zsh's (N) +# qualifier, as the zsh step in spec.yaml does: without it, an empty ~/.rc.d +# prints "no matches found". + +# REPLACE: the live value scripts/verify checks. Keep it or update verify. +alias gst='git status' + +# Guard on tools installed at startup, so the first shell of a fresh sandbox +# degrades instead of erroring: +# command -v eza >/dev/null 2>&1 && alias ls='eza' + +# To make zsh your working shell (sandbox entry shells are bash), put this in +# a 99- file: exec replaces the shell, so anything sorting after it never +# runs in bash. acq's loop runs only in bash, so give zsh its own ~/.rc.d loop +# with the commented zsh startup step in spec.yaml. +# The guards, in order: an interactive shell (acq's loop also runs in +# scripted `bash -lc`); a terminal; not a command string (bash sets +# BASH_EXECUTION_STRING for one, so `acq exec ... bash -lic '...'` still runs +# its command, with or without a terminal); not zsh itself; and +# not a bash started from inside the handed-off zsh (RC_D_ZSH_HANDOFF is +# exported to it), so typing `bash` in zsh still gives you bash. +# case $- in *i*) [ -t 0 ] && [ -z "${BASH_EXECUTION_STRING:-}" ] && [ -z "${ZSH_VERSION:-}" ] && [ -z "${RC_D_ZSH_HANDOFF:-}" ] && command -v zsh >/dev/null 2>&1 && RC_D_ZSH_HANDOFF=1 exec zsh ;; esac diff --git a/integrations/isolation/acq-kits/examples/personal-kit/files/home/personal/tui.jsonc b/integrations/isolation/acq-kits/examples/personal-kit/files/home/personal/tui.jsonc new file mode 100644 index 00000000..675bf3dd --- /dev/null +++ b/integrations/isolation/acq-kits/examples/personal-kit/files/home/personal/tui.jsonc @@ -0,0 +1,9 @@ +{ + // Personal OpenCode TUI settings, loaded via OPENCODE_TUI_CONFIG. Theme and + // keybinds live here, not in opencode.jsonc: OpenCode strips them from + // opencode.jsonc on load. + "$schema": "https://opencode.ai/tui.json", + + // REPLACE with your theme; "system" follows the terminal's own palette. + "theme": "system" +} diff --git a/integrations/isolation/acq-kits/examples/personal-kit/scripts/verify b/integrations/isolation/acq-kits/examples/personal-kit/scripts/verify new file mode 100755 index 00000000..46f21527 --- /dev/null +++ b/integrations/isolation/acq-kits/examples/personal-kit/scripts/verify @@ -0,0 +1,318 @@ +#!/usr/bin/env bash +# +# verify — host-side, end-to-end check for the personal-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 proves the three-layer stack. 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, the TEAM kit followed by this kit. It then asserts: +# 1. the global layer is intact (USAi merged into OpenCode's global config); +# 2. the team layer is intact under this kit (its env var, conventions, +# startup command, and egress host all survive); +# 3. every live extension-point value in spec.yaml landed (so both kits' +# environment and files[] coexist), a bash login shell sources the shell +# drop-in exactly once, and a scripted bash -lc still runs; +# 4. caps.network.allow is the union of both kits' hosts. +# The per-field rules themselves (last wins, append order) are asserted by +# the team-kit template's verify against a competing fixture. +# Extend the PERSONAL section as your kit grows: one assertion per mechanism. +# +# The team kit defaults to the team-kit template next to this one. Point +# TEAM_KIT at your team's real kit and set the TEAM_* variables to what it +# ships (each defaults to the team-kit template's value). If your team kit +# allows example.com, set UNLISTED_EGRESS_HOST to a host neither kit allows. +# Each kit's egress host must answer at / with a code other than the +# backend's deny response (000 on msb, 403 on sbx), or it reads as denied. +# The personal host must also be one the team kit does NOT allow; otherwise +# the check passes on the team's entry and proves nothing about yours. +# +# Usage (from anywhere): +# path/to/personal-kit/scripts/verify +# TEAM_KIT=/path/to/team-kit …/verify # stack on your team's kit +# TEAM_CONFIG=… TEAM_EGRESS_HOST=… …/verify # that kit's values (see below) +# PERSONAL_EGRESS_HOST=… …/verify # your own egress host, once replaced +# KEEP=1 …/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")" +TEAM_KIT="${TEAM_KIT:-$(dirname "$KIT_DIR")/team-kit}" +SBX_NAME="${KIT_NAME}-verify-$$" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/${KIT_NAME}-verify.XXXXXX")" +WORKSPACE="$WORK/workspace" +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 kit ships (mirror spec.yaml; update both when you replace one). +PERSONAL_TUI=/home/agent/personal/tui.jsonc +PERSONAL_DROPIN=/home/agent/.rc.d/50-example.sh +PERSONAL_ALIAS=gst +PERSONAL_GIT_KEY=alias.st +PERSONAL_GIT_VALUE=status +PERSONAL_EGRESS_HOST="${PERSONAL_EGRESS_HOST-example.net}" +# Values the team kit ships; override them for your team's kit, or set one to +# empty to skip its check when the team kit has no such thing. Defaults are +# the team-kit template's. +TEAM_CONFIG="${TEAM_CONFIG-/home/agent/team-config/opencode.jsonc}" +TEAM_CONVENTIONS="${TEAM_CONVENTIONS-team-conventions.md}" +TEAM_GIT_KEY="${TEAM_GIT_KEY-push.autoSetupRemote}" +TEAM_GIT_VALUE="${TEAM_GIT_VALUE-true}" +TEAM_EGRESS_HOST="${TEAM_EGRESS_HOST-example.org}" +# Allowed by neither kit: the egress probe's negative control. +UNLISTED_EGRESS_HOST="${UNLISTED_EGRESS_HOST:-example.com}" + +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"; } + +# True if SPEC has HOST as an uncommented list item, exactly (optionally +# quoted, optionally followed by a comment). A plain substring match would +# also hit commented examples and longer hosts that start with HOST. +spec_lists_host() { + _re="$(printf '%s' "$2" | sed 's/[.[\*^$+?(){}|/]/\\&/g')" + grep -qE "^[[:space:]]*-[[:space:]]+[\"']?${_re}[\"']?([[:space:]]|$)" "$1" +} + +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 +if [ -f "$TEAM_KIT/spec.yaml" ]; then + ok "team kit found ($TEAM_KIT)" +else + bad "team kit not found at $TEAM_KIT (set TEAM_KIT)"; exit 1 +fi +# Both kit paths go into ACQ_EXTRA_KITS, which is whitespace-separated. +case "$TEAM_KIT$KIT_DIR" in + *[[:space:]]*) bad "a kit path contains whitespace; ACQ_EXTRA_KITS is whitespace-separated (move the kit)"; exit 1 ;; +esac + +info "1. Kits validate (hybrid/v1)" +for k in "$TEAM_KIT" "$KIT_DIR"; do + if "$ACQ" kit validate "$k" >/dev/null 2>&1; then + ok "$(basename "$k") validates" + else + bad "$(basename "$k") failed validation — run: $ACQ kit validate $k" + fi +done + +info "2. Create a sandbox through acq (built-in bundle + team kit + this kit)" +mkdir -p "$WORKSPACE" && git init -q "$WORKSPACE" +echo " creating $SBX_NAME (workspace $WORKSPACE)..." +ACQ_EXTRA_KITS="$TEAM_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 LAST +# startup command's artifact, this kit's git key: commands append in kit +# order, so once it is set every earlier step has run. If you add or +# uncomment a step AFTER it in spec.yaml (the terminfo or Nix examples), poll +# for that step's artifact instead, or its checks may run before it finishes. +printf ' waiting for startup steps to settle' +settled=0 +for _ in $(seq 1 120); do + [ "$(in_sbx "git config --global $PERSONAL_GIT_KEY 2>/dev/null")" = "$PERSONAL_GIT_VALUE" ] && { settled=1; break; } + printf '.'; sleep 1 +done +printf '\n' +[ "$settled" = 1 ] || echo " note: $PERSONAL_GIT_KEY never became '$PERSONAL_GIT_VALUE' within 120s; later checks may run before startup finished. If you replaced that step, update PERSONAL_GIT_KEY/PERSONAL_GIT_VALUE." >&2 + +info "3. Global layer intact" +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 "4. Team layer intact under this kit" +if [ -n "$TEAM_CONFIG" ]; then + cfg="$(in_sbx 'printf "%s" "${OPENCODE_CONFIG:-}"')" + if [ "$cfg" = "$TEAM_CONFIG" ]; then + ok "team OPENCODE_CONFIG survives ($cfg)" + else + bad "team OPENCODE_CONFIG unexpected: '${cfg:-}' (shadowed by this kit, or the team kit did not apply)" + fi +else skip "team OPENCODE_CONFIG (TEAM_CONFIG empty)"; fi +if [ -n "$TEAM_CONVENTIONS" ]; then + if printf '%s' "$eff" | grep -qF "$TEAM_CONVENTIONS"; then + ok "team conventions still in the effective config instructions" + else + bad "$TEAM_CONVENTIONS missing from the effective config instructions" + fi +else skip "team conventions (TEAM_CONVENTIONS empty)"; fi +if [ -n "$TEAM_GIT_KEY" ]; then + if [ "$(in_sbx "git config --global $TEAM_GIT_KEY 2>/dev/null")" = "$TEAM_GIT_VALUE" ]; then + ok "team startup command ran ($TEAM_GIT_KEY = $TEAM_GIT_VALUE)" + else + bad "team startup command did not run, or this kit overrode it" + fi +else skip "team startup command (TEAM_GIT_KEY empty)"; fi + +info "5. Live values landed" +# The TUI override is optional: drop it from spec.yaml when your team's README +# does not allow a personal OPENCODE_TUI_CONFIG, and these checks skip. +if grep -Eq '^[[:space:]]+OPENCODE_TUI_CONFIG:' "$KIT_DIR/spec.yaml"; then + tui="$(in_sbx 'printf "%s" "${OPENCODE_TUI_CONFIG:-}"')" + if [ "$tui" = "$PERSONAL_TUI" ]; then + ok "OPENCODE_TUI_CONFIG = $tui" + else + bad "OPENCODE_TUI_CONFIG unexpected: '${tui:-}'" + fi + if [ "$(in_sbx "test -s $PERSONAL_TUI && echo y")" = "y" ]; then + ok "TUI config file present" + else + bad "TUI config file missing" + fi +else + skip "OPENCODE_TUI_CONFIG override (not in spec.yaml)" +fi +if [ "$(in_sbx "test -s $PERSONAL_DROPIN && echo y")" = "y" ]; then + ok "shell drop-in present" +else + bad "shell drop-in missing" +fi +# The settle poll above already probed this kit's git key. +if [ "$settled" = 1 ]; then + ok "startup command ran ($PERSONAL_GIT_KEY = $PERSONAL_GIT_VALUE)" +else + bad "startup command did not run" +fi +# acq's ~/.profile sources ~/.rc.d in every bash login shell. The drop-in must +# be sourced there exactly once: twice means some layer added its own loop +# (a ~/.bashrc append, the pre-v4 practice), which `acq`'s ~/.profile then +# runs as well. Counted from bash's trace of its `. FILE` lines, so the check +# works whatever the drop-in contains. Last line only for the alias probe: +# ~/.bashrc or a drop-in may print its own output first. +if [ "$(in_sbx "bash -lic 'alias $PERSONAL_ALIAS >/dev/null 2>&1 && echo y' 2>/dev/null" | tail -n1)" = "y" ]; then + ok "drop-in sourced by a bash login shell (alias $PERSONAL_ALIAS)" +else + bad "drop-in not sourced by a bash login shell (does ~/.profile run the ~/.rc.d loop?)" +fi +sourced="$(in_sbx "bash -lixc : 2>&1 >/dev/null | grep -cE '^[+]+ (\\.|source) $PERSONAL_DROPIN\$'")" +case "$sourced" in + 1) ok "drop-in sourced exactly once per login shell" ;; + *) bad "drop-in sourced ${sourced:-?} times per login shell (expected 1; a layer adds its own ~/.rc.d loop, see TROUBLESHOOTING.md)" ;; +esac +# Drop-ins also run in scripted login shells, so one that execs another shell +# without its guards would swallow the script. +if [ "$(in_sbx "bash -lc 'echo ran' 2>/dev/null" | tail -n1)" = "ran" ]; then + ok "a scripted bash -lc still runs its command" +else + bad "a scripted bash -lc did not run its command (a drop-in exec'd another shell?)" +fi + +info "6. Egress union (docs/scope-layers.md)" +# environment and files[] coexistence is already covered: sections 4 and 5 +# assert both kits' variables and files. +# caps.network.allow: union. A host listed by neither kit must get a deny +# code, 000 or 403 (the negative control); each kit's host must get neither. +if [ "$SKIP_NETWORK" = "1" ]; then + skip "caps.network.allow union (SKIP_NETWORK=1)" +else + code_denied="$(probe_host "$UNLISTED_EGRESS_HOST")" + # The backends' deny answers: 000 (no connection, msb) or 403 (proxy, sbx). + 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 team personal; do + if [ "$side" = team ]; then + host="$TEAM_EGRESS_HOST" + [ -n "$host" ] || { skip "caps.network.allow: team host (TEAM_EGRESS_HOST empty)"; continue; } + else + host="$PERSONAL_EGRESS_HOST" + [ -n "$host" ] || { skip "caps.network.allow: personal host (PERSONAL_EGRESS_HOST empty)"; continue; } + spec_lists_host "$KIT_DIR/spec.yaml" "$host" \ + || { skip "caps.network.allow: personal host ($host not in spec.yaml)"; continue; } + spec_lists_host "$TEAM_KIT/spec.yaml" "$host" \ + && { skip "caps.network.allow: personal host ($host is also in the team kit, so this proves nothing)"; continue; } + fi + code="$(probe_host "$host")" + case "$code" in + 000|403|"") bad "caps.network.allow: $side host denied ($host -> ${code:-none}): the union is broken, the host is offline, or its / answers with a deny code (pick another host)" ;; + *) ok "caps.network.allow: $side host admitted ($host -> $code)" ;; + esac + done + fi +fi + +info "7. PERSONAL checks (extend as your kit grows)" +# One assertion per mechanism your kit adds: a drop-in's alias or function, a +# compiled terminfo entry, an installed tool, an included gitconfig... Check a +# key from an included gitconfig with `git config --global --includes KEY`: +# naming one file (--global) turns include.path off, so it comes back empty. +skip "no personal 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/personal-kit/spec.yaml b/integrations/isolation/acq-kits/examples/personal-kit/spec.yaml new file mode 100644 index 00000000..2a4abbc2 --- /dev/null +++ b/integrations/isolation/acq-kits/examples/personal-kit/spec.yaml @@ -0,0 +1,181 @@ +# spec.yaml — personal-kit: a TEMPLATE for your own acq mixin kit (hybrid/v1). +# +# Not applied by acq. Copy this directory into a repo you own (a dotfiles repo +# is ideal), rename BOTH the directory and `name:` below, replace every value +# marked REPLACE, and grow it. 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 (commented YAML is never +# schema-checked, so a template made of comments would rot silently). The +# scripts/verify next to this file asserts each value landed and that the +# team layer below it survives. +# +# Apply it LAST, after your team's kit (later kits win on overlap): list it +# in PERSONAL_KITS before your rc sources the team kit's env.sh (README.md). +# acq kit validate /path/to/dotfiles/personal-kit +schemaVersion: "hybrid/v1" +kind: mixin +# REPLACE name (kebab-case, equal to the directory name), displayName, and +# description. +name: personal-kit +displayName: Personal kit (template) +description: > + Starting point for a personal scope-layer kit: shell drop-ins, git + preferences, and a TUI theme, plus one live value per extension point + (egress, files, startup commands, environment) to replace. + +# --- Egress your personal tools need ----------------------------------------- +# Hosts beyond the global and team layers. Lists UNION across kits; under org +# governance, org rules win. Egress is fixed at sandbox creation on every +# backend: recreate after changing it. +caps: + network: + allow: + # REPLACE with the hosts your own tools need, or delete the block. The + # live value is an IANA-reserved example domain: harmless, and allowed + # by no global kit, so scripts/verify can prove this entry took effect. + - example.net + +# --- Files ------------------------------------------------------------------- +# List EVERY payload under files/ with its in-guest absolute path: msb +# materializes only the listed records. Files are whole-file and LAST WINS by +# path, so never ship ~/.bashrc, ~/.zshrc, or ~/.gitconfig: the image and the +# layers below own them. Ship drop-ins and includes instead. Text only: ship +# sources and generate binaries at startup (see the terminfo example below). +files: + # Shell drop-ins: files/home/.rc.d/NN-name.sh. acq's ~/.profile sources + # them in lexical order in every bash login shell, scripted `bash -lc` runs + # included, so never add a ~/.bashrc loop of your own (drop-ins would run + # twice). Add one files[] record per drop-in. + - path: /home/agent/.rc.d/50-example.sh + mode: "0644" + source: files/home/.rc.d/50-example.sh + description: Example shell drop-in (sourced by bash login shells) — REPLACE the content + - path: /home/agent/personal/tui.jsonc + mode: "0644" + source: files/home/personal/tui.jsonc + description: Personal OpenCode TUI settings (read via OPENCODE_TUI_CONFIG below) + +# --- Commands ---------------------------------------------------------------- +# Commands APPEND across kits, so yours run after the team's; `startup` +# re-runs at every sandbox start. ALWAYS set `user:` ("1000" for the agent +# user). Keep each step idempotent and non-fatal. Write argv as a block list +# (one `- arg` per line) or one `- |` block scalar, never `[a, b]`. +commands: + # zsh as your working shell: acq's ~/.rc.d loop runs only in bash, so give + # ~/.zshrc its own loop. No `$-` guard is needed (zsh reads .zshrc only in + # interactive shells). `(N)` is zsh's null-glob qualifier: without it an + # empty ~/.rc.d aborts with "no matches found". zsh sourcing the 99- + # `exec zsh` drop-in is safe, because that line guards on ZSH_VERSION and + # RC_D_ZSH_HANDOFF. + # - phase: startup + # user: "1000" + # description: Source ~/.rc.d drop-ins from zsh + # command: + # - sh + # - -c + # - | + # grep -Eqs '^[^#]*\.rc\.d' "$HOME/.zshrc" && exit 0 + # [ -s "$HOME/.zshrc" ] && [ -n "$(tail -c1 "$HOME/.zshrc")" ] \ + # && printf '\n' >> "$HOME/.zshrc" + # printf '%s\n' '# ~/.rc.d drop-ins (personal kit)' \ + # 'for f in "$HOME"/.rc.d/*.sh(N); do . "$f"; done' \ + # >> "$HOME/.zshrc" \ + # || echo "could not wire ~/.rc.d into ~/.zshrc" >&2 + # exit 0 + # Live value: one git preference. REPLACE with yours: one step per key, or + # ship files/home/personal/gitconfig and register it once in a `sh -c` step + # (include.path is multi-valued: a plain `git config include.path` errors + # once two values exist, so check with --get-all and add with --add): + # git config --global --get-all include.path | grep -qxF /home/agent/personal/gitconfig \ + # || git config --global --add include.path /home/agent/personal/gitconfig + # A per-remote identity (a work email for the team VCS, say) goes in its own + # fragment, included from that gitconfig only for matching remotes. Write + # the host part as `*/**` (a bare `**` only spans directories next to a + # slash), and match both the SSH and HTTPS forms, since guest git can report + # either: + # [includeIf "hasconfig:remote.*.url:git@git.example.gov:*/**"] + # path = /home/agent/personal/gitconfig-work + # [includeIf "hasconfig:remote.*.url:https://git.example.gov/**"] + # path = /home/agent/personal/gitconfig-work + - phase: startup + user: "1000" + description: Git alias (git st) + command: + - git + - config + - --global + - alias.st + - status + # Terminfo for your terminal (kitty, ghostty, ...): ship the SOURCE, from + # `infocmp -x xterm-ghostty > files/home/.terminfo-src/xterm-ghostty.ti`, + # list it in files[], and compile it here. tic writes ~/.terminfo. + # - phase: startup + # user: "1000" + # description: Compile terminfo + # command: + # - sh + # - -c + # - | + # infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + # tic -x "$HOME/.terminfo-src/xterm-ghostty.ti" || echo "tic failed; skipping" >&2 + # exit 0 + # + # Personal CLI tools, only if your image ships Nix (the devenv image under + # integrations/isolation/images/devenv/ does). This is the one exception to + # "tools belong in the image": a tool only you want, installed in a STARTUP + # step that skips on failure, never `phase: install` (a failed create-time + # install fails the whole create). Pin to the image's NIXPKGS_REV so + # installs hit the binary cache: copy the commit from that Dockerfile's ARG + # (it is not set inside the sandbox). Allow cache.nixos.org and + # codeload.github.com above; api.github.com, which the github: fetch also + # uses, is already allowed by the global playbook kit. Each tool is + # non-fatal, and drop-ins must guard on the binary existing. So must git + # config that names the tool: ship it as its own fragment (e.g. + # files/home/personal/gitconfig-delta) and register its include.path in this + # step only while the tool is present, or a skipped install leaves git + # pointing at a missing pager. Team-wide tools belong in the image. + # - phase: startup + # user: "1000" + # description: Install personal CLI tools (nix) + # command: + # - sh + # - -c + # - | + # [ -r /etc/profile.d/nix.sh ] && . /etc/profile.d/nix.sh + # command -v nix >/dev/null 2>&1 || exit 0 + # rev='REPLACE-with-the-image-NIXPKGS_REV-commit' + # # PACKAGE or PACKAGE:BINARY when the names differ (ripgrep's binary + # # is rg), or the skip check never finds it and it reinstalls at + # # every start. + # for spec in starship eza delta ripgrep:rg; do + # tool=${spec%%:*} bin=${spec##*:} + # command -v "$bin" >/dev/null 2>&1 && continue + # nix profile install "github:NixOS/nixpkgs/${rev}#${tool}" \ + # || echo "nix install of ${tool} failed; skipping" >&2 + # done + # if command -v delta >/dev/null 2>&1; then + # git config --global --get-all include.path | grep -qxF /home/agent/personal/gitconfig-delta \ + # || git config --global --add include.path /home/agent/personal/gitconfig-delta + # fi + # exit 0 + +# --- Environment ------------------------------------------------------------- +# Non-secret plain strings only; secrets go through `acq secret set`, never a +# kit. LAST WINS by name, and yours is the last kit, so a variable here +# silently shadows the team's. Override a team variable only if the team kit's +# README says a personal kit may. OPENCODE_TUI_CONFIG holds TUI settings +# (theme, keybinds) and is separate from OPENCODE_CONFIG, which the team layer +# owns. It is usually personal, but a team kit may ship its own tui.jsonc +# through it: then this line replaces the team's, so keep it only if the team +# kit's README says a personal kit may override it. +environment: + OPENCODE_TUI_CONFIG: /home/agent/personal/tui.jsonc + +# 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 29e6b3ed..b3242f7c 100644 --- a/integrations/isolation/docs/decisions/0005-kit-templates.md +++ b/integrations/isolation/docs/decisions/0005-kit-templates.md @@ -77,7 +77,10 @@ Adopt **templates (option 1)** and **placement (B)**. template via `ACQ_EXTRA_KITS`, and asserts the per-field rules the pattern doc states: `environment` last-wins, `files[]` last-wins by path, `commands[]` append in order, `caps.network.allow` union. It uses only `acq` - verbs, so the same script runs on every backend. + verbs, so the same script runs on every backend. The personal-kit + template's verify instead stacks it on a team kit and asserts the + three-layer stack (global and team layers intact, both kits' values + coexisting); the per-field rules are proven once, by the team template. - **Placement (B).** `acq-kits/examples/` is a *container of templates*, not a kit. `validate-kits.py` skips it in the kit walk, validates each `examples//` with the same per-kit checks (schema, `files[].source` @@ -103,7 +106,11 @@ Adopt **templates (option 1)** and **placement (B)**. coupling, agent-specific config beyond a single OpenCode example of the config-tier mechanism (`OPENCODE_CONFIG` pointing at a team file), any workaround specific to its repos, and any internal URL. The reference - implementation is named, not linked. + implementation is named, not linked. One deliberate exception to the Nix + exclusion: the personal-kit template carries a *commented* Nix tool-install + step, tied to this repo's devenv image and its `NIXPKGS_REV`, as the single + sanctioned case of a personal tool installed at startup rather than baked + into the image. It is not live config and no team coupling comes with it. ## Consequences @@ -137,7 +144,8 @@ Adopt **templates (option 1)** and **placement (B)**. ## Links - Pattern doc: [`../scope-layers.md`](../scope-layers.md). -- Template: [`../../acq-kits/examples/team-kit/`](../../acq-kits/examples/team-kit/). +- Templates: [`../../acq-kits/examples/team-kit/`](../../acq-kits/examples/team-kit/), + [`../../acq-kits/examples/personal-kit/`](../../acq-kits/examples/personal-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 index 86612369..97998d4a 100644 --- a/integrations/isolation/docs/scope-layers.md +++ b/integrations/isolation/docs/scope-layers.md @@ -208,8 +208,8 @@ global layer does. | 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. | +| 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. The personal-kit template's `scripts/verify` checks that its drop-in is sourced exactly once. | +| 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. Also guard the `exec` on a terminal (`[ -t 0 ]`), on `BASH_EXECUTION_STRING` being empty (bash sets it whenever it runs a command string, so `bash -lic '...'` still runs its command), on `ZSH_VERSION` being empty, and on an exported handoff marker, or zsh sourcing the same drop-in re-execs itself forever. The personal-kit template's `50-example.sh` carries the full guard. The `acq` loop runs only in bash: give `~/.zshrc` its own loop with an append-if-absent startup step (the personal-kit template has a commented one). | | 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 | @@ -218,7 +218,10 @@ global layer does. ### Neither -- **Tools and toolchains.** Bake them into the image (`ACQ_IMAGE`). +- **Tools and toolchains.** Bake them into the image (`ACQ_IMAGE`). The one + exception is a tool only you want in your personal kit: a guarded, + non-fatal startup step, never `phase: install` (the personal-kit template + shows one). - **Secrets.** Host-side only. - **Whole shared files** (`~/.bashrc`, `~/.zshrc`, `~/.gitconfig`). - **Binary files.** Kit file delivery is for text. Ship sources and generate @@ -407,8 +410,12 @@ future teammates inherit the *why*, not just the YAML. 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. + above. +- [`../acq-kits/examples/personal-kit/`](../acq-kits/examples/personal-kit/) + is the same for the personal layer: a shell drop-in, a git preference, and an OpenCode TUI theme through + `OPENCODE_TUI_CONFIG`. Its `scripts/verify` stacks it on a team kit (the + team-kit template by default) and asserts the three-layer stack: the global + and team layers survive under it, and both kits' values coexist. - 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. diff --git a/scripts/tests/test_kit_rc_d_wiring.py b/scripts/tests/test_kit_rc_d_wiring.py new file mode 100644 index 00000000..77425787 --- /dev/null +++ b/scripts/tests/test_kit_rc_d_wiring.py @@ -0,0 +1,88 @@ +"""Kits deliver `~/.rc.d` drop-ins; acq wires the bash loop. + +acq writes `~/.profile` so every bash login shell sources `~/.rc.d/*.sh`, and +that profile also sources `~/.bashrc`. A kit command that adds its own loop to +a bash startup file therefore makes every drop-in run twice. The first test +keeps any such command out of the repo's kits and templates. + +The personal-kit template still carries a commented zsh step (acq's loop runs +only in bash) that skips when `~/.zshrc` already names `~/.rc.d`. Its +`grep -E` pattern passes through YAML and shell quoting, and an over-escaped +version (`\\\\.rc\\\\.d`, which looks for literal backslashes) once silently +stopped matching any real loop, so the remaining tests run it through the real +`grep -E`. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[2] +KITS = ROOT / "integrations" / "isolation" / "acq-kits" +SPECS = sorted(KITS.glob("*/spec.yaml")) + sorted(KITS.glob("examples/*/spec.yaml")) +PERSONAL_SPEC = KITS / "examples" / "personal-kit" / "spec.yaml" +BASH_STARTUP_FILES = (".bashrc", ".bash_profile", ".bash_login", ".profile") + +# Each `grep -Eqs ''` in the spec that looks for `.rc.d`, commented +# examples included (the zsh step ships commented out). +PATTERNS = [p for p in re.findall(r"grep -Eqs '([^']*)'", PERSONAL_SPEC.read_text()) if "rc" in p] + +LOOP_LINES = [ + 'for f in "$HOME"/.rc.d/*.sh(N); do . "$f"; done', + 'case $- in *i*) for f in "$HOME"/.rc.d/*.sh; do [ -r "$f" ] && . "$f"; done ;; esac', +] +NON_LOOP_LINES = [ + "# ~/.rc.d drop-ins (personal kit)", + "export PATH=/usr/local/bin:$PATH", + # Only matches if the dots are unescaped wildcards. + "alias arcbd=true", +] + + +def _command_text(command) -> str: + return " ".join(command) if isinstance(command, list) else str(command) + + +@pytest.mark.parametrize("spec", SPECS, ids=lambda p: str(p.parent.relative_to(KITS))) +def test_no_kit_wires_rc_d_into_bash(spec): + commands = (yaml.safe_load(spec.read_text()) or {}).get("commands") or [] + for cmd in commands: + text = _command_text(cmd.get("command", "")) + if ".rc.d" in text: + hit = [f for f in BASH_STARTUP_FILES if f in text] + assert not hit, f"{cmd.get('description')!r} wires ~/.rc.d into {hit}; acq's ~/.profile already does" + + +def test_specs_found(): + assert PERSONAL_SPEC in SPECS + assert len(SPECS) > 2 + + +def _matches(pattern: str, line: str, tmp_path: Path) -> bool: + rc = tmp_path / "rc" + rc.write_text(line + "\n") + return subprocess.run(["grep", "-Eqs", pattern, str(rc)], check=False).returncode == 0 + + +def test_spec_carries_the_zsh_pattern(): + assert len(PATTERNS) == 1, PATTERNS + + +@pytest.mark.skipif(shutil.which("grep") is None, reason="grep not available") +@pytest.mark.parametrize("pattern", PATTERNS) +@pytest.mark.parametrize("line", LOOP_LINES) +def test_pattern_matches_a_wired_loop(pattern, line, tmp_path): + assert _matches(pattern, line, tmp_path) + + +@pytest.mark.skipif(shutil.which("grep") is None, reason="grep not available") +@pytest.mark.parametrize("pattern", PATTERNS) +@pytest.mark.parametrize("line", NON_LOOP_LINES) +def test_pattern_ignores_comments_and_lookalikes(pattern, line, tmp_path): + assert not _matches(pattern, line, tmp_path)