diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25718c8..4e7a14a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,19 @@ jobs: with: node-version: "24" package-manager-cache: false - - name: Provision pinned configuration parser explicitly - run: ./setup-sync.sh + # --ci requires zsh for the zsh launcher and completion cases; dash, the + # image's /bin/sh, runs install.sh in the standalone end-to-end test, + # and GNU Stow builds its folded Stow layouts. standalone-e2e.sh skips + # those cases without Stow, so the step fails unless their heading is in + # the output; `shell: bash` adds pipefail, so a failing run.sh fails too. + - name: Install zsh and GNU Stow + run: sudo apt-get update && sudo apt-get install --yes --no-install-recommends zsh stow - name: Run behavior tests with required CI prerequisites - run: /bin/bash ./tests/run.sh --ci + shell: bash + run: | + /bin/bash ./tests/run.sh --ci | tee "$RUNNER_TEMP/tests.log" + grep -Fqx '==> refusals: destinations in directories GNU Stow folded into another repository' "$RUNNER_TEMP/tests.log" || + { echo '::error::standalone-e2e.sh skipped its folded Stow cases.'; exit 1; } # The same steps pre-commit/action@v3.0.1 runs, without its unmaintained # actions/cache@v4 pin (Node 20). Same cache path and key. - name: Install pre-commit @@ -64,9 +73,12 @@ jobs: with: node-version: "24" package-manager-cache: false - - name: Provision pinned configuration parser explicitly - run: ./setup-sync.sh - - name: Run behavior tests with system Bash and BSD utilities + # macOS ships zsh, which --ci requires; install it only if the image lacks it. + - name: Check for zsh + run: command -v zsh || brew install zsh + # The tests run under /bin/bash 3.2, and the standalone end-to-end test + # runs install.sh under /bin/sh and drives the launchers through its links. + - name: Run behavior tests with system Bash, /bin/sh and BSD utilities run: /bin/bash ./tests/run.sh --ci - name: Check that tests left no file behind run: | @@ -84,16 +96,12 @@ jobs: # The runner's default core.autocrlf stays on, so the LF check below # proves .gitattributes, not a runner setting. - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - with: - python-version: "3.12" + # run.ps1 runs only the Node suite of the Claude Code helpers. - uses: actions/setup-node@v6 with: node-version: "24" package-manager-cache: false - - name: Provision pinned configuration parser explicitly - run: ./setup-sync.ps1 - # stow-all.ps1 runs the POSIX sh helpers under Git Bash, where a CRLF + # The launchers and completions are shell scripts, where a CRLF # `set -eu` fails. - name: Check that the working tree has LF line endings run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b94e66f..4a66ce3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,26 +34,27 @@ repos: hooks: - id: shellcheck args: [--severity=warning] - files: (\.sh$|/\.local/bin/[^/]+-sync$|/\.local/bin/(codex|claude)-as$|/bash-completion/completions/.*$) + files: (\.sh$|^bin/(codex|claude)-as$|^completions/bash/.*$) types: [file] + # The upstream hook selects the shell type, which identify does not give the + # extension-less completions; each hook checks every file its pattern names + # instead. - repo: https://github.com/scop/pre-commit-shfmt rev: v3.14.1-1 hooks: - id: shfmt args: [-w, -i, "4", -ci, -ln, bash] - files: (\.sh$|/\.local/bin/codex-as$|/bash-completion/completions/.*$) - exclude: ^(setup-sync\.sh|lib/sync-runtime\.sh)$ + files: (\.sh$|^bin/codex-as$|^completions/bash/.*$) + exclude: ^install\.sh$ + types: [file] - id: shfmt args: [-w, -i, "4", -ci, -ln, posix] - files: (/\.local/bin/([^/]+-sync|claude-as)$|^(setup-sync\.sh|lib/sync-runtime\.sh)$) + files: ^(bin/claude-as|install\.sh)$ types: [file] - id: shfmt args: [-w, -i, "4", -ci, -ln, zsh] - files: /custom/completions/_[^/]+$ - # The upstream hook selects the shell type, which identify does not - # give the extension-less completions; every file the pattern names - # is checked instead. + files: ^completions/zsh/_[^/]+$ types: [file] - repo: local @@ -64,47 +65,45 @@ repos: entry: '[^\x09\x0a\x0d\x20-\x7e]' types: [file] files: '(?i)(\.(md|markdown|mdx|rst|txt)$|(^|/)(README|CHANGELOG|CONTRIBUTING|AUTHORS|LICENSE|NOTICE)(\.[^/]*)?$)' - # The engine suites below load lib/; in dotfiles the installer and - # doctor hooks covered engine edits, and those hooks do not exist here. - - id: structured-config-sync - name: structured configuration merge and safe writes - entry: tests/config-sync.sh - language: script - pass_filenames: false - files: ^(lib/.*|requirements-sync\.txt|setup-sync\.(sh|ps1)|tests/(test_config_sync\.py|config-sync\.sh|fixtures/codex-config\.toml)|common/(claude|codex)/\.local/bin/.*)$ - - id: ai-config-sync - name: ai config and rules sync - entry: tests/ai-config-sync.sh - language: script - pass_filenames: false - files: ^(lib/.*|common/(claude|codex)/.*|tests/(ai-config-sync\.sh|fixtures/(claude-settings\.json|portable\.rules)))$ - id: claude-customizations name: Claude status line, notifications and Git hook guard entry: node --test tests/claude-customizations.cjs language: system pass_filenames: false - files: ^(common/claude/.*|tests/(claude-customizations\.cjs|fixtures/claude-settings\.json))$ - - id: codex-config-sync - name: codex config sync behavior - entry: tests/codex-config-sync.sh - language: script - pass_filenames: false - files: ^(lib/.*|common/codex/\.local/bin/codex-config-sync|tests/codex-config-sync\.sh)$ + files: ^(helpers/.*|tests/(claude-customizations\.cjs|fixtures/claude-settings\.json))$ - id: codex-as name: Codex account views and native sharing (isolated Bash/zsh) entry: tests/codex-as.sh language: script pass_filenames: false - files: ^(common/codex/\.local/(bin/codex-as|lib/codex-as/.*)|tests/(codex-as\.sh|test_codex_as\.py|test_codex_sharing_native\.py|fixtures/(zsh|bash)_aliases))$ + files: ^(bin/codex-as|lib/(codex-as|coding-agent-kit)/.*|tests/(codex-as\.sh|test_codex_as\.py|test_codex_sharing_native\.py|fixtures/(zsh|bash)_aliases))$ - id: account-enrollment name: account enrollment and completion (isolated HOME and native CLI fakes) entry: tests/account-enrollment.sh language: script pass_filenames: false - files: ^(common/(codex|claude)/.*|tests/(account-enrollment\.sh|test_account_enrollment\.py))$ + files: ^(bin/.*|lib/.*|completions/.*|tests/(account-enrollment\.sh|test_account_enrollment\.py|test_codex_as\.py))$ + - id: config + name: kit configuration, launcher location and metadata queries (isolated HOME) + entry: tests/config.sh + language: script + pass_filenames: false + files: ^(bin/.*|lib/.*|completions/.*|docs/account-launchers\.md|tests/(config\.sh|test_config\.py|test_codex_as\.py))$ - id: test-entrypoints - name: test entry points and dotfiles knob hygiene + name: test entry points and kit knob hygiene entry: tests/test-entrypoints.sh language: script pass_filenames: false - files: ^(tests/.*\.(sh|ps1)|lib/.*|common/.*)$ + files: ^(tests/(.*\.(sh|ps1)|test_config\.py)|bin/.*|lib/.*|completions/.*|helpers/.*|install\.sh)$ + - id: contract + name: public contract (paths, modes, no symlinks, CI job names) + entry: tests/contract.sh + language: script + pass_filenames: false + files: ^(bin/.*|lib/.*|completions/.*|helpers/.*|docs/.*|install\.sh|\.github/workflows/ci\.yml|tests/(contract\.sh|claude-customizations\.cjs|fixtures/claude-settings\.json))$ + - id: standalone-e2e + name: standalone install and launchers without dotfiles (temporary HOME, fakes) + entry: tests/standalone-e2e.sh + language: script + pass_filenames: false + files: ^(install\.sh|bin/.*|lib/.*|completions/.*|docs/account-launchers\.md|tests/standalone-e2e\.sh)$ diff --git a/AGENTS.md b/AGENTS.md index 579b487..90b7485 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,73 +1,100 @@ # Agent Guide -This repository is the engine behind -[FridrichMethod/dotfiles](https://github.com/FridrichMethod/dotfiles): the -`codex-as` and `claude-as` account launchers, the AI config sync engine -`lib/config_sync.py`, and the Claude status line, notification and Git-hook -helpers. Dotfiles consumes it as the submodule -`common/codex/coding-agent-kit`. The settings data (the portable Claude -`settings.json`, the Codex `config.toml` and `portable.rules`, and the global -instruction files) stays in dotfiles. +This repository is coding-agent-kit, a standalone Codex and Claude Code +multi-account wrapper: the `codex-as` and `claude-as` launchers and three +Claude Code helpers (status line, notifications, Git hook guard). It installs +on top of an existing `codex` or `claude` and never installs either. Users +install it with `install.sh` or by linking or copying its files; +[FridrichMethod/dotfiles](https://github.com/FridrichMethod/dotfiles) is one +consumer, a submodule whose files it links. ## Scope and layout -- Paths mirror the dotfiles Stow packages. The files under `common/` that - dotfiles delivers are the contract: dotfiles links these 14 files one by one, - with relative links (the three `common/claude/.claude/dotfiles/*.cjs` - helpers, `claude-as`, `codex-as`, their `.local/lib` modules and their Bash - and zsh completions). Renaming, removing or changing the mode of one needs a - coordinated dotfiles change. -- The binding (the four `*-sync` launchers, `lib/sync-runtime.sh`, - `setup-sync.sh`, `setup-sync.ps1` and `requirements-sync.txt`) is a - reference twin: dotfiles keeps its own copy, because the provisioned - interpreter is found four levels above the real launcher's directory. Change a - twin, the tomlkit pin or `TOMLKIT_VERSION` only together with dotfiles; the - dotfiles delivery test and the bump guard refuse drift. -- Helpers take every path as an argument (`PORTABLE LIVE`, `--append`, - `--hooks`) and never infer a home or a dotfiles checkout. -- Track no symlinks, so a Windows checkout needs no `core.symlinks`. -- Frozen output contract: `INSTRUCTIONS_MARKER`, `SUBMODULE_HINT`, the - foreign-instructions refusal texts, `(current)`/`(stale)`, the setup-sync - hints, the `--help` pointer `~/dotfiles/docs/account-launchers.md` and the - `DOTFILES_*` knob names keep their bytes. The dotfiles doctor parses several - of them, and the marker is the first line and ownership key of every - generated `~/.codex/AGENTS.override.md`. -- Develop in a separate clone, never inside a dotfiles checkout's submodule: - any untracked file there stops that host's automatic update. `.gitignore` - covers editor, cache and venv files at any depth for the same reason. -- `tests/fixtures/codex-config.toml`, `tests/fixtures/claude-settings.json` and - `tests/fixtures/portable.rules` are an interface: dotfiles replaces them with - its real data and reruns the suites. Keep the names, and keep the suites - reading only them. +``` +bin/codex-as, bin/claude-as launchers (Bash, POSIX sh) +lib/codex-as/*.py codex-as modules: launch, check, layout, handoff +lib/claude-as/launch.py claude-as module +lib/coding-agent-kit/config.py the configuration loader both launchers share +completions/{bash,zsh}/ account-name completion for both launchers +helpers/*.cjs Claude Code helpers (Node) +install.sh links bin/ and completions/ into a prefix +docs/ account-launchers.md, codex-as.md, configuration.md +tests/ suites, fixtures, runners, COVERAGE.md +``` + +- `bin/..` has the shape of a `~/.local` prefix: each launcher resolves its own + symlinks with a bounded loop (40 hops, `readlink` without `-f`, `cd -P`) to + `bin/..` and runs `lib//launch.py` there, so linked, copied and + Stow-managed installs all work. Keep `lib//` beside `bin/`, and load + sibling modules by path from `os.path.realpath(__file__)`: `python3 -I` puts + no script directory on `sys.path`. +- Track no symlinks, so a checkout works where links are unsupported. +- `--help` and a run without arguments are pure shell and work without + `python3`. Every other Python entry checks the Python version first, then + loads its modules inside the error handling, so a missing file prints + `incomplete install at ROOT`, never a traceback. +- `lib/coding-agent-kit/config.py` is the only launcher or completion code + that reads `HOME` or the configuration file (a test greps for other reads). It loads once per run into + one frozen object that every consumer takes. Unknown keys warn and never + fail, so a newer configuration file works with an older kit. See + [docs/configuration.md](docs/configuration.md). +- Without a configuration file the launchers must keep passing the same + arguments, environment, files and links as before; the golden test in + `tests/test_config.py` compares no file with an explicit-defaults file. + +## Public contract + +`tests/contract.sh` pins what consumers build on, and runs in `run.sh` and as +a pre-commit hook: + +- The public paths and their modes (`git ls-files -s`): the launchers, their + `lib/` modules, `config.py`, the completions, the helpers, `install.sh` and + the three docs. Consumers link these paths one by one (dotfiles links each + file from its own tree), copied installs depend on the `bin/` plus `lib/` + shape, and `--help` points at `docs/account-launchers.md`. +- Each file is a regular file in the working tree, executable where its mode + says so, and the launchers and `install.sh` keep their `#!/bin/bash` or + `#!/bin/sh` line. +- No tracked symlinks. +- The CI job names `Linux behavior and lint`, `macOS system Bash behavior` and + `Windows native PowerShell behavior`, and no other job: dotfiles' bump gate + requires those three check runs to be green on a release tag. +- `tests/claude-customizations.cjs` and `tests/fixtures/claude-settings.json`: + dotfiles overlays its real `settings.json` on the fixture and reruns the test, + which derives the helper directory from each fixture command. + +The command-line surface is public too, guarded by the suites rather than +`contract.sh`: the help text (dotfiles tests grep its `Concurrent accounts` +line and its `Details and manual repair:` path), `--list-accounts` and +`--show-config` with their output, the configuration file location, keys and +defaults, `install.sh` and its options, and `CODING_AGENT_KIT_COLOR`. Changing any of it, or anything `contract.sh` pins, +is a breaking change: it needs a minor release and a matching CHANGELOG entry. ## Required conventions - Keep secrets and machine-specific absolute paths out of version control. - Write this repository's documentation in English using printable ASCII, tabs and line breaks only. This includes README files, agent guides and docs. Use ASCII punctuation and diagrams; do not add Chinese text, emoji or decorative Unicode symbols. The native pre-commit pygrep hook `english-documentation` checks every documentation file in this repository. +- Develop in your own clone. A consumer's submodule checkout must stay clean, + because an untracked file there can stop its updates; for the same reason + tests and hooks leave nothing behind, and `.gitignore` covers editor, cache + and venv files at any depth. ## Weight and dependencies -These files are installed on every host, including fresh machines and clusters, so they must run on what a host already has and stay small enough to audit. +The kit runs on what a host already has and stays small enough to audit. -- At run time a stowed helper needs only POSIX `sh` or Bash with ordinary POSIX utilities, the Python 3.11+ standard library run as `python3 -I -B`, and the tool it wraps. It never needs a pip package, virtual environment, Node, compiler, network service or background process of its own. The one exception is the pinned tomlkit that the TOML-writing sync helpers load from an explicitly provisioned interpreter (see AI assistant configuration). Installing tools stays with `setup-host` (see Bootstrap and doctor). +- At run time the launchers and `install.sh` need only POSIX `sh` or Bash with ordinary POSIX utilities, the Python 3.11+ standard library run as `python3 -I -B`, and the tool they wrap. The helpers need Node.js 22+ and nothing from npm. Nothing needs a pip package, virtual environment, compiler, network service or background process of its own. The kit never installs a tool. - A missing optional tool narrows the feature; a missing required tool fails once with a message that names it, never with a traceback or a half-done run. For example, codex-as without `git` limits takeover to the launch directory. - A wrapper forwards to the native tool instead of reimplementing it. It requires a minimum version, never an exact pin or an upper bound. Anything beyond the tool's documented CLI and files (internal socket paths, wire protocols, lock layouts) is best effort: a failure there warns and falls back to plain native behavior, and blocks or asks only where continuing could lose work. - Keep helper code proportional to what it adds over the native tool. A feature that needs its own protocol client, journal or lock scheme must justify its code and test cost; prefer the native tool's own feature when one exists. -- Tests use the Python standard library (`unittest`), the shells under test and fakes for third-party CLIs, run in a temporary HOME, and reach no network beyond `127.0.0.1` fixtures. A test may use a real host tool (zsh, codex) when it is present and must skip cleanly when it is absent; anything that needs Docker, the internet or real credentials is opt-in, like `tests/e2e/run.sh`. Tests never touch the real home, its credentials or running daemons. +- Tests use the Python standard library (`unittest`), Node's built-in test runner, the shells under test and fakes for third-party CLIs, run in a temporary HOME, and reach no network beyond `127.0.0.1` fixtures. A test may use a real host tool (zsh, dash, GNU Stow, codex) when it is present and must skip cleanly when it is absent. Nothing in the suites needs Docker, the internet or real credentials. Tests never touch the real home, its credentials or running daemons. - CI runs every pre-commit hook on every push, so keep hooks scoped by `files:` and fast: test parsing and other pure logic in process, and spawn real shells and child processes only for behavior that needs them (exec, signals, umask, TTY, file modes). -## AI assistant configuration - -- The TOML-writing sync helpers and the full `lib/config_sync.py --runtime-check` load the exact `tomlkit` pinned in `requirements-sync.txt` (and in `TOMLKIT_VERSION`), only from an explicitly provisioned interpreter: the checkout-local `.venv-sync` that `./setup-sync.sh` (Windows: `./setup-sync.ps1`) creates, or the executable named by `DOTFILES_SYNC_PYTHON`. Everything else uses the Python 3.11+ standard library. - -## Bootstrap and doctor - -- Installing tools (`setup-host`), the doctor and the opt-in Docker e2e suite (`tests/e2e/run.sh`) live in the consuming dotfiles repository; nothing here installs a tool. - ## Working commands -- Provision AI-sync parser once per clone: `./setup-sync.sh` (Windows: `./setup-sync.ps1`), with Python 3.11+. +- Install into a prefix: `./install.sh [--prefix DIR]`; remove: `./install.sh --uninstall` - Run the tests: `./tests/run.sh` (CI: `--ci`; Windows: `./tests/run.ps1`, CI: `-CI`) +- Run one suite: `tests/config.sh`, `tests/codex-as.sh`, `tests/account-enrollment.sh`, `tests/standalone-e2e.sh`, `tests/contract.sh` - Run checks: `pre-commit run --all-files` ## Verification @@ -76,49 +103,39 @@ After modifying any file, run `pre-commit run --all-files` to ensure changes pas ## Shell conventions -- Use Bash/Zsh-specific syntax only in matching shell files. +- Use Bash/Zsh-specific syntax only in matching shell files. `bin/claude-as` and `install.sh` are POSIX `sh` and run under dash. - Follow pre-commit shell style: - Bash: `shfmt -i 4 -ci -ln bash` - POSIX: `shfmt -i 4 -ci -ln posix` - Zsh: `shfmt -i 4 -ci -ln zsh` (needs shfmt 3.13+, the first release with a zsh dialect) -- Launcher messages follow the dotfiles terminal policy: `DOTFILES_COLOR=auto|always|never`, and a nonempty `NO_COLOR` or `TERM=dumb` disables color. Native CLI output and completion lists stay untouched, and redirected logs stay plain by default. - -## Sync helpers - -- Keep all four AI sync helpers fail-closed. Update their sources, docs and focused tests together. -- Keep structured merges in `lib/config_sync.py`; only TOML sync and the full runtime check require pinned `tomlkit`, while JSON/rules use the Python standard library. Do not reintroduce AWK/regex TOML parsing or a second JSON engine. Normal sync never rewrites the portable source; baseline cleanup is an explicit migration (`--migrate-portable`). Never broaden existing regular-file Unix permissions. -- `--check` is read-only; `--quiet` suppresses successful helper chatter, never errors. Helpers never install dependencies. -- Calling contract: installers run every selected AI helper with read-only `--check` before any apply, and pass `--quiet` to both. The dotfiles installers rely on it. -- `codex-rules-sync` is an opaque byte copy; siblings such as `default.rules` are untouched. -- The owned Codex keys (`CODEX_POLICY`, `CODEX_OPTIONAL_POLICY`, `CODEX_RETIRED`) and the Claude portable checks (string `permissions.allow` and `permissions.ask`, no portable `model`) live here. What the portable files contain is dotfiles policy; changing an owned key needs a dotfiles bump with matching data. -- Windows: the `*-sync` launchers stay POSIX `sh`, because dotfiles' `stow-all.ps1` runs them through Git Bash. `codex-instructions-sync` is POSIX-only. +- Launcher messages follow one colour policy in shell and Python: `CODING_AGENT_KIT_COLOR=auto|always|never` (unset or invalid means `auto`, styled only when the destination is a terminal), and a nonempty `NO_COLOR` or `TERM=dumb` disables color. Native CLI output, completion lists and the output of `--list-accounts` and `--show-config` stay untouched, and redirected logs stay plain by default. ## Testing -- Every test entry point (`tests/*.sh`, `tests/run.ps1`) unsets the dotfiles knobs first, from the one list in `tests/run.sh`; `tests/test-entrypoints.sh` checks each entry point against it and that it covers every `DOTFILES_*`/`AWESOME_SKILLS_*` name in a tracked file outside `tests/`, `docs/`, `.github/` and Markdown, so a knob a new file reads goes on that list too. +- Every test entry point (`tests/*.sh`) starts with the one statement `unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR`, and `tests/run.ps1` removes the same names in the same order. `tests/test-entrypoints.sh` checks each entry point against `tests/run.sh`, and that the statement names every `CODING_AGENT_KIT_*` variable (pattern `_?CODING_AGENT_KIT_[A-Z_]+`) in a tracked file outside `tests/`, `docs/`, `.github/` and Markdown, so a knob a new file reads goes on that list too. Python tests that pass the caller's environment on drop the same names in `setUp`. - Behavior coverage is tracked as a source-to-test matrix in `tests/COVERAGE.md`; update it with the tests. ## Windows line endings -- `.gitattributes` pins `eol=lf` because Git for Windows enables `core.autocrlf` in its system config, and Git Bash fails on a CRLF `set -eu`. Superproject attributes do not apply inside a submodule, so this repository carries its own. The Windows CI job fails when `git ls-files --eol` reports a `w/crlf` file. +- `.gitattributes` pins `eol=lf` because Git for Windows enables `core.autocrlf` in its system config, and Git Bash fails on a CRLF `set -eu`. A superproject's attributes do not apply inside a submodule, so this repository carries its own. The Windows CI job fails when `git ls-files --eol` reports a `w/crlf` file. ## Workflows and checks - Keep workflow definitions in `.github/workflows/*.yml`. -- Pin action versions and avoid unnecessary matrix complexity. Actions use major tags (for example `@v6`) and CI installs `pre-commit==4.6.2`, the same convention as dotfiles. +- Pin action versions and avoid unnecessary matrix complexity. Actions use major tags (for example `@v6`) and CI installs `pre-commit==4.6.2`. - Keep CI aligned with `.pre-commit-config.yaml` tools: - `shellcheck` - `shfmt` - basic hygiene hooks - the `english-documentation` ASCII check - `gitleaks`, plus the manual `gitleaks-history` stage that CI runs over the full history -- CI runs `./tests/run.sh --ci` on Linux and macOS and `./tests/run.ps1 -CI` on native Windows, and each job fails if the tests or hooks leave an untracked file. +- CI runs `./tests/run.sh --ci` on Linux (zsh and GNU Stow installed; the test step fails if the folded Stow cases of `standalone-e2e.sh` did not run) and on macOS (system Bash 3.2, `/bin/sh`), and `./tests/run.ps1 -CI` on native Windows; each job fails if the tests or hooks leave an untracked file. The job names are part of the contract. - If CI behavior affects contributors, update `README.md` in the same change. -## Relationship to dotfiles +## Releases and tags -- Every host auto-updates from dotfiles `main` at login, so `main` here must stay releasable; a change reaches hosts only through the guarded dotfiles bump (`update-coding-agent-kit-submodule.yml` there). -- Never force-push or rewrite `main` and never move a tag: a dotfiles gitlink may point at any commit on `main`, and a host that cannot fetch it never updates again on its own. -- Changing the tomlkit pin: change `requirements-sync.txt`, `TOMLKIT_VERSION` and the twins here first. Then bump dotfiles by hand in a commit that also updates its `requirements-sync.txt` twin, because the bump guard refuses pin drift. -- History before the import lives in dotfiles (https://github.com/FridrichMethod/dotfiles, up to 6fe50bc); the imported commits keep only these files. -- Real-settings coupling: `tests/fixtures/codex-config.toml` mirrors dotfiles' real `config.toml`. Changing an owned key, the `CLAUDE.md` fallback or `network_proxy` there works in this order: change the fixture and the assertion here first, then make one dotfiles commit that changes the data, bumps the gitlink and updates any dotfiles assertion. The guarded bump refuses such a change on its own. +- Versions are `vMAJOR.MINOR.PATCH` tags on `main`, with semantic versioning in the 0.x range: a minor release may break the public contract, a patch release must not. Every release gets a CHANGELOG section, and a breaking one lists its breaking changes and moved paths. +- Tags are immutable: never move or delete one. A bad release is fixed by a new patch tag. +- `main` is never force-pushed or rewritten, because a consumer's gitlink may point at any commit on it, and a host that cannot fetch that commit stops updating. +- dotfiles bumps its submodule daily to the newest tag of its pinned minor line once the three CI jobs are green on it, so a patch tag ships to every dotfiles host within a day. Tag a patch only from a green `main`, and run `tests/contract.sh` and the full suite first. A minor release reaches dotfiles only through a reviewed change there. +- History before 0.1.0 lives in dotfiles (up to 6fe50bc); the imported commits keep only this repository's files. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2932eed..3d9b542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,159 @@ # Changelog +## 0.2.0 + +coding-agent-kit becomes a standalone install. The launchers find their own +files, an optional configuration file replaces the hard-coded paths and +executables, and `install.sh` links the commands. Without a configuration file +`codex-as` and `claude-as` pass the same arguments, environment, files and +links to Codex and Claude Code as in 0.1.0. + +Added: + +- `install.sh [--prefix DIR] [--uninstall]` links `bin/codex-as` and + `bin/claude-as` into `DIR/bin` (default `~/.local`), the Bash completions into + `${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-~/.local/share}/bash-completion}/completions` + and the zsh completions into `DIR/share/zsh/site-functions`. It replaces or + removes only its own links, whose text starts with the checkout's physical + path as `pwd -P` prints it (also after their target moved within it); a + link made through another spelling of that path is not its own. Before + changing anything it refuses every other existing path, including relative + links such as Stow's, any destination reached through a relative directory + link below `$HOME` or the parent of `DIR` (a Stow fold or a hand-made + relative alias; absolute aliases, `$HOME` itself and the links above it + are accepted), and any destination whose nearest existing parent is not a + writable directory. `--uninstall` changes nothing when one of its own links + is in a directory it cannot write or a directory on the way cannot be + searched. It prints the zsh `fpath` line and the Claude Code helper + commands, and never writes a configuration file. +- An optional configuration file, `CODING_AGENT_KIT_CONFIG` or + `${XDG_CONFIG_HOME:-~/.config}/coding-agent-kit/config.toml`, with the keys + `codex`, `claude`, `codex_home` (default `~/.codex`) and `secrets_dir` + (default `~/.secrets`). It must be owned by you or root and not writable by + others (group-writable only for your user private group, which is your + login group in the password database, also in a `newgrp` or `sg` shell, + and then without an access ACL; on NFSv4 every group-writable file or + directory counts as having one), and so must the directories on its way, + up to `$HOME` (or its resolved path) or `/`; a symlink on its way must be + yours or root's. Values with control characters (C0, DEL and C1) are + refused; unknown keys only warn. See `docs/configuration.md`. +- `codex-as --show-config` and `claude-as --show-config` print the effective + values and where they came from. `codex-as --list-accounts` prints the + account names, like `claude-as --list-accounts`. Neither starts a native CLI. +- The launchers resolve their own symlinks (at most 40) to the kit root and run + `lib//launch.py` there, so linked, copied and Stow-managed installs all + work. An incomplete install prints `incomplete install at ROOT` instead of a + traceback. +- Tests: `tests/test_config.py` (runner `tests/config.sh`) for the loader and + the launchers' location, `tests/standalone-e2e.sh` for an install from a + plain clone, and `tests/contract.sh` for the public contract. + +Changed: + +- `--help` names the configurable `` and `` with their + defaults, adds a `Configuration:` line, and ends with + `Details and manual repair: ROOT/docs/account-launchers.md`, or with the + GitHub URL of that file when the launcher has no kit around it. +- With a configured `codex_home`, root help, version, `update` and + `completion` run with `CODEX_HOME=`, and an inherited `CODEX_HOME` + equal to it is accepted. +- `codex-as` checks the parents of `codex_home` and of `secrets_dir`, + `claude-as` the parent of `secrets_dir`. Both are `$HOME` by default, checked + as before. A missing parent, `$HOME` included, or one below a regular file, + is now named (`PARENT does not exist; ...`) instead of the generic + `missing, unreadable or malformed ...` message. +- The configuration location is looked up even without a file. A missing file, + or a path component that is a regular file, means the defaults; any other + failure to open it (an unreadable file, an unsearchable `~/.config` or + `$XDG_CONFIG_HOME`, a symlink loop) is an error, so it stops every launch, + `--list-accounts` and completion, where 0.1.0 never looked there. +- `claude-as` catches the same errors at the top level as `codex-as`, so none + ends in a traceback; for example `claude-as NAME --settings ~unknown/x` now + prints the one-line `missing, unreadable or malformed ...` message. +- An unset or relative `HOME` fails with `TOOL: HOME must be set to an + absolute path.`, for `--list-accounts` too. +- `claude-as` with a missing `lib/claude-as/launch.py` prints the + incomplete-install message and exits 1 (it exited 2 with Python's error). +- `tests/run.sh --ci` requires zsh, and the Linux CI job installs it and GNU + Stow and fails if the folded Stow cases of `tests/standalone-e2e.sh` did + not run. The Windows job no longer sets up Python. +- `tests/claude-customizations.cjs` takes the helper directory from each + fixture command instead of assuming `~/.claude/dotfiles`. + +Breaking changes: + +- Every launcher, library, completion and helper file moved; see the table + below. A consumer that links files must use the new paths. +- `DOTFILES_COLOR` is no longer read. Use + `CODING_AGENT_KIT_COLOR=auto|always|never`; `NO_COLOR` and `TERM=dumb` work + as before. +- The launchers no longer run `~/.local/lib//`. They run `lib//` + beside their own `bin/` after resolving symlinks, so a copied launcher needs + `PREFIX/lib/{codex-as,claude-as,coding-agent-kit}/` beside `PREFIX/bin/`. +- The `--help` pointer `~/dotfiles/docs/account-launchers.md` is gone (see + Changed). +- The completions get account names from `codex-as --list-accounts` and + `claude-as --list-accounts`, so the launchers must be on `PATH`. The old + listing `check.py accounts` still works for shells opened before an update. +- `codex-as --list-accounts` and `codex-as --show-config` are no longer + refused as invalid account names, and `claude-as --show-config` is no longer + taken as an account name. +- `claude-as` refuses account names that start with `-`, as `codex-as` does: + `claude-as -x`, `claude-as -h` or `claude-as --version` prints `claude-as: + invalid account name (ASCII letters, digits, _ or -; no leading -).` and + exits 1 instead of offering to enrol an account of that name. Every invalid + name now gets this message, which `codex-as` already printed. +- The AI config sync engine moved back to dotfiles: `lib/config_sync.py`, the + four `*-sync` launchers, `lib/sync-runtime.sh`, `setup-sync.*`, + `requirements-sync.txt`, their suites and fixtures and `docs/config-sync.md` + are removed. The kit needs no `tomlkit`, only the Python 3.11+ standard + library and Node.js 22+. +- The test entry points unset `CODING_AGENT_KIT_CONFIG`, + `CODING_AGENT_KIT_COLOR`, `XDG_CONFIG_HOME` and `NO_COLOR` instead of the + dotfiles knobs, and the knob-hygiene check covers `CODING_AGENT_KIT_*` names. + +Paths, 0.1.0 to 0.2.0: + +| 0.1.0 | 0.2.0 | +| --- | --- | +| `common/codex/.local/bin/codex-as` | `bin/codex-as` | +| `common/claude/.local/bin/claude-as` | `bin/claude-as` | +| `common/codex/.local/lib/codex-as/launch.py` | `lib/codex-as/launch.py` | +| `common/codex/.local/lib/codex-as/check.py` | `lib/codex-as/check.py` | +| `common/codex/.local/lib/codex-as/layout.py` | `lib/codex-as/layout.py` | +| `common/codex/.local/lib/codex-as/handoff.py` | `lib/codex-as/handoff.py` | +| `common/claude/.local/lib/claude-as/launch.py` | `lib/claude-as/launch.py` | +| `common/codex/.local/share/bash-completion/completions/codex-as` | `completions/bash/codex-as` | +| `common/claude/.local/share/bash-completion/completions/claude-as` | `completions/bash/claude-as` | +| `common/codex/.oh-my-zsh/custom/completions/_codex-as` | `completions/zsh/_codex-as` | +| `common/claude/.oh-my-zsh/custom/completions/_claude-as` | `completions/zsh/_claude-as` | +| `common/claude/.claude/dotfiles/statusline.cjs` | `helpers/statusline.cjs` | +| `common/claude/.claude/dotfiles/notify.cjs` | `helpers/notify.cjs` | +| `common/claude/.claude/dotfiles/check-git-hooks.cjs` | `helpers/check-git-hooks.cjs` | +| `common/claude/.local/bin/claude-settings-sync` | removed | +| `common/codex/.local/bin/codex-config-sync` | removed | +| `common/codex/.local/bin/codex-instructions-sync` | removed | +| `common/codex/.local/bin/codex-rules-sync` | removed | +| `lib/config_sync.py` | removed | +| `lib/sync-runtime.sh` | removed | +| `setup-sync.sh`, `setup-sync.ps1` | removed | +| `requirements-sync.txt` | removed | +| `docs/config-sync.md` | removed | +| `tests/test_config_sync.py`, `tests/config-sync.sh`, `tests/codex-config-sync.sh`, `tests/ai-config-sync.sh`, `tests/inspect_codex_hooks.py` | removed | +| `tests/fixtures/codex-config.toml`, `tests/fixtures/portable.rules` | removed | +| (new) | `lib/coding-agent-kit/config.py`, `install.sh`, `docs/configuration.md`, `tests/config.sh`, `tests/test_config.py`, `tests/contract.sh`, `tests/standalone-e2e.sh` | + +Every other 0.1.0 path is unchanged: `.gitattributes`, +`.github/workflows/ci.yml`, `.gitignore`, `.gitleaks.toml`, +`.pre-commit-config.yaml`, `AGENTS.md`, `CHANGELOG.md`, `CLAUDE.md`, +`LICENSE`, `README.md`, `docs/account-launchers.md`, `docs/codex-as.md`, +`tests/COVERAGE.md`, `tests/account-enrollment.sh`, +`tests/claude-customizations.cjs`, `tests/codex-as.sh`, +`tests/fixtures/bash_aliases`, `tests/fixtures/claude-settings.json`, +`tests/fixtures/zsh_aliases`, `tests/run.ps1`, `tests/run.sh`, +`tests/test-entrypoints.sh`, `tests/test_account_enrollment.py`, +`tests/test_codex_as.py` and `tests/test_codex_sharing_native.py`. + ## 0.1.0 - Extracted from dotfiles 6fe50bc, no behavior change. The `codex-as` and diff --git a/README.md b/README.md index 25e5633..29898ca 100644 --- a/README.md +++ b/README.md @@ -1,214 +1,254 @@ # coding-agent-kit -The Codex and Claude Code tooling of -[FridrichMethod/dotfiles](https://github.com/FridrichMethod/dotfiles), kept as -its own repository: - -- `codex-as` and `claude-as`, account launchers that run the native CLI as a - named account; -- the AI config sync engine `lib/config_sync.py` behind `codex-config-sync`, - `claude-settings-sync`, `codex-rules-sync` and `codex-instructions-sync`; -- the Claude Code status line, notification and Git-hook helpers. - -Dotfiles consumes it as the submodule `common/codex/coding-agent-kit`. The -settings data the helpers apply (the portable Claude `settings.json`, the Codex -`config.toml` and `portable.rules`, and the global instruction files) stays in -dotfiles. Version 0.1.0 is the dotfiles code at 6fe50bc, moved with no behavior -change; see [CHANGELOG.md](CHANGELOG.md). +Multi-account wrappers and helpers for Codex and Claude Code: + +- `codex-as NAME [codex arguments...]` and + `claude-as NAME [claude arguments...]` run the native CLI as a named + account, with private stored credentials and the shared everyday + configuration and history; +- three Claude Code helpers: a status line, terminal notifications and a Git + hook guard. + +It installs on top of an existing `codex` or `claude` and never installs, +updates or replaces either. [FridrichMethod/dotfiles](https://github.com/FridrichMethod/dotfiles) +is one consumer: it carries this repository as a submodule and links its +files. ## Requirements -- POSIX `sh` or Bash with ordinary POSIX utilities. -- Python 3.11+ standard library, run as `python3 -I -B`. -- The pinned `tomlkit` in [`requirements-sync.txt`](requirements-sync.txt), - only for TOML sync and the full runtime check, provisioned by - `./setup-sync.sh` (Windows: `./setup-sync.ps1`). -- Node.js 22+ for the `.cjs` Claude helpers. -- The tool a launcher wraps: Codex CLI 0.161.0 or newer for `codex-as`, Claude - Code for `claude-as`. +- Linux or macOS. On native Windows only the Node helpers are tested. +- Python 3.11 or newer, standard library only (run as `python3 -I -B`). +- Bash for `codex-as` (3.2 is enough), POSIX `sh` for `claude-as` and + `install.sh`, and ordinary POSIX utilities. +- Codex CLI 0.161.0 or newer for `codex-as`, with no upper limit. Claude Code + for `claude-as`; its token capture is audited for 2.1.296, and other + versions fall back to an explicit manual paste. +- Node.js 22 or newer for the helpers; Git is optional for the status line. +- Optional: bash-completion or zsh for account-name completion. -## Contract +## Install -### E1: delivered files +```sh +git clone https://github.com/FridrichMethod/coding-agent-kit.git +cd coding-agent-kit +./install.sh # or: sh install.sh, ./install.sh --prefix DIR +``` -Dotfiles links each of these files from the same path in its own tree, one -relative link per file. Their paths and modes are the interface: +`install.sh [--prefix DIR] [--uninstall]` creates absolute links into the +checkout; `DIR` must be absolute and defaults to `~/.local`: -| Path | Mode | +| Link | Target | | --- | --- | -| `common/claude/.claude/dotfiles/check-git-hooks.cjs` | 100644 | -| `common/claude/.claude/dotfiles/notify.cjs` | 100644 | -| `common/claude/.claude/dotfiles/statusline.cjs` | 100644 | -| `common/claude/.local/bin/claude-as` | 100755 | -| `common/claude/.local/lib/claude-as/launch.py` | 100644 | -| `common/claude/.local/share/bash-completion/completions/claude-as` | 100644 | -| `common/claude/.oh-my-zsh/custom/completions/_claude-as` | 100644 | -| `common/codex/.local/bin/codex-as` | 100755 | -| `common/codex/.local/lib/codex-as/check.py` | 100644 | -| `common/codex/.local/lib/codex-as/handoff.py` | 100644 | -| `common/codex/.local/lib/codex-as/launch.py` | 100644 | -| `common/codex/.local/lib/codex-as/layout.py` | 100644 | -| `common/codex/.local/share/bash-completion/completions/codex-as` | 100644 | -| `common/codex/.oh-my-zsh/custom/completions/_codex-as` | 100644 | - -The engine `lib/config_sync.py` is canonical here; dotfiles calls it at -`common/codex/coding-agent-kit/lib/config_sync.py`. The binding (the four -`*-sync` launchers, `lib/sync-runtime.sh`, `setup-sync.sh`, `setup-sync.ps1` -and `requirements-sync.txt`) is a reference twin: dotfiles keeps its own -byte-identical copy, apart from the engine path, and its delivery test refuses -drift, including in the `tomlkit` pin. - -### E2: helper CLI - -```text -codex-config-sync [--quiet] [--check | --migrate-portable] [--hooks EVENTS.json] PORTABLE LIVE -claude-settings-sync [--quiet] [--check] [--hooks EVENTS.json] PORTABLE LIVE -codex-rules-sync [--quiet] [--check] PORTABLE LIVE -codex-instructions-sync [--quiet] [--check] [--append RULES] PORTABLE LIVE +| `DIR/bin/codex-as`, `DIR/bin/claude-as` | `bin/` | +| `${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-~/.local/share}/bash-completion}/completions/{codex-as,claude-as}` | `completions/bash/` | +| `DIR/share/zsh/site-functions/{_codex-as,_claude-as}` | `completions/zsh/` | + +- It checks every destination first. A path is this checkout's own only when + it is a symlink whose text starts with the checkout's physical path (as + `pwd -P` prints it) and has no `.` or `..` components. That is the form + `install.sh` writes, also when you run it through another spelling of the + path. Any other existing path is refused with its name and what it is: a + file, a directory, a link into another checkout, a dangling link, every + relative link, whether Stow made it or you did, and a link you made + through another spelling of the checkout's path, such as a symlinked + directory above it or macOS `/var` for `/private/var`. Such a link reaches + this checkout but is not its own; remove it by hand. +- A destination is refused too when a directory on its way, below `$HOME` or + below the parent of `DIR`, is a relative symlink. That is the form Stow + folding writes: a missing `~/.local/bin` becomes a link into another + repository's package, and a new link there would land in that repository. + Absolute directory links are accepted, and so is any link above both, + such as macOS `/var`; `$HOME` itself and the links above it are accepted + for every `DIR`. Stow with `--no-folding`, or create such a directory (for + example `~/.local/bin`) before you stow. +- A destination whose nearest existing parent is not a writable directory is + refused too, naming that parent. A refusal exits 1, and nothing changes. +- Otherwise it creates or replaces the links, so a second run is a no-op. + `git pull` needs no reinstall. +- `--uninstall` removes only this checkout's own links and reports any other + path it keeps; directories stay. It first refuses, exit 1 with nothing + changed, when an own link is in a directory it cannot write, or when a + directory on the way cannot be searched, so it cannot tell what is there. + Give it the same `--prefix` and environment as the install. +- A link of this checkout whose target has moved or gone within it, as after + a release that moved a file, is still its own: a reinstall replaces it and + `--uninstall` removes it. A moved or deleted checkout leaves dangling links + that count as foreign: remove them by hand, or run `--uninstall` from the + old place first. +- Exit 0 on success, 1 on a refusal or error, 2 on a usage error. It never + writes a configuration file and starts no login. + +It then prints what it cannot do for you: + +``` +zsh: unless fpath has it, add this before compinit (with oh-my-zsh, before sourcing it): + fpath=("/home/u/.local/share/zsh/site-functions" $fpath) +Claude Code helpers, as commands in settings.json: + statusLine: node "/home/u/src/coding-agent-kit/helpers/statusline.cjs" + Notification hook: node "/home/u/src/coding-agent-kit/helpers/notify.cjs" + PreToolUse hook (Bash): node "/home/u/src/coding-agent-kit/helpers/check-git-hooks.cjs" +PATH: add /home/u/.local/bin ``` -Every path is an argument; no helper infers a home or a dotfiles checkout. -`--check` is a read-only preflight, `--quiet` hides only successful chatter, -and every helper fails closed. See [docs/config-sync.md](docs/config-sync.md) -for merge ownership, the instructions overlay, migration and file safety. - -### E3: runtime lookup - -A launcher resolves its own symlink chain and takes the checkout root four -levels above the directory of the real launcher file. It runs the engine with -`DOTFILES_SYNC_PYTHON` when that is set (an invalid value fails, with no -fallback), and otherwise with `/.venv-sync/bin/python`, or -`/.venv-sync/Scripts/python.exe` under Git Bash. Python runs as -`-I -B -X utf8`. It never searches for a system Python. - -### E4: provisioning - -`./setup-sync.sh [--python PYTHON]` (Windows: -`./setup-sync.ps1 [-Python PYTHON]`) creates the ignored, checkout-local -`.venv-sync/`, installs only the hash-pinned `tomlkit` wheel and runs -`lib/config_sync.py --runtime-check`. Profiles, installers and automatic -updates never install anything; a missing or outdated runtime fails with a -request to rerun setup. - -### E5: frozen strings - -These strings keep their bytes, because dotfiles or existing hosts depend on -them: - -- `INSTRUCTIONS_MARKER`, the first line and ownership key of every generated - `~/.codex/AGENTS.override.md`; -- `SUBMODULE_HINT` (`git submodule update --init --recursive`), the - foreign-instructions refusal texts and the `(current)`/`(stale)` suffixes of - `codex-instructions-sync --check`, which the dotfiles doctor parses; -- the setup-sync hints (`run setup-sync.sh or setup-sync.ps1`, - `rerun setup-sync explicitly`); -- the `--help` pointer `~/dotfiles/docs/account-launchers.md` of both - launchers; -- the knob names `DOTFILES_COLOR` and `DOTFILES_SYNC_PYTHON`. - -## Why the paths mirror Stow packages - -Each file sits at the path it had in dotfiles. The `*-sync` launchers find the -checkout root at a fixed depth, every test's repository-root arithmetic and -every pre-commit file pattern keep working, and the imported history needs no -renames. Dotfiles can then link each delivered file from the same package path, -so every stowed `$HOME` link keeps its exact text. - -## Account launchers - -`codex-as NAME [ARGS...]` runs Codex with a fixed `~/.codex/accounts/NAME` -view, private credentials in untracked `~/.secrets/codex-NAME.json`, and shared -config, session and SQLite state; its Python standard-library helpers live in -`~/.local/lib/codex-as/`. `claude-as ACCOUNT [ARGS...]` runs Claude Code with -the `claude setup-token` OAuth token kept untracked in -`~/.secrets/claude-ACCOUNT`. - -Existing accounts launch immediately. Missing accounts ask `[y/N]`, run native login in an isolated temporary configuration, save private credentials, then continue the original command. zsh/fzf-tab and Bash complete account names without reading tokens. -Launcher messages use terminal-aware colors; redirected logs stay plain by default. `DOTFILES_COLOR=auto|always|never`, `NO_COLOR` and `TERM=dumb` follow the existing dotfiles convention; native CLI output and completion lists remain untouched. -Both require Python 3.11+. Codex requires CLI 0.161.0 or newer and forces file authentication. -Apart from `--help`, `codex-as` has no options of its own: `codex-as NAME ARGS...` runs `codex ARGS...` as NAME, -refusing what would change, revoke or bypass that login, daemon-only commands, session names, -and settings that would split the shared state. -The root `~/.codex/auth.json` stays native; `codex-as` never reads or changes it, so root -login/logout and desktop clients keep their own token chain. -Accounts and different sessions can run concurrently and share history. Resume by UUID, -`--last` or picker; explicit session names are refused. Account leases exclude credential -maintenance while wrapper clients are active, and native thread locks exclude two writers -to the same session. Same-account OAuth refresh retains native cross-process limitations. -Interactive resume takes sessions over from the root daemon (Desktop's backend): an -idle `codex-as NAME resume UUID` without a prompt, one that may be running after a -confirmation, for `--last` only the session it opens when idle, and before the picker the -idle sessions it lists by default (this directory or the same place in a linked worktree; -all with `--all`). -Takeover ends the old runtime and spawned agents there, restores saved archive -visibility, then resumes with the selected account. Interrupted takeovers keep a private -recovery record that the next `resume`, `fork` or `exec resume` of that UUID, picker or -`resume --last` finishes. Exec, fork and sessions held by other -processes are never taken over. -Ambiguous credential divergence stops with both copies kept and a manual relink command; shared config paths -must be absolute, while cloud policy, remote plugin caches and auth-bound integrations remain isolated. -See [account layout, new accounts, completion and safety boundaries](docs/account-launchers.md), and [Codex source verification](docs/codex-as.md). Installing does not start login or switch real accounts. - -## Claude helpers - -The portable dotfiles `settings.json` wires `~/.claude/dotfiles/*.cjs` into -Claude Code. - -Claude's local helpers require Node.js 22 or newer on `PATH` (18 and 20 are end of life; Git is optional for the status line): - -- **Status line:** shows the active model, reported effort, project and Git branch (or detached commit), context usage, and estimated session cost. For example: `Opus | xhigh | dotfiles (main) | ctx 38% | ~$1.23`. Missing usage/cost data shows `?`; cost is Claude's estimate, not an invoice. It performs only bounded Git ref reads, without scanning the worktree or contacting the network. See the [status-line reference](https://code.claude.com/docs/en/statusline). -- **Notifications:** `permission_prompt` and `idle_prompt` events emit [Claude's `terminalSequence`](https://code.claude.com/docs/en/hooks#emit-terminal-notifications), using OSC 99 for Kitty, OSC 777 for Ghostty/Warp/rxvt, or OSC 9 for iTerm2/WezTerm/Windows Terminal. The terminal handles desktop delivery, including remote sessions when terminal identification is available. Enable notifications for the terminal in OS settings; unsupported terminals and non-interactive sessions may ignore them. The notice contains only the project name and a fixed status, never prompt or command contents. The plugin's existing Stop notification remains separate. -- **Git hook checks:** a local `PreToolUse` helper replaces per-Bash `npx block-no-verify@1.1.2` execution. It blocks common `--no-verify`, commit `-n`, and `-c core.hooksPath=...` forms without npm startup or downloads. This is a conservative text check, not a shell parser; quoted text can trigger it, and indirect bypasses are outside its scope. Successful checks do not grant permission or skip the classifier. +The `PATH` line appears only when `DIR/bin` is not on `PATH`. In Claude +Code's `settings.json`, the helpers go in as: + +```json +{ + "statusLine": { + "type": "command", + "command": "node \"/home/u/src/coding-agent-kit/helpers/statusline.cjs\"" + }, + "hooks": { + "Notification": [ + { + "matcher": "permission_prompt|idle_prompt", + "hooks": [{ "type": "command", "command": "node \"/home/u/src/coding-agent-kit/helpers/notify.cjs\"" }] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "node \"/home/u/src/coding-agent-kit/helpers/check-git-hooks.cjs\"" }] + } + ] + } +} +``` -## Consuming it from dotfiles +Instead of `install.sh` you can link or copy the files yourself. A launcher +follows its own symlinks (up to 40) to `bin/..` and runs `lib//launch.py` +there, and `bin/..` has the shape of a `~/.local` prefix: link `bin/codex-as` +and `bin/claude-as` from any directory on `PATH`, or copy them and +`lib/{codex-as,claude-as,coding-agent-kit}/` into `PREFIX/bin` and +`PREFIX/lib`. Put the completions wherever your shell loads them; they call the +launchers by name. A launcher without its `lib/` reports +`incomplete install at ROOT (missing lib//launch.py)` and exits 1, and +one without `docs/` beside its `bin/` points `--help` at the GitHub copy of +[docs/account-launchers.md](docs/account-launchers.md). -Dotfiles carries this repository as the https submodule -`common/codex/coding-agent-kit`, following `main`. The checkout sits inside -the `codex` Stow package and is kept out of `$HOME` by one `.stowrc` line, -`--ignore=^coding-agent-kit(/|$)`. A package-local `.stow-local-ignore` would -replace Stow's built-in ignore list for the whole package. +## Usage -`common/claude` and `common/codex` in dotfiles hold one relative symlink per -delivered file, never a directory link, for example -`common/codex/.local/bin/codex-as -> -../../../codex/coding-agent-kit/common/codex/.local/bin/codex-as`. The package -paths are unchanged, so Stow links `$HOME` to them exactly as before and -resolution takes one more hop. +```sh +codex-as work # Codex as account "work" +codex-as work resume --last +codex-as work exec --json "Check the tests" +claude-as personal -p "Check the tests" +codex-as --list-accounts # account names, what completion offers +claude-as --show-config # configuration in effect +codex-as --help +``` -A change here reaches hosts only through the dotfiles bump workflow -`update-coding-agent-kit-submodule.yml`, which runs on manual dispatch, checks -the new commit and pushes a review branch; the dotfiles pull request then runs -the full dotfiles CI. Develop in a separate clone: an untracked file inside a -host's submodule checkout stops that host's automatic update. +`` and `` below are configurable and default to +`~/.codex` and `~/.secrets` (see [Configuration](#configuration)). + +- Existing accounts launch immediately. A missing one lists the existing + accounts and asks `[y/N]`; after native login in an isolated temporary + configuration (Codex device login, Claude `setup-token`) it saves the + credentials and continues the original command. +- `codex-as` keeps a fixed account view in `/accounts/NAME`, with + credentials in `/codex-NAME.json` and the configuration, + sessions, history and SQLite state shared from ``. Accounts and + different sessions run concurrently, and interactive resume takes sessions + over from the root daemon (the desktop app's backend). It forwards + arguments unchanged and refuses only what would change, revoke or bypass the + account's login, daemon-only commands, session names and settings that would + split the shared state. The root `/auth.json` stays native; + `codex-as` never reads or changes it. +- `claude-as` starts Claude Code with the `claude setup-token` OAuth token from + `/claude-NAME` and otherwise inherits the existing + configuration. +- Apart from `--help`, `--list-accounts` and `--show-config`, each accepted + only alone, the launchers have no options of their own. + +[docs/account-launchers.md](docs/account-launchers.md) covers the account +layout, adding accounts, completion, safety boundaries and manual repair; +[docs/codex-as.md](docs/codex-as.md) the Codex argument rules, daemon +takeover, version compatibility and the remaining differences from native +Codex. + +## Configuration + +The launchers read one optional TOML file: +`CODING_AGENT_KIT_CONFIG` if set, otherwise +`${XDG_CONFIG_HOME:-~/.config}/coding-agent-kit/config.toml`. Without it they +use the defaults: + +```toml +codex = "codex" # executable: a name looked up on PATH, or an absolute or ~/ path +claude = "claude" +codex_home = "~/.codex" # accounts live in codex_home/accounts +secrets_dir = "~/.secrets" +``` + +The file, and the directories and symlinks on its way, must be owned by you +(or root) and not writable by others; unknown keys only warn. Choose `codex_home` and `secrets_dir` before the first +enrolment, and after moving `codex_home` export `CODEX_HOME` for plain +`codex`. `--show-config` prints the values in effect. +[docs/configuration.md](docs/configuration.md) has every rule and message. + +## Claude Code helpers + +- **Status line:** shows the active model, reported effort, project and Git branch (or detached commit), context usage, and estimated session cost. For example: `Opus | xhigh | coding-agent-kit (main) | ctx 38% | ~$1.23`. Missing usage/cost data shows `?`; cost is Claude's estimate, not an invoice. It performs only bounded Git ref reads, without scanning the worktree or contacting the network. See the [status-line reference](https://code.claude.com/docs/en/statusline). +- **Notifications:** `permission_prompt` and `idle_prompt` events emit [Claude's `terminalSequence`](https://code.claude.com/docs/en/hooks#emit-terminal-notifications), using OSC 99 for Kitty, OSC 777 for Ghostty/Warp/rxvt, or OSC 9 for iTerm2/WezTerm/Windows Terminal. The terminal handles desktop delivery, including remote sessions when terminal identification is available. Enable notifications for the terminal in OS settings; unsupported terminals and non-interactive sessions may ignore them. The notice contains only the project name and a fixed status, never prompt or command contents. Other events and notification types produce nothing. +- **Git hook checks:** a local `PreToolUse` helper replaces per-Bash `npx block-no-verify@1.1.2` execution. It blocks common `--no-verify`, commit `-n`, and `-c core.hooksPath=...` forms without npm startup or downloads. This is a conservative text check, not a shell parser; quoted text can trigger it, and indirect bypasses are outside its scope. Successful checks do not grant permission or skip the classifier. + +## Colour + +The launchers style only their own messages: headings cyan, questions and +warnings yellow, saves green, errors red. Native Codex and Claude output, +completion lists and `--list-accounts`/`--show-config` output are never +styled. + +- `CODING_AGENT_KIT_COLOR=auto` (also when unset or invalid) styles a message + only when its destination is a terminal, so redirected logs stay plain; + `always` and `never` force it. +- A non-empty `NO_COLOR` or `TERM=dumb` turns colour off, even with `always`. ## Tests ```sh -./setup-sync.sh ./tests/run.sh # CI: ./tests/run.sh --ci pre-commit run --all-files ``` ```powershell -./setup-sync.ps1 ./tests/run.ps1 # CI: ./tests/run.ps1 -CI ``` -The suites read frozen copies of the dotfiles settings in `tests/fixtures/`. -Dotfiles replaces `codex-config.toml`, `claude-settings.json` and -`portable.rules` there with its real files and reruns the engine suites, so -those names are an interface. CI runs the Unix suite on Linux and macOS, the -native suite on Windows, and every pre-commit hook on Linux. -[tests/COVERAGE.md](tests/COVERAGE.md) maps each source file to the tests that -cover it. +- `run.sh` needs bash, sh, git, python3 and node. Without zsh it skips the zsh + cases and says so; `--ci` requires zsh. Without `codex` on `PATH` it skips + the native Codex sharing checks, and without GNU Stow the folded Stow cases + of `standalone-e2e.sh`, and says so; as root it skips the read-only + directory cases. +- `run.ps1` runs the Node helper suite; `-CI` requires Windows. +- Every suite runs in a temporary HOME with fake CLIs and fictitious + credentials, reaches no network beyond local fixtures, and first unsets + `CODING_AGENT_KIT_CONFIG`, `CODING_AGENT_KIT_COLOR`, `XDG_CONFIG_HOME` and + `NO_COLOR`. +- `tests/standalone-e2e.sh` installs with `install.sh` under `/bin/sh`, bash and + (when present) dash, checks its refusals and drives both launchers and their + completions through the links; + `tests/contract.sh` checks the public contract (see + [AGENTS.md](AGENTS.md)). +- CI runs the Unix suite on Linux and on macOS with the system Bash 3.2, the + Node suite on Windows, and every pre-commit hook on Linux. The Linux job + installs GNU Stow and fails if the folded Stow cases did not run; macOS + skips them. + [tests/COVERAGE.md](tests/COVERAGE.md) maps each source file to its tests. ## Versioning -Releases follow semantic versioning in the 0.x range. `v0.1.0` is the first -commit that dotfiles pins; a tag is never moved, so a later fix becomes -v0.1.1. `main` is never force-pushed or rewritten, because a dotfiles gitlink -may point at any commit on it. Changes are listed in -[CHANGELOG.md](CHANGELOG.md). +Releases follow semantic versioning within 0.x. A minor release (0.2.0 to +0.3.0) may break the contract, the command-line surface or the defaults; a +patch release (0.2.0 to 0.2.1) must not. Tags are never moved and `main` is +never rewritten, so a fix ships as a new patch tag. + +dotfiles consumes patch tags automatically: a daily job moves its submodule to +the newest tag of the pinned minor line once this repository's CI is green, so +a patch reaches every dotfiles host within a day, and a patch that breaks the +contract breaks them all. Minor releases reach it only through a reviewed +change. [CHANGELOG.md](CHANGELOG.md) lists every release. ## License diff --git a/bin/claude-as b/bin/claude-as new file mode 100755 index 0000000..d0a8170 --- /dev/null +++ b/bin/claude-as @@ -0,0 +1,75 @@ +#!/bin/sh +# claude-as ACCOUNT [CLAUDE_ARGS...] +# Existing token: launch. Missing token: confirm, native setup-token, save, launch. +set -eu +umask 077 +# Only style this command's heading, never the native Claude streams. +_claude_as_message() { + if [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != dumb ] && + { [ "${CODING_AGENT_KIT_COLOR:-auto}" = always ] || + { [ "${CODING_AGENT_KIT_COLOR:-auto}" != never ] && [ -t "$2" ]; }; }; then + printf '\033[%sm%s\033[0m\n' "$1" "$3" >&"$2" + else + printf '%s\n' "$3" >&"$2" + fi +} +# Set _claude_as_root to the kit, the physical bin/.. of this file after its +# symlinks; readlink -f is not portable, and paths may contain spaces. Every +# step checks its status: set -e does not apply inside a tested function. +_claude_as_locate() { + _claude_as_script=$0 + case $_claude_as_script in + */*) ;; + *) [ -e "$_claude_as_script" ] || _claude_as_script=$(command -v "$_claude_as_script") || { + _claude_as_message 31 2 "claude-as: cannot find this launcher ($0) on PATH." + return 1 + } ;; + esac + _claude_as_links=0 + while [ -L "$_claude_as_script" ]; do + _claude_as_links=$((_claude_as_links + 1)) + if [ "$_claude_as_links" -gt 40 ]; then + _claude_as_message 31 2 'claude-as: launcher symlink loop (more than 40 links); reinstall the helper.' + return 1 + fi + _claude_as_dir=$(CDPATH='' cd -P -- "$(dirname -- "$_claude_as_script")" 2>/dev/null && pwd) && + _claude_as_link=$(readlink "$_claude_as_script" 2>/dev/null) || { + _claude_as_message 31 2 "claude-as: cannot resolve the launcher link $_claude_as_script." + return 1 + } + case $_claude_as_link in + /*) _claude_as_script=$_claude_as_link ;; + *) _claude_as_script=$_claude_as_dir/$_claude_as_link ;; + esac + done + _claude_as_root=$(CDPATH='' cd -P -- "$(dirname -- "$_claude_as_script")/.." 2>/dev/null && pwd) || { + _claude_as_message 31 2 "claude-as: cannot resolve the launcher directory of $_claude_as_script." + return 1 + } +} +if [ "$#" -eq 0 ] || [ "$1" = --help ]; then + # Help is plain shell: a lone copy without the kit still answers it. + _claude_as_docs=https://github.com/FridrichMethod/coding-agent-kit/blob/main/docs/account-launchers.md + if _claude_as_locate 2>/dev/null && [ -f "$_claude_as_root/docs/account-launchers.md" ]; then + _claude_as_docs=$_claude_as_root/docs/account-launchers.md + fi + _claude_as_message '1;36' 1 'Usage: claude-as NAME [claude arguments...]' + printf '%s\n' \ + 'Credentials: /claude-NAME (default ~/.secrets); owner-only 0600 OAuth token.' \ + 'Missing account: confirm [y/N], native setup-token, save, launch.' \ + 'Automatic capture audited for Claude Code 2.1.296; otherwise explicit manual fallback.' \ + 'No server validation, OAuth implementation or automatic token renewal.' \ + 'login/logout/setup-token, --bare, unsupported flags and auth/provider overrides are refused.' \ + 'Daily configuration is inherited. No global process killing or concurrency lock.' \ + 'Requires Python 3.11+ (stdlib).' \ + 'Configuration: ${XDG_CONFIG_HOME:-~/.config}/coding-agent-kit/config.toml; --show-config prints the effective values.' \ + "Details and manual repair: $_claude_as_docs" + [ "$#" -gt 0 ] && exit 0 + exit 2 +fi +_claude_as_locate || exit 1 +if [ ! -r "$_claude_as_root/lib/claude-as/launch.py" ]; then + _claude_as_message 31 2 "claude-as: incomplete install at $_claude_as_root (missing lib/claude-as/launch.py)" + exit 1 +fi +exec python3 -I -B "$_claude_as_root/lib/claude-as/launch.py" "$@" diff --git a/bin/codex-as b/bin/codex-as new file mode 100755 index 0000000..af7b736 --- /dev/null +++ b/bin/codex-as @@ -0,0 +1,101 @@ +#!/bin/bash +# codex-as NAME [CODEX_ARGS...] - invoked directly, like claude-as. +# Fixed account views; shared sessions. Requires codex-cli 0.161.0 or newer. + +# Native Codex gets the caller's umask, variables, cwd and options; the Python +# launcher writes its own files under umask 077. +# CODING_AGENT_KIT_COLOR=auto|always|never styles these messages, never native Codex output. +_codex_as_message() { + if [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != dumb ] && + { [ "${CODING_AGENT_KIT_COLOR:-auto}" = always ] || + { [ "${CODING_AGENT_KIT_COLOR:-auto}" != never ] && [ -t "$2" ]; }; }; then + printf '\033[%sm%s\033[0m\n' "$1" "$3" >&"$2" + else + printf '%s\n' "$3" >&"$2" + fi +} +# Set _codex_as_root to the kit, the physical bin/.. of this file after its +# symlinks; readlink -f is not portable, and paths may contain spaces. +_codex_as_locate() { + _codex_as_script=$0 + case $_codex_as_script in + */*) ;; + *) [ -e "$_codex_as_script" ] || _codex_as_script=$(command -v "$_codex_as_script") || { + _codex_as_message 31 2 "codex-as: cannot find this launcher ($0) on PATH." + return 1 + } ;; + esac + _codex_as_links=0 + while [ -L "$_codex_as_script" ]; do + _codex_as_links=$((_codex_as_links + 1)) + if [ "$_codex_as_links" -gt 40 ]; then + _codex_as_message 31 2 'codex-as: launcher symlink loop (more than 40 links); reinstall the helper.' + return 1 + fi + _codex_as_dir=$(CDPATH='' cd -P -- "$(dirname -- "$_codex_as_script")" 2>/dev/null && pwd) && + _codex_as_link=$(readlink "$_codex_as_script" 2>/dev/null) || { + _codex_as_message 31 2 "codex-as: cannot resolve the launcher link $_codex_as_script." + return 1 + } + case $_codex_as_link in + /*) _codex_as_script=$_codex_as_link ;; + *) _codex_as_script=$_codex_as_dir/$_codex_as_link ;; + esac + done + _codex_as_root=$(CDPATH='' cd -P -- "$(dirname -- "$_codex_as_script")/.." 2>/dev/null && pwd) || { + _codex_as_message 31 2 "codex-as: cannot resolve the launcher directory of $_codex_as_script." + return 1 + } +} +if [ "$#" -eq 0 ] || [ "$1" = --help ]; then + # Help is plain shell: a lone copy without the kit still answers it. + _codex_as_docs=https://github.com/FridrichMethod/coding-agent-kit/blob/main/docs/account-launchers.md + if _codex_as_locate 2>/dev/null && [ -f "$_codex_as_root/docs/account-launchers.md" ]; then + _codex_as_docs=$_codex_as_root/docs/account-launchers.md + fi + _codex_as_message '1;36' 1 'Usage: codex-as NAME [codex arguments...]' + printf '%s\n' \ + 'Credentials: /codex-NAME.json (default ~/.secrets; complete JSON, owner-only 0600).' \ + 'Account home: /accounts/NAME (default ~/.codex); config/sessions/SQLite shared from .' \ + 'Forwards codex arguments after its own -c file-auth and sqlite_home flags; every command but' \ + 'exec also gets -c check_for_update_on_startup=false and --no-daemon.' \ + 'Root help or version, help, update and completion run as plain codex in .' \ + 'Refuses login (not login status), logout, agents, queue, app, daemon management' \ + 'and remote/provider/auth overrides; -p NAME uses /NAME.config.toml.' \ + 'Missing account: confirm [y/N], device login in empty temporary home, save, launch.' \ + 'Root /auth.json stays native: codex-as never reads or changes it.' \ + 'Concurrent accounts and different sessions are supported; native thread locks are shared.' \ + 'Resume UUID: takes it over from the root daemon, asking only if it may be running.' \ + 'resume --last: takes over the session it opens if idle; the picker: the idle sessions it lists.' \ + 'Takeover ends the session and its agents there; resume, fork or exec resume of that UUID,' \ + 'the picker or --last finishes an interrupted one.' \ + 'Same-account OAuth refresh retains native cross-process limitations.' \ + 'Resume by UUID, --last or picker; explicit session names are refused.' \ + 'Requires Python 3.11+ (stdlib) and Codex 0.161.0 or newer; no upper version limit.' \ + 'Configuration: ${XDG_CONFIG_HOME:-~/.config}/coding-agent-kit/config.toml; --show-config prints the effective values.' \ + "Details and manual repair: $_codex_as_docs" + [ "$#" -gt 0 ] && exit 0 + exit 2 +fi +_codex_as_locate || exit 1 + +# --list-accounts and --show-config alone are the launcher's own metadata queries; +# no account name starts with -. +if [ "$#" -ne 1 ] || { [ "$1" != --list-accounts ] && [ "$1" != --show-config ]; }; then + case $1 in + '' | -* | *[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-]*) + _codex_as_message 31 2 'codex-as: invalid account name (ASCII letters, digits, _ or -; no leading -).' + exit 2 + ;; + esac +fi +if ! command -v python3 >/dev/null 2>&1; then + _codex_as_message 31 2 'codex-as: Python 3.11+ is required for local JSON and file checks.' + exit 1 +fi +_codex_as_launcher=$_codex_as_root/lib/codex-as/launch.py +if [ ! -r "$_codex_as_launcher" ]; then + _codex_as_message 31 2 "codex-as: incomplete install at $_codex_as_root (missing lib/codex-as/launch.py)" + exit 1 +fi +exec python3 -I -B "$_codex_as_launcher" "$@" diff --git a/common/claude/.local/bin/claude-as b/common/claude/.local/bin/claude-as deleted file mode 100755 index e163463..0000000 --- a/common/claude/.local/bin/claude-as +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/sh -# claude-as ACCOUNT [CLAUDE_ARGS...] -# Existing token: launch. Missing token: confirm, native setup-token, save, launch. -set -eu -umask 077 -# Only style this command's heading, never the native Claude streams. -_claude_as_message() { - if [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != dumb ] && - { [ "${DOTFILES_COLOR:-auto}" = always ] || - { [ "${DOTFILES_COLOR:-auto}" != never ] && [ -t "$2" ]; }; }; then - printf '\033[%sm%s\033[0m\n' "$1" "$3" >&"$2" - else - printf '%s\n' "$3" >&"$2" - fi -} -if [ "$#" -eq 0 ] || [ "$1" = --help ]; then - _claude_as_message '1;36' 1 'Usage: claude-as NAME [claude arguments...]' - printf '%s\n' \ - 'Credentials: ~/.secrets/claude-NAME; owner-only 0600 OAuth token.' \ - 'Missing account: confirm [y/N], native setup-token, save, launch.' \ - 'Automatic capture audited for Claude Code 2.1.296; otherwise explicit manual fallback.' \ - 'No server validation, OAuth implementation or automatic token renewal.' \ - 'login/logout/setup-token, --bare, unsupported flags and auth/provider overrides are refused.' \ - 'Daily configuration is inherited. No global process killing or concurrency lock.' \ - 'Requires Python 3.11+ (stdlib). See ~/dotfiles/docs/account-launchers.md.' - [ "$#" -gt 0 ] && exit 0 - exit 2 -fi -exec python3 -I -B "$HOME/.local/lib/claude-as/launch.py" "$@" diff --git a/common/claude/.local/bin/claude-settings-sync b/common/claude/.local/bin/claude-settings-sync deleted file mode 100755 index d40670a..0000000 --- a/common/claude/.local/bin/claude-settings-sync +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# claude-settings-sync [--check] [--hooks EVENTS.json] PORTABLE LIVE -# Resolve Stow links without GNU readlink -f; paths may contain spaces. -set -eu - -sync_script=$0 -case "$sync_script" in - */*) ;; - *) sync_script=$(command -v "$sync_script") ;; -esac -sync_links=0 -while [ -L "$sync_script" ]; do - sync_links=$((sync_links + 1)) - if [ "$sync_links" -gt 40 ]; then - echo "claude-settings-sync: wrapper symlink loop" >&2 - exit 1 - fi - sync_dir=$(CDPATH='' cd -P -- "$(dirname -- "$sync_script")" && pwd) - sync_link=$(readlink "$sync_script") - if command -v cygpath >/dev/null 2>&1; then - sync_link=$(cygpath -u "$sync_link") - fi - case "$sync_link" in - /*) sync_script=$sync_link ;; - *) sync_script=$sync_dir/$sync_link ;; - esac -done -sync_root=$(CDPATH='' cd -P -- "$(dirname -- "$sync_script")/../../../.." && pwd) -sync_kind=claude-settings-sync -# shellcheck source=lib/sync-runtime.sh -. "$sync_root/lib/sync-runtime.sh" diff --git a/common/codex/.local/bin/codex-as b/common/codex/.local/bin/codex-as deleted file mode 100755 index 644ca2a..0000000 --- a/common/codex/.local/bin/codex-as +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash -# codex-as NAME [CODEX_ARGS...] - invoked directly, like claude-as. -# Fixed account views; shared sessions. Requires codex-cli 0.161.0 or newer. - -# Native Codex gets the caller's umask, variables, cwd and options; the Python -# launcher writes its own files under umask 077. -# Match the dotfiles terminal policy without changing native Codex output. -_codex_as_message() { - if [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != dumb ] && - { [ "${DOTFILES_COLOR:-auto}" = always ] || - { [ "${DOTFILES_COLOR:-auto}" != never ] && [ -t "$2" ]; }; }; then - printf '\033[%sm%s\033[0m\n' "$1" "$3" >&"$2" - else - printf '%s\n' "$3" >&"$2" - fi -} -if [ "$#" -eq 0 ] || [ "$1" = --help ]; then - _codex_as_message '1;36' 1 'Usage: codex-as NAME [codex arguments...]' - printf '%s\n' \ - 'Credentials: ~/.secrets/codex-NAME.json (complete JSON, owner-only 0600).' \ - 'Account home: ~/.codex/accounts/NAME; config/sessions/SQLite shared from ~/.codex.' \ - 'Forwards codex arguments after its own -c file-auth and sqlite_home flags; every command but' \ - 'exec also gets -c check_for_update_on_startup=false and --no-daemon.' \ - 'Root help or version, help, update and completion run as plain codex in ~/.codex.' \ - 'Refuses login (not login status), logout, agents, queue, app, daemon management' \ - 'and remote/provider/auth overrides; -p NAME uses ~/.codex/NAME.config.toml.' \ - 'Missing account: confirm [y/N], device login in empty temporary home, save, launch.' \ - 'Root ~/.codex/auth.json stays native: codex-as never reads or changes it.' \ - 'Concurrent accounts and different sessions are supported; native thread locks are shared.' \ - 'Resume UUID: takes it over from the root daemon, asking only if it may be running.' \ - 'resume --last: takes over the session it opens if idle; the picker: the idle sessions it lists.' \ - 'Takeover ends the session and its agents there; resume, fork or exec resume of that UUID,' \ - 'the picker or --last finishes an interrupted one.' \ - 'Same-account OAuth refresh retains native cross-process limitations.' \ - 'Resume by UUID, --last or picker; explicit session names are refused.' \ - 'Requires Python 3.11+ (stdlib) and Codex 0.161.0 or newer; no upper version limit.' \ - 'Details and manual repair: ~/dotfiles/docs/account-launchers.md' - [ "$#" -gt 0 ] && exit 0 - exit 2 -fi - -case $1 in - '' | -* | *[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-]*) - _codex_as_message 31 2 'codex-as: invalid account name (ASCII letters, digits, _ or -; no leading -).' - exit 2 - ;; -esac -if ! command -v python3 >/dev/null 2>&1; then - _codex_as_message 31 2 'codex-as: Python 3.11+ is required for local JSON and file checks.' - exit 1 -fi -_codex_as_launcher=$HOME/.local/lib/codex-as/launch.py -if [ ! -r "$_codex_as_launcher" ]; then - _codex_as_message 31 2 'codex-as: missing ~/.local/lib/codex-as/launch.py; reinstall the helper.' - exit 1 -fi -exec python3 -I -B "$_codex_as_launcher" "$@" diff --git a/common/codex/.local/bin/codex-config-sync b/common/codex/.local/bin/codex-config-sync deleted file mode 100755 index 6f7fa5c..0000000 --- a/common/codex/.local/bin/codex-config-sync +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# codex-config-sync [--check | --migrate-portable] [--hooks EVENTS.json] PORTABLE LIVE -# Resolve Stow links without GNU readlink -f; paths may contain spaces. -set -eu - -sync_script=$0 -case "$sync_script" in - */*) ;; - *) sync_script=$(command -v "$sync_script") ;; -esac -sync_links=0 -while [ -L "$sync_script" ]; do - sync_links=$((sync_links + 1)) - if [ "$sync_links" -gt 40 ]; then - echo "codex-config-sync: wrapper symlink loop" >&2 - exit 1 - fi - sync_dir=$(CDPATH='' cd -P -- "$(dirname -- "$sync_script")" && pwd) - sync_link=$(readlink "$sync_script") - if command -v cygpath >/dev/null 2>&1; then - sync_link=$(cygpath -u "$sync_link") - fi - case "$sync_link" in - /*) sync_script=$sync_link ;; - *) sync_script=$sync_dir/$sync_link ;; - esac -done -sync_root=$(CDPATH='' cd -P -- "$(dirname -- "$sync_script")/../../../.." && pwd) -sync_kind=codex-config-sync -# shellcheck source=lib/sync-runtime.sh -. "$sync_root/lib/sync-runtime.sh" diff --git a/common/codex/.local/bin/codex-instructions-sync b/common/codex/.local/bin/codex-instructions-sync deleted file mode 100755 index 5307833..0000000 --- a/common/codex/.local/bin/codex-instructions-sync +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# codex-instructions-sync [--check] [--append RULES] PORTABLE LIVE -# Resolve Stow links without GNU readlink -f; paths may contain spaces. -set -eu - -sync_script=$0 -case "$sync_script" in - */*) ;; - *) sync_script=$(command -v "$sync_script") ;; -esac -sync_links=0 -while [ -L "$sync_script" ]; do - sync_links=$((sync_links + 1)) - if [ "$sync_links" -gt 40 ]; then - echo "codex-instructions-sync: wrapper symlink loop" >&2 - exit 1 - fi - sync_dir=$(CDPATH='' cd -P -- "$(dirname -- "$sync_script")" && pwd) - sync_link=$(readlink "$sync_script") - if command -v cygpath >/dev/null 2>&1; then - sync_link=$(cygpath -u "$sync_link") - fi - case "$sync_link" in - /*) sync_script=$sync_link ;; - *) sync_script=$sync_dir/$sync_link ;; - esac -done -sync_root=$(CDPATH='' cd -P -- "$(dirname -- "$sync_script")/../../../.." && pwd) -sync_kind=codex-instructions-sync -# shellcheck source=lib/sync-runtime.sh -. "$sync_root/lib/sync-runtime.sh" diff --git a/common/codex/.local/bin/codex-rules-sync b/common/codex/.local/bin/codex-rules-sync deleted file mode 100755 index 001f1a5..0000000 --- a/common/codex/.local/bin/codex-rules-sync +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# codex-rules-sync [--check] PORTABLE LIVE -# Resolve Stow links without GNU readlink -f; paths may contain spaces. -set -eu - -sync_script=$0 -case "$sync_script" in - */*) ;; - *) sync_script=$(command -v "$sync_script") ;; -esac -sync_links=0 -while [ -L "$sync_script" ]; do - sync_links=$((sync_links + 1)) - if [ "$sync_links" -gt 40 ]; then - echo "codex-rules-sync: wrapper symlink loop" >&2 - exit 1 - fi - sync_dir=$(CDPATH='' cd -P -- "$(dirname -- "$sync_script")" && pwd) - sync_link=$(readlink "$sync_script") - if command -v cygpath >/dev/null 2>&1; then - sync_link=$(cygpath -u "$sync_link") - fi - case "$sync_link" in - /*) sync_script=$sync_link ;; - *) sync_script=$sync_dir/$sync_link ;; - esac -done -sync_root=$(CDPATH='' cd -P -- "$(dirname -- "$sync_script")/../../../.." && pwd) -sync_kind=codex-rules-sync -# shellcheck source=lib/sync-runtime.sh -. "$sync_root/lib/sync-runtime.sh" diff --git a/common/claude/.local/share/bash-completion/completions/claude-as b/completions/bash/claude-as similarity index 80% rename from common/claude/.local/share/bash-completion/completions/claude-as rename to completions/bash/claude-as index 5be421d..9c5037e 100644 --- a/common/claude/.local/share/bash-completion/completions/claude-as +++ b/completions/bash/claude-as @@ -1,9 +1,10 @@ # shellcheck shell=bash -# bash-completion autoloads this file; metadata only, no CLI invocation. +# bash-completion autoloads this file. Account names are metadata only: +# claude-as --list-accounts never starts native Claude. _claude_as() { local names name if [ "$COMP_CWORD" -eq 1 ]; then - names=$(python3 -I -B "$HOME/.local/lib/claude-as/launch.py" --list-accounts 2>/dev/null) || return + names=$(command claude-as --list-accounts 2>/dev/null) || return COMPREPLY=() while IFS= read -r name; do [ -n "$name" ] && COMPREPLY+=("$name") diff --git a/common/codex/.local/share/bash-completion/completions/codex-as b/completions/bash/codex-as similarity index 80% rename from common/codex/.local/share/bash-completion/completions/codex-as rename to completions/bash/codex-as index 1e2ee07..689dac2 100644 --- a/common/codex/.local/share/bash-completion/completions/codex-as +++ b/completions/bash/codex-as @@ -1,9 +1,10 @@ # shellcheck shell=bash -# bash-completion autoloads this file; metadata only, no CLI invocation. +# bash-completion autoloads this file. Account names are metadata only: +# codex-as --list-accounts never starts native Codex. _codex_as() { local names name if [ "$COMP_CWORD" -eq 1 ]; then - names=$(python3 -I -B "$HOME/.local/lib/codex-as/check.py" accounts 2>/dev/null) || return + names=$(command codex-as --list-accounts 2>/dev/null) || return COMPREPLY=() while IFS= read -r name; do [ -n "$name" ] && COMPREPLY+=("$name") diff --git a/common/claude/.oh-my-zsh/custom/completions/_claude-as b/completions/zsh/_claude-as similarity index 80% rename from common/claude/.oh-my-zsh/custom/completions/_claude-as rename to completions/zsh/_claude-as index 2d85738..1804b51 100644 --- a/common/claude/.oh-my-zsh/custom/completions/_claude-as +++ b/completions/zsh/_claude-as @@ -2,7 +2,8 @@ local -a names suggestions if ((CURRENT == 2)); then - names=("${(@f)$(python3 -I -B "$HOME/.local/lib/claude-as/launch.py" --list-accounts 2>/dev/null)}") + # Metadata only: claude-as --list-accounts never starts native Claude. + names=("${(@f)$(command claude-as --list-accounts 2>/dev/null)}") names=("${(@)names:#}") ((${#names})) && _describe -t accounts 'Claude accounts' names return diff --git a/common/codex/.oh-my-zsh/custom/completions/_codex-as b/completions/zsh/_codex-as similarity index 80% rename from common/codex/.oh-my-zsh/custom/completions/_codex-as rename to completions/zsh/_codex-as index a3f9614..05fd424 100644 --- a/common/codex/.oh-my-zsh/custom/completions/_codex-as +++ b/completions/zsh/_codex-as @@ -2,7 +2,8 @@ local -a names suggestions if ((CURRENT == 2)); then - names=("${(@f)$(python3 -I -B "$HOME/.local/lib/codex-as/check.py" accounts 2>/dev/null)}") + # Metadata only: codex-as --list-accounts never starts native Codex. + names=("${(@f)$(command codex-as --list-accounts 2>/dev/null)}") names=("${(@)names:#}") ((${#names})) && _describe -t accounts 'Codex accounts' names return diff --git a/docs/account-launchers.md b/docs/account-launchers.md index c08ff90..451ca35 100644 --- a/docs/account-launchers.md +++ b/docs/account-launchers.md @@ -1,6 +1,8 @@ # Codex/Claude account launchers -Each tool's Stow package installs its executable command in `~/.local/bin`; no sourcing is required. Small Python standard-library helpers live in each tool's `~/.local/lib`. They require Python 3.11+, install no dependencies, implement no OAuth flow, and run no background service. +`install.sh` links both commands into `~/.local/bin` (or `PREFIX/bin`); no sourcing is required. Each launcher follows its own symlinks to the kit and runs its Python standard-library modules from the kit's `lib/`. They require Python 3.11+, install no dependencies, implement no OAuth flow, and run no background service. + +Paths below use the configured values: `` (default `~/.codex`) and `` (default `~/.secrets`). `codex-as --show-config` and `claude-as --show-config` print the values in effect; [configuration](configuration.md) describes the file that changes them. ## Daily use and adding accounts @@ -20,47 +22,47 @@ Valid local credentials launch immediately. A missing account lists existing acc Claude setup-token tokens typically last one year and do not renew automatically. They support model requests, with no guarantee for Remote Control or claude.ai connectors; local MCP remains usable. [Official documentation](https://code.claude.com/docs/en/authentication) -Confirmation and login I/O use a separate `/dev/tty`, preserving the final stdin/stdout/stderr, cwd, arguments, and exit code. A missing account is refused without a controlling terminal; there is no `--yes`. The wrapper's own `--help`, and `codex-as NAME` with a root help or version flag, `help`, `update` or `completion`, run without the account and never start a login. +Confirmation and login I/O use a separate `/dev/tty`, preserving the final stdin/stdout/stderr, cwd, arguments, and exit code. A missing account is refused without a controlling terminal; there is no `--yes`. The wrapper's own `--help`, `--list-accounts` and `--show-config`, and `codex-as NAME` with a root help or version flag, `help`, `update` or `completion`, run without the account and never start a login. ## Codex account layout -Each account has a fixed entry point: `~/.codex/accounts/NAME/auth.json -> ~/.secrets/codex-NAME.json`. Account directories are 0700; complete credentials are 0600. `codex-as NAME` sets that account's `CODEX_HOME` and enforces file authentication; the first launch creates the account view itself, with no separate initialization command. Other arguments go to native `codex` unchanged after its own file-auth and SQLite flags (every command but `exec` also gets the update-check and `--no-daemon` flags), and the launched Codex keeps the caller's umask. `login` (except `login status` and help), `logout`, the daemon-only `agents`, `queue`, `app` (macOS and Windows), `remote-control` subcommands and `app-server daemon`, and remote or provider/auth settings are refused; `-p NAME` profiles work from `~/.codex/NAME.config.toml`; root help or version, `help`, `update` and `completion` run as plain `codex`. See the [Codex verification record](codex-as.md) for the exact list and the remaining differences from native Codex. The root `~/.codex/auth.json` belongs to native `codex`, desktop and IDE clients. codex-as never reads, checks or changes it, so the root login and each account keep independent token chains, even for the same ChatGPT user. +Each account has a fixed entry point: `/accounts/NAME/auth.json -> /codex-NAME.json`, an absolute link. Account directories are 0700; complete credentials are 0600. `codex-as NAME` sets that account's `CODEX_HOME` and enforces file authentication; the first launch creates the account view itself, with no separate initialization command. Other arguments go to native `codex` unchanged after its own file-auth and SQLite flags (every command but `exec` also gets the update-check and `--no-daemon` flags), and the launched Codex keeps the caller's umask. `login` (except `login status` and help), `logout`, the daemon-only `agents`, `queue`, `app` (macOS and Windows), `remote-control` subcommands and `app-server daemon`, and remote or provider/auth settings are refused; `-p NAME` profiles work from `/NAME.config.toml`; root help or version, `help`, `update` and `completion` run as plain `codex`. See the [Codex verification record](codex-as.md) for the exact list and the remaining differences from native Codex. The root `/auth.json` belongs to native `codex`, desktop and IDE clients. codex-as never reads, checks or changes it, so the root login and each account keep independent token chains, even for the same ChatGPT user. -Private backups live at `~/.secrets/.codex-as-backup-*` and are excluded from completion. Permission errors name the exact path and are never fixed by an automatic chmod: `~/.codex` and `~/.codex/.tmp` must be owned real directories that are not group/other writable, and `~/.secrets` must be 0700. The account marker `codex-as.initialized` and a private pending-setup record are fully written to temporary files before atomic publication, so an interrupted first launch can be retried; unknown existing directories are preserved and initialization is refused. +Private backups live at `/.codex-as-backup-*` and are excluded from completion. Permission errors name the exact path and are never fixed by an automatic chmod: the parents of `` and `` (both `$HOME` by default), `` and `/.tmp` must be owned real directories that are not group/other writable, and `` must be 0700. The account marker `codex-as.initialized` and a private pending-setup record are fully written to temporary files before atomic publication, so an interrupted first launch can be retried; unknown existing directories are preserved and initialization is refused. -Older codex-as layouts linked the root `auth.json` into `~/.secrets` and left `~/.codex/codex-as.layout.json` and `~/.codex/codex-as.initialized`. Both markers are now ignored and can be deleted. A remaining root link still works, but it shares that account's token chain with the root clients, so a root login or logout revokes the account. To separate them, quit every root, desktop and IDE client completely, including the root app-server daemon and IDE extension app-servers, until `pgrep -af 'codex.*app-server'` lists none. Then remove only the link with `rm ~/.codex/auth.json` (the secrets file stays) and log in natively in the root home. A client that is still running keeps the old tokens in memory, and its logout (`account/logout`) revokes those cached tokens even after the link is gone, so quit it rather than logging out. +Older codex-as layouts linked the root `auth.json` into `` and left `/codex-as.layout.json` and `/codex-as.initialized`. Both markers are now ignored and can be deleted. A remaining root link still works, but it shares that account's token chain with the root clients, so a root login or logout revokes the account. To separate them, quit every root, desktop and IDE client completely, including the root app-server daemon and IDE extension app-servers, until `pgrep -af 'codex.*app-server'` lists none. Then remove only the link with `rm /auth.json` (the secrets file stays) and log in natively in the root home. A client that is still running keeps the old tokens in memory, and its logout (`account/logout`) revokes those cached tokens even after the link is gone, so quit it rather than logging out. -Credentials renew without a new login. The access token carries its own expiry (ten days in the files observed here); native Codex refreshes it when less than five minutes remain, and the server rotates the refresh token, which Codex writes through the account link into `~/.secrets/codex-NAME.json`. Only use triggers this refresh; no background keepalive runs. A revoked refresh token cannot be renewed and needs a new device login. **Native `codex login` and `codex logout` revoke the tokens of the current home's `auth.json` before removing it.** In the root home, a desktop or IDE logout included, that affects only the root's own login, unless that file is still a link into `~/.secrets` or a still-running client loaded its tokens through such a link; codex-as refuses both inside account views. A logout from inside an account's Codex (`/logout`) is native and still revokes that account's tokens; the launcher warns at exit with the steps below. Add accounts with `codex-as NEWNAME`. To replace a revoked account, move its secrets file aside under a name that does not match `codex-NAME.json`, then run `codex-as NAME`. +Credentials renew without a new login. The access token carries its own expiry (ten days in the files observed here); native Codex refreshes it when less than five minutes remain, and the server rotates the refresh token, which Codex writes through the account link into `/codex-NAME.json`. Only use triggers this refresh; no background keepalive runs. A revoked refresh token cannot be renewed and needs a new device login. **Native `codex login` and `codex logout` revoke the tokens of the current home's `auth.json` before removing it.** In the root home, a desktop or IDE logout included, that affects only the root's own login, unless that file is still a link into `` or a still-running client loaded its tokens through such a link; codex-as refuses both inside account views. A logout from inside an account's Codex (`/logout`) is native and still revokes that account's tokens; the launcher warns at exit with the steps below. Add accounts with `codex-as NEWNAME`. To replace a revoked account, move its secrets file aside under a name that does not match `codex-NAME.json`, then run `codex-as NAME`. Config, both AGENTS files, rules, skills, agents, memories, session directories, history, the session index, and the complete SQLite home are shared. Database files and WAL/SHM use the original directory together, rather than individual file links. Configuration path fields must be absolute because native relative paths resolve against the account directory; the launcher reports the field name and never rewrites configuration. An explicit `sqlite_home` retains its absolute directory; otherwise, the root directory is used. Conflicting environment, project, or CLI overrides are refused. Native thread writer locks and the rollout maintenance lock are shared. An active writer prevents another account from writing the same session. Interactive resume takes sessions over from the root daemon so another account can write them: it ends the old session and spawned agents there, temporarily archives them, restores saved archive visibility, then launches the selected account. An explicit `codex-as OTHER resume UUID` does this without a prompt for an idle session and asks first when it may be running, since takeover interrupts active work. `resume --last` takes over only the session it opens, the newest one native Codex lists, when that one is idle in the root daemon. Before the picker starts, the idle top-level sessions it lists by default (this directory, or the same place in a linked worktree; all of them with `--all`) are taken over and named, because the selection happens inside native Codex; they move to the top of the list. Sessions that may be running stay and are named too. Clients attached to the root daemon, such as Desktop, close a session taken over. The remaining daemon sessions stay loaded. Sessions held by standalone clients are never touched; native Codex shows its conflict screen until they exit. Explicit session names have account-path filtering problems, so the launcher rejects them and requests a UUID. Names can still be displayed and edited in the native UI. -Takeover requires a root daemon at 0.161.0 or newer, and a controlling terminal only to confirm a session that may be running. Unknown identities, unsafe sockets and ambiguous ownership cause refusal, and an unrecognized option in the resume command skips takeover. Recovery metadata stays private under `~/.codex/accounts/.locks/UUID.handoff.json`; retain it and retry the same command after an interrupted or failed takeover. `fork UUID`, `exec resume UUID` and `exec fork UUID` never take over, but first finish an interrupted takeover of that UUID; `resume UUID` does too, and the picker and `resume --last` finish every interrupted takeover. Recovery only restores visibility. Saved history and original descendant archive visibility are preserved; unfinished runtime progress, SessionEnd hook effects and timestamp changes cannot be rolled back. +Takeover requires a root daemon at 0.161.0 or newer, and a controlling terminal only to confirm a session that may be running. Unknown identities, unsafe sockets and ambiguous ownership cause refusal, and an unrecognized option in the resume command skips takeover. Recovery metadata stays private under `/accounts/.locks/UUID.handoff.json`; retain it and retry the same command after an interrupted or failed takeover. `fork UUID`, `exec resume UUID` and `exec fork UUID` never take over, but first finish an interrupted takeover of that UUID; `resume UUID` does too, and the picker and `resume --last` finish every interrupted takeover. Recovery only restores visibility. Saved history and original descendant archive visibility are preserved; unfinished runtime progress, SessionEnd hook effects and timestamp changes cannot be rolled back. Cloud policy, the model catalog/cache, remote plugin installations/cache, MCP/Connector login, daemons, IPC, temporary files, and logs remain separate for each account. Local plugin paths in shared configuration can be shared. Remote plugin cache directories cannot be linked wholesale: one account's sync could delete another account's bundles. These existing root account caches are not imported into new accounts. If account credentials are atomically replaced with a regular file, the link is restored when there are no other wrapper clients and the file matches secrets. If secrets have not changed during this run and the user identity matches, both copies are backed up, the new original bytes are imported, and the link is restored. If both copies changed, no startup snapshot exists, or identities differ, repair stops without guessing from timestamps. The last exiting client or the next idle startup can perform safe repair; repair errors do not replace the native exit code. -Divergence needs a manual choice while every client of that account is closed; the error prints both paths. Back up `~/.codex/accounts/NAME/auth.json` and `~/.secrets/codex-NAME.json`, keep the wanted copy at `~/.secrets/codex-NAME.json`, then restore the link with `ln -sfn ~/.secrets/codex-NAME.json ~/.codex/accounts/NAME/auth.json`. A missing link is restored the same way, unless a logout inside Codex removed it: those tokens are revoked, so move the secrets file aside and run `codex-as NAME` to log in again. **Do not delete the account marker or overwrite either file with an older snapshot.** See the [Codex verification record](codex-as.md) for source and test limitations. +Divergence needs a manual choice while every client of that account is closed; the error prints both paths and the command. Back up `/accounts/NAME/auth.json` and `/codex-NAME.json`, keep the wanted copy at `/codex-NAME.json`, then restore the link with `ln -sfn /codex-NAME.json /accounts/NAME/auth.json`. Use the absolute paths the error or `codex-as --show-config` prints: the launcher compares the link text with them, so a link through a symlinked alias of the same directory is refused. A missing link is restored the same way, unless a logout inside Codex removed it: those tokens are revoked, so move the secrets file aside and run `codex-as NAME` to log in again. **Do not delete the account marker or overwrite either file with an older snapshot.** See the [Codex verification record](codex-as.md) for source and test limitations. ## Claude configuration and security boundaries -Tokens are saved at `~/.secrets/claude-NAME`: 0600, a regular file owned by the current user, with a single hard link. Daily startup sets only `CLAUDE_CODE_OAUTH_TOKEN` for the child and inherits the existing `CLAUDE_CONFIG_DIR`. It does not force `~/.claude`, preserving existing sources of `.claude.json`, MCP, configuration, and trust state. +Tokens are saved at `/claude-NAME`: 0600, a regular file owned by the current user, with a single hard link. Daily startup sets only `CLAUDE_CODE_OAUTH_TOKEN` for the child and inherits the existing `CLAUDE_CONFIG_DIR`. It does not force `~/.claude`, preserving existing sources of `.claude.json`, MCP, configuration, and trust state. Explicit API key/OAuth token/authentication FD/alternative provider/endpoint/profile overrides, authentication helpers in settings, and organization restrictions are refused. Only variable names or sources are reported, never values. Passthrough of login/logout/setup-token, `--bare`, authentication configuration source overrides such as remote/SDK/managed-settings/project-config-root, and unaudited options is prohibited. Only the first command operand is identified using native argument boundaries; words inside prompts are not scanned. Native Claude dispatches an exact first `auth` operand to authentication subcommands even with `-p` or `--`, so it is refused. Required/optional/variadic argument values retain their native roles. This is a bounded local CLI option check, without a promise of compatibility with every command. Managed policy is respected: automatic enrollment stops for nonempty managed caches, system policy, policy helpers, authentication bindings, or profile loading chains that cannot be preserved. Empty or missing caches do not cause this refusal. The local cache inspected was an empty object; it is checked again at runtime. -New account files are created exclusively; incorrect permissions, ownership, symlinks, or hard links are refused. Account names must be nonempty and contain only ASCII letters, digits, underscores, and hyphens. Local format validation is not server authentication; expired credentials are not automatically deleted or replaced through login. +New account files are created exclusively; incorrect permissions, ownership, symlinks, or hard links are refused. Account names must be nonempty, contain only ASCII letters, digits, underscores, and hyphens, and not start with a hyphen, as in codex-as. Local format validation is not server authentication; expired credentials are not automatically deleted or replaced through login. ## Completion, installation, and concurrency -zsh uses OMZ custom completions from each tool's package and the existing compinit; Bash autoloads from the standard bash-completion user directory. The first argument enumerates only safe filenames and metadata, without reading credentials, running the CLI, or using the network. Corrupt contents are detected at startup. Later arguments first complete the supported scope, then delegate to existing native completion when available. +`install.sh` links the zsh completions into `PREFIX/share/zsh/site-functions`, which must be on `fpath` before `compinit` runs (with oh-my-zsh, before it is sourced; the installer prints the line), and the Bash completions into the bash-completion user directory, `${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-~/.local/share}/bash-completion}/completions`, where bash-completion autoloads them. For the first argument both run `codex-as --list-accounts` or `claude-as --list-accounts`, so the launchers must be on `PATH`; the listing enumerates only safe filenames and metadata, without reading credentials, running the native CLI, or using the network, and an invalid configuration offers nothing. Corrupt contents are detected at startup. Later arguments first complete the supported scope, then delegate to existing native completion when available. -fzf-tab uses the same candidates; previews for both account commands only print text and do not read files. Normal Tab works without fzf. New terminals load the new completions; no source or PATH lines are appended to rc files. Installation uses Stow and triggers no real initialization, login, or model requests. +fzf-tab, when present, uses the same candidates; normal Tab works without fzf. New terminals load the new completions; nothing appends source or PATH lines to rc files. Installation only creates links and triggers no real initialization, login, or model requests. -The launcher's own step/help headings are cyan, confirmations/warnings yellow, successful saves/initialization green, and errors red. By default, color is enabled only for the terminal receiving the message; redirected logs stay plain. Confirmations on a separate `/dev/tty` can still use color without affecting redirected Codex/Claude output. `DOTFILES_COLOR=auto|always|never` is supported; nonempty `NO_COLOR` or `TERM=dumb` takes priority and disables color. Native command output, completion account lists, and internal machine output are not colorized. +The launcher's own step/help headings are cyan, confirmations/warnings yellow, successful saves/initialization green, and errors red. By default, color is enabled only for the terminal receiving the message; redirected logs stay plain. Confirmations on a separate `/dev/tty` can still use color without affecting redirected Codex/Claude output. `CODING_AGENT_KIT_COLOR=auto|always|never` is supported (unset or any other value means `auto`); nonempty `NO_COLOR` or `TERM=dumb` takes priority and disables color. Native command output, completion account lists, and internal machine output are not colorized. **Codex supports concurrent sessions across accounts and different sessions within one account.** Each wrapper and native child holds a shared account lease; credential repair requires an exclusive lease. Suspended processes or a child surviving an abnormal supervisor exit still block repair. Account lease files persist and are not deleted based on PIDs; per-session takeover leases are removed when the takeover ends. A shell inheriting `CODEX_HOME` from account A can launch B directly, but the inherited source must be a verified managed account directory. Native same-account OAuth refresh has only an in-process lock, so multiple processes can still refresh simultaneously or briefly read partial JSON; the wrapper implements no additional OAuth. Direct CLI, desktop, and IDE clients do not participate in wrapper leases, and automatic exit repair cannot exclude them. Manual repair requires closing that account's clients. Processes are not automatically killed, quotas are not switched automatically, no background keepalive runs, and Claude tokens are not renewed. @@ -69,7 +71,9 @@ The launcher's own step/help headings are cyan, confirmations/warnings yellow, s ```sh tests/codex-as.sh tests/account-enrollment.sh +tests/config.sh +tests/standalone-e2e.sh pre-commit run --all-files ``` -Routine tests use temporary HOME directories, fictitious credentials, and fake CLIs. They cover Bash/zsh, TTY, stdin/JSON stdout, link repair and interrupted setup, root auth isolation, concurrent leases, signals, refresh writeback, incorrect permissions, and completion/preview secrecy. When Codex 0.161.0 or newer is installed, an additional real Codex offline app-server fixture checks legacy/paginated history, shared SQLite discovery, native writer locks and token refresh writeback through the account link against a local token endpoint, without login or model calls and with zero other requests. A missing or older Codex explicitly skips this check. Native protocol tests do not verify real-account server authorization, quotas or token lifetimes, and do not directly operate the terminal picker/`--last`. +Routine tests use temporary HOME directories, fictitious credentials, and fake CLIs. They cover Bash/zsh, TTY, stdin/JSON stdout, link repair and interrupted setup, root auth isolation, concurrent leases, signals, refresh writeback, incorrect permissions, completion secrecy, the configuration file, launcher location and `install.sh`. When Codex 0.161.0 or newer is installed, an additional real Codex offline app-server fixture checks legacy/paginated history, shared SQLite discovery, native writer locks and token refresh writeback through the account link against a local token endpoint, without login or model calls and with zero other requests. A missing or older Codex explicitly skips this check. Native protocol tests do not verify real-account server authorization, quotas or token lifetimes, and do not directly operate the terminal picker/`--last`. diff --git a/docs/codex-as.md b/docs/codex-as.md index efa9858..2a4bdf8 100644 --- a/docs/codex-as.md +++ b/docs/codex-as.md @@ -1,6 +1,6 @@ # codex-as -See [account launchers](account-launchers.md) for daily use, first-time initialization, new-account login, and completion. +See [account launchers](account-launchers.md) for daily use, first-time initialization, new-account login, and completion. Paths below use the configured `` (default `~/.codex`) and `` (default `~/.secrets`); see [configuration](configuration.md). ```sh codex-as ayting @@ -10,6 +10,8 @@ codex-as ayting exec --json "Check the tests" Existing accounts launch directly. Missing accounts require confirmation, followed by native device login in an empty temporary `CODEX_HOME`; after saving complete JSON credentials, the original command continues. No sourcing or old /tmp initialization examples are needed. +Apart from `--help`, the launcher has two options of its own, each accepted only as the single argument and neither starting Codex: `codex-as --list-accounts` prints the account names (what completion offers), and `codex-as --show-config` prints the configuration file in use, the `codex` executable, `` and ``. + ## Daemon takeover ```sh @@ -81,7 +83,7 @@ Codex then shows its conflict screen. The lock probe briefly holds a free writer lock, so a native client opening it at that instant also sees the conflict screen. Before archive, a 0600 recovery journal is written and synced, including its -directory, under `~/.codex/accounts/.locks/UUID.handoff.json`; a per-UUID lease +directory, under `/accounts/.locks/UUID.handoff.json`; a per-UUID lease next to it serializes takeovers and is removed when one ends. A timeout, disconnect or interruption with unknown archive completion retains the journal. The next `resume`, `fork` or `exec resume` of that UUID, or the next picker or @@ -95,7 +97,7 @@ visibility is restored. An explicit UUID, and `--last` for the session it opens, do not launch the account backend while that session's recovery remains incomplete, because native Codex would open it archived or open another one; the picker warns and launches. Both point to the preserved records in -`~/.codex/accounts/.locks` when the daemon is not running. `--last` chooses its session after recovering the others, since +`/accounts/.locks` when the daemon is not running. `--last` chooses its session after recovering the others, since a restored session becomes the newest. Keep the journal until the helper removes it. Declining the confirmation leaves session state untouched. @@ -151,7 +153,7 @@ pin: table or array is refused when it uses TOML 1.1 syntax (such as a trailing comma in an inline table) that this Python's `tomllib` cannot read. - `-p NAME` and `--profile NAME` select profile v2, which layers - `~/.codex/NAME.config.toml`: the launcher links that file into the account + `/NAME.config.toml`: the launcher links that file into the account home, where native Codex reads it, and checks it like the user configuration (absolute paths, the shared `sqlite_home`). A legacy `profile` key is a native error, and `[profiles.*]` tables are never @@ -164,8 +166,8 @@ pin: Codex reads as a login, token endpoint or billing scope (`CODEX_API_KEY`, `CODEX_ACCESS_TOKEN`, `OPENAI_API_KEY`, which realtime voice uses as its login, `OPENAI_ORGANIZATION`, `OPENAI_PROJECT`, the token and login URL - overrides and the workload identity variables); a `CODEX_HOME` other than `~/.codex` or - a managed account home; a `CODEX_SQLITE_HOME` or project `sqlite_home` other + overrides and the workload identity variables); a `CODEX_HOME` other than `` or + an initialized account home in `/accounts`; a `CODEX_SQLITE_HOME` or project `sqlite_home` other than the shared one; relative path fields in shared configuration; and an administrator `cli_auth_credentials_store` other than `file`. Variables no supported release reads as Codex's own login, such as `OPENAI_ORG_ID`, pass, @@ -211,7 +213,7 @@ running `codex ARGS...`. What still differs: native Codex dies of SIGINT, SIGTERM, SIGHUP, SIGKILL or SIGPIPE, the launcher dies of the same signal; other signals exit with 128 plus the signal number. - A logout inside Codex (`/logout`) is native and revokes the account's tokens - in `~/.secrets/codex-NAME.json`; the launcher cannot stop it, and warns at + in `/codex-NAME.json`; the launcher cannot stop it, and warns at exit with the steps to log in again. A write through the link that names another ChatGPT user is kept and reported. diff --git a/docs/config-sync.md b/docs/config-sync.md deleted file mode 100644 index 5e6ff70..0000000 --- a/docs/config-sync.md +++ /dev/null @@ -1,207 +0,0 @@ -# AI configuration sync runtime and contract - -Installation and login orchestration remain shell and PowerShell. The four -`*-sync` entrypoints delegate configuration/file operations to the -small `lib/config_sync.py` backend. It uses Python's standard-library JSON -implementation and `tomlkit` for syntax-aware TOML edits; no AWK/regular-expression -TOML parser or alternate jq merge implementation remains. - -This file is the helper half of that contract: the runtime lookup, the helper -API, merge ownership, the Codex instructions overlay, the migration semantics -and file safety. Provisioning (`setup-sync`, the `tomlkit` pin and -`.venv-sync`) works the same way in this repository; it, and how the dotfiles -installers call the helpers, are described in the dotfiles -[docs/config-sync.md](https://github.com/FridrichMethod/dotfiles/blob/main/docs/config-sync.md). - -## Runtime lookup - -The shell wrappers resolve their own Stow symlink chains to find the checkout -and its `lib/` directory. They use `.venv-sync/bin/python` on Unix or -`.venv-sync/Scripts/python.exe` under native Windows/Git Bash. An explicit -`DOTFILES_SYNC_PYTHON` **executable path** overrides this lookup, useful for CI -or a separately provisioned host-local environment. An invalid override fails; -it never falls back silently to another interpreter. Native Windows paths are -converted for Git Bash's executable lookup. Backend paths and arguments remain -quoted, and Python runs with `-I -B -X utf8` (no user-site/PYTHONPATH injection or -bytecode cache writes, and UTF-8 diagnostics for Windows paths). - -Python itself does not need a venv. A separately provisioned interpreter selected -with `DOTFILES_SYNC_PYTHON` can run the helpers without one. JSON/rules do not -require `tomlkit` on that path, but TOML sync and the full `--runtime-check` still -require the exact pin. The default launcher continues to use `.venv-sync`; it -does not silently search system Python. See [dependency options](https://github.com/FridrichMethod/dotfiles/blob/main/docs/dependencies.md). - -## Helper API - -```text -codex-config-sync [--quiet] [--check | --migrate-portable] [--hooks EVENTS.json] PORTABLE LIVE -claude-settings-sync [--quiet] [--check] [--hooks EVENTS.json] PORTABLE LIVE -codex-rules-sync [--quiet] [--check] PORTABLE LIVE -codex-instructions-sync [--quiet] [--check] [--append RULES] PORTABLE LIVE -``` - -All paths are explicit; helpers do not infer a user's real home. `--check` -validates the runtime, both selected inputs and the complete serialized merge -without creating target directories, temporary files, or changing any input. -It permits an absent live file and validates a legacy live symlink's contents -without replacing it. This is the installers' preflight interface. It is not a -promise that a later write will succeed: permissions or files can change after -preflight, and a filesystem can run out of space. - -`--quiet` suppresses successful validation/sync messages, never errors. Installers -use it for both preflight and apply, and report their own stages and final summary. - -Missing portable input, unreadable/nonregular input, dangling live symlinks, -malformed JSON/TOML, and invalid required portable fields fail closed. Empty live -JSON is treated as a fresh host; nonempty malformed JSON is rejected. Empty TOML -is a valid fresh document. Empty portable rules are rejected. Duplicate JSON -keys and non-JSON constants such as `NaN` are rejected rather than normalized. - -Ordinary sync treats the portable source as read-only: bytes, permissions and -mtime are untouched. Passing the same regular file or two hardlinks as portable -and live is rejected. A live symlink to the portable source is supported for -migration to a mutable regular file. - -## Merge ownership - -Codex ownership, required types, and retired paths are centralized in -`CODEX_POLICY`, `CODEX_OPTIONAL_POLICY` and `CODEX_RETIRED` in `lib/config_sync.py`. The current owned -fields are: - -| TOML path | Owned required keys | -| --- | --- | -| Top level | `model`, `model_reasoning_effort`, `plan_mode_reasoning_effort`, `personality`, `default_permissions`, `approval_policy`, `approvals_reviewer`, `project_doc_fallback_filenames` | -| `features` | `network_proxy`, `multi_agent`, `memories` | -| `permissions.workspace-net` | `description`, `extends` | -| `permissions.workspace-net.network` | `enabled`, `allow_local_binding` | -| `permissions.workspace-net.network.domains` | `"*"` | -| `memories` | `generate_memories`, `use_memories` | -| `hooks` (optional) | Typed command event arrays, only when explicitly present | - -Portable values replace these paths only. Other live keys, tables, comments, -multiline strings and arrays of tables are retained by TOMLKit's document model. -Formatting of edited values can change; the library may also normalize some -unusual out-of-order array-of-tables layouts. Semantic no-ops do not replace the -file. Retired `sandbox_mode`, the whole `sandbox_workspace_write` table, and -`features.js_repl` are removed. Unknown portable keys are not silently deployed -as shared policy. - -Claude deep-merges objects with portable leaf values winning. Arrays, including -permissions and hooks, replace wholesale, even when empty. Live-only fields such -as `model` and `permissions.additionalDirectories` remain. `false`, `null`, and -numeric values remain distinct. Portable `permissions.allow` and `permissions.ask` -must be string arrays, and portable `model` is rejected to protect per-host model -selection. This refactor does not change the contents of the portable policies -or the separate Node-based Claude hooks. - -Explicit `--hooks EVENTS.json` enables scoped hook-array preservation for Claude -or Codex. It validates command registrations, combines live/portable/opt-in arrays -without exact duplicates, and keeps host-only registrations. Claude's other -portable arrays retain their ordinary ownership. For Codex, absent portable -`hooks` leaves all existing runtime hooks intact, including previously opted-in -registrations; optional portable hook events also merge with host registrations. -Removing a portable/scoped registration does not revoke a live opt-in registration; -review and remove the exact live handler when retiring or upgrading it. - -Codex's on-disk timeout field is `timeout`, a positive integer in seconds. The -normalized API field `timeoutSec` and the unsupported `timeout_sec` are rejected -by sync because Codex 0.161.0 can silently ignore them. Non-command handlers, -unknown events/fields, and conflicting Windows command spellings fail before -writes. Registration and client trust remain separate from file sync: run the -helper with `--check` against a disposable target first, then let the client -report what it loaded. For Codex, `python3 -I -B tests/inspect_codex_hooks.py ---home DISPOSABLE_CODEX_HOME --cwd PROJECT [--command COMMAND]` is an offline -`hooks/list` probe that makes no model call. - -Codex rules remain an opaque, authoritative byte copy: the backend does not -parse, reformat or edit rule syntax. Siblings such as `default.rules` are untouched. - -## Codex instructions overlay - -Codex reads one global instructions file, `~/.codex/AGENTS.override.md` when it -exists and `~/.codex/AGENTS.md` otherwise, with no include mechanism. Claude -receives a host's sherlock-kit rules through the stowed -`~/.claude/rules/sherlock-kit.md` link; Codex can only receive them inside one -generated override. `codex-instructions-sync` owns that file: - -- With `--append RULES` it reads PORTABLE (`common/codex/.codex/AGENTS.md`) and - RULES through their whole symlink chains; both must be nonempty UTF-8 regular - files. LIVE becomes the marker line, PORTABLE and RULES, each without its - trailing newlines and separated by one blank line, ending in one newline. The - marker is exactly - ``. -- Only a regular LIVE whose first line is the marker is ever rewritten or - removed. With `--append`, a symlinked, nonregular or user-owned LIVE fails - closed and is left as it is. A byte-identical result is a no-op. -- Without `--append` (a host without rules), a generated LIVE is removed, an - absent one stays absent, and anything else is kept with a warning on stderr - and exit status 0, so a user's own override never blocks an unrelated host. -- A RULES link that dangles fails with a hint to run - `git submodule update --init --recursive`. -- `--check` never writes or unlinks. Its success line ends in `(current)` when - the apply step would change nothing, `(stale)` when it would rewrite or remove - LIVE, and `(foreign file left unchanged)` when it would only warn. Drift is not - an error, so the installer preflight still passes; read the line instead. - -## Legacy portable cleanup - -Older Stow links may have allowed runtime state to accumulate in tracked Codex -input. Ordinary sync now materializes the live file without cleaning its source. -To explicitly clean that known legacy state, pass the portable file and the -live file (the dotfiles guide `docs/config-sync.md` names them for its -checkout): - -```sh -codex-config-sync --migrate-portable PORTABLE LIVE -``` - -Migration first retains portable-only runtime state in the live document; -existing live runtime values win conflicts. It then applies owned portable -policy and removes retired paths. Only after the live snapshot is safely -materialized does it rewrite the portable baseline to owned keys. This also -preserves source-only runtime state when live is already a regular file or is -absent, not just when it remains a legacy symlink. The portable migration target -must itself be a regular file, not a symlink. Review the resulting tracked diff; -the command intentionally modifies the source and never runs automatically. - -## File safety and test boundaries - -Each changed file is serialized and validated before writes, staged in an -adjacent temporary file, flushed, and replaced with `os.replace`. There is no -preliminary unlink of an existing live symlink. Failed temporary creation, -writing or replacement leaves the prior file/link in place and cleans the -temporary file. New regular files and materialized legacy symlinks default to -`0600` for Codex config and `0644` for Claude settings, Codex rules and the -generated Codex instructions on Unix. Existing regular-file permissions are -intersected with those defaults, never broadened: for example, a private `0600` -Claude file stays private on both changed writes and no-ops. Explicit portable migration also preserves stricter source modes. -Windows uses native replacement behavior and inherited directory ACLs; -Unix permission bits are not an ACL policy there. - -Unchanged regular files keep their inode and mtime; Unix mode drift may still be -corrected. The backend checks for observed content changes before replacement -and asks for retry instead of overwriting them. This is not an atomic -compare-and-swap and cannot eliminate a simultaneous application's final -read/replace race. Close the configuration editor during manual migrations. - -Safety is per file, not a transaction across all AI files. Explicit migration -writes live first, then portable; failure of the second write leaves the original -portable source intact and can be retried. Installer state must only acknowledge -a completely successful install. - -Tests use the standard library, disposable targets, and injected write failures: - -```sh -.venv-sync/bin/python -I -B -m unittest discover -s tests -p test_config_sync.py -v -./tests/codex-config-sync.sh -./tests/ai-config-sync.sh -``` - -```powershell -& ./.venv-sync/Scripts/python.exe -I -B -m unittest discover -s tests -p test_config_sync.py -v -``` - -Native backend tests run on all three OSes; tests specific to POSIX permission -bits/FIFOs are explicitly marked. Windows symlink tests require the CI runner's -symlink privilege. These tests do not establish OpenSSH network-logon trust or -Task Scheduler behavior; those are separate installer/update integration checks. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..5177aba --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,312 @@ +# Configuration + +`codex-as` and `claude-as` read one optional TOML file. Without it they use +the defaults below, which give the same arguments, environment, files and +links as 0.1.0, which had no configuration. The file only moves paths and +names executables; it adds no behavior. + +`lib/coding-agent-kit/config.py` is the only code that reads it, and the only +launcher or completion code that reads `HOME`. Each run loads it once, after +the Python version check: every launch, `--list-accounts`, `--show-config` +and therefore every completion. `--help` and a run without arguments are +plain shell and never read it, nor does a `codex-as` account name that fails +the name check. + +## Location + +The first rule that applies wins: + +1. `CODING_AGENT_KIT_CONFIG`, when set and non-empty. It must be an absolute + path and the file must exist. +2. `$XDG_CONFIG_HOME/coding-agent-kit/config.toml`, when `XDG_CONFIG_HOME` is + an absolute path. An empty or relative value is ignored. +3. `$HOME/.config/coding-agent-kit/config.toml`. + +For rules 2 and 3 a file that does not exist means the defaults, and so does a +path component that is a regular file (for example a file named `~/.config`), +because no file can exist below it. A dangling symlink, at the file or at a +directory on the way to it, is an error, and so is any other failure to open +it, such as a permission denial (an unreadable file or an unsearchable +directory) or a symlink loop: the launchers never fall back to the defaults +when a file may be there. Such a failure therefore stops every launch, +listing and completion until it is fixed, even when no file exists. `HOME` +must be set to an absolute path in every case. + +Neither the launchers nor `install.sh` ever create the file. + +## Format and keys + +The keys are top-level strings, all optional: + +```toml +codex = "codex" +claude = "claude" +codex_home = "~/.codex" +secrets_dir = "~/.secrets" +``` + +This file, with the defaults written out, behaves exactly like no file. Put +the keys before any `[table]` header: TOML assigns the keys that follow a +header to that table, which then counts as one unknown key, and the keys in it +are not read. + +| Key | Default | Used by | Value | +| --- | --- | --- | --- | +| `codex` | `"codex"` | `codex-as` | the Codex executable | +| `claude` | `"claude"` | `claude-as` | the Claude Code executable | +| `codex_home` | `"~/.codex"` | `codex-as` | the root Codex home; accounts live in `codex_home/accounts` | +| `secrets_dir` | `"~/.secrets"` | both | the directory of `codex-NAME.json` and `claude-NAME` credentials | + +Both launchers validate every key, so an invalid `codex_home` also stops +`claude-as`. + +Rules for every key: + +- The value is a non-empty string without control characters (U+0000 to + U+001F and U+007F to U+009F, so C1 controls such as CSI are refused too), + which would reach the terminal raw in `--show-config` and in error + messages. +- `~/` at the start expands from `$HOME`. `~` alone and `~user` are not + accepted. + +`codex` and `claude`: + +- A bare name made only of `A-Z a-z 0-9 . _ + -` is looked up on `PATH` when + the launcher runs, as before. +- Anything else must be an absolute path or start with `~/`. The path names + one executable file; it is not a command line and takes no arguments. + +`codex_home` and `secrets_dir`: + +- Absolute, or starting with `~/`. +- No `..` component. +- Normalized only lexically: repeated slashes (a leading `//` included, which + Linux and macOS read as `/`), `.` components and a trailing slash are + dropped. Symlinks in the value are kept as written and never resolved, + because the launchers store these paths in absolute account links and + compare the links' text on every launch. +- They must differ, and neither may lie inside the other. This check is + lexical too, so it does not see two spellings of one directory through a + symlink, or through case on a case-insensitive file system. +- The parent of each must be a directory you own that neither group nor others + can write: `codex-as` checks both parents, `claude-as` the parent of + `secrets_dir`. With the defaults both parents are `$HOME`, checked as + before. A parent that does not exist, also one below a regular file, is + named in the error; the launchers never create it. +- The directories themselves follow the rules in + [account launchers](account-launchers.md): `codex_home` owned and not + group/other writable, `secrets_dir` owned with mode 0700. +- The account root is always `codex_home/accounts`; it is not configurable. + +An unknown key prints a warning and the run continues, so a file written for +a newer kit still works with an older one. `python3` is always found on +`PATH`; the interpreter is not configurable. + +## File permissions + +The file decides which executable runs with your credentials and where your +tokens are written, so anyone who can change it can capture them. The +launchers therefore open it once, following symlinks, and check the opened +file itself: + +- It is a regular file. A symlink that resolves is fine; the checks apply to + its target. +- Its owner is you or root. +- Others cannot write it. +- The group can write it only when the group is your user private group: your + login group (the group ID in the password database, which `id -g` shows in + a login shell; `newgrp` and `sg` do not change it), named exactly like your + user, with no other member, neither listed in the group nor an account + whose primary group it is. Debian and Ubuntu create such a group for each + user and then commonly use umask 002, which makes new files 0664. Any other + group-writable file is refused, because the other members of that group + could edit it. +- A group-writable file that carries an access ACL (POSIX or NFSv4) is + refused even in your private group: with an ACL the group bits show the ACL + mask, which named users and groups can use. Remove the ACL with + `setfacl -b FILE`, or the group write with `chmod g-w FILE`. A directory + default ACL gives every new file such an ACL. On NFSv4 every group-writable + file and directory counts as having one; see below. +- It holds at most 64 KiB of UTF-8 text. + +Whoever can write a directory on the way can replace the file, so the +launchers also check, once each, every directory from the file's parent up +to and including `$HOME` (or its resolved path, when `$HOME` is a symlink) +when the file lies below `$HOME`, otherwise up to `/`, both on the path as +the location rules give it and on its resolved path (after symlinks): + +- Each directory is owned by you or root. +- Others cannot write it, and the group only under the same private-group + and ACL rule as the file, unless it is sticky like `/tmp`, where other + users cannot rename or remove what they do not own. An Ubuntu `~/.config` + with mode 0775 therefore passes, also in a `newgrp` or `sg` shell (not on + NFSv4; see below), and so does a stowed link into a checkout you own. +- A symlink on the path as given, the file's own included, is owned by you or + root. + +Limits: the private-group rule sees only the accounts the host can list +(`getent passwd`), so users of a directory service that does not enumerate +(sssd or LDAP with enumeration off, the default) are not seen. A run lists +them at most once, and only when a group-writable file or directory is in +your login group. ACLs on macOS (`chmod +a`) are not checked, nor NFSv4 +grants that the mode bits do not show. Only the path as given and the +resolved path are checked, not intermediate symlinks or their directories. + +On an NFSv4 mount, the Linux client reports an ACL on every file and +directory of a server that supports ACLs, whatever their mode. There every +group-writable file or directory on the way to the configuration file is +refused, even in your private group, with the +`group-writable with an access ACL` message: an Ubuntu home with umask 002 +is refused on the file, then on `~/.config/coding-agent-kit`, then on +`~/.config`. `setfacl -b` does not work on NFSv4; remove the group write with +`chmod g-w` on each path the message names, for example +`chmod g-w ~/.config ~/.config/coding-agent-kit ~/.config/coding-agent-kit/config.toml`. + +The launchers never change any mode or owner; fix it yourself, for example +with `chmod go-w FILE`. + +## Messages + +`TOOL` is `codex-as` or `claude-as`, and `FILE` the path from the location +rules. Warnings are yellow and errors red under the launchers' colour policy +(see the README). Every message is one line on stderr. No error prints a +traceback or any of the file's contents; a TOML syntax error names only its +position. + +Warning, the run continues: + +``` +TOOL: FILE: unknown key KEY ignored +``` + +A key that is not a bare TOML key is shown JSON-quoted, for example +`unknown key "a b" ignored`. + +Errors, exit 1, nothing launched: + +``` +TOOL: HOME must be set to an absolute path. +TOOL: VALUE: CODING_AGENT_KIT_CONFIG must be an absolute path. +TOOL: FILE: not found (CODING_AGENT_KIT_CONFIG). +TOOL: FILE: dangling symlink. +TOOL: FILE: dangling symlink COMPONENT. +TOOL: FILE: cannot be opened (REASON). +TOOL: FILE: not a regular file. +TOOL: FILE: owned by another user. +TOOL: FILE: writable by other users. +TOOL: FILE: group-writable, and the group is not your private group. +TOOL: FILE: group-writable with an access ACL; remove the ACL (setfacl -b) or the group write (chmod g-w). +TOOL: FILE: directory DIR: REASON +TOOL: FILE: symlink LINK: owned by another user. +TOOL: FILE: cannot be checked (REASON). +TOOL: FILE: larger than 64 KiB. +TOOL: FILE: not UTF-8 text. +TOOL: FILE: invalid TOML at line L, column C. +TOOL: FILE: KEY: must be a string. +TOOL: FILE: KEY: must not be empty. +TOOL: FILE: KEY: must not contain control characters. +TOOL: FILE: KEY: must be a command name ([A-Za-z0-9._+-]+) or an absolute or ~/ path. +TOOL: FILE: KEY: must be an absolute or ~/ path. +TOOL: FILE: KEY: must not contain a .. component. +TOOL: FILE: secrets_dir: must differ from codex_home. +TOOL: FILE: secrets_dir: must not be inside codex_home. +TOOL: FILE: codex_home: must not be inside secrets_dir. +``` + +A syntax error at the very end reads `invalid TOML at end of document.`, and +one the parser reports without a position `invalid TOML.`, as do nesting +deeper than Python's parser can follow and an integer longer than Python +converts (4300 digits). The first `dangling symlink` form is the file +itself, the second a directory on the way to it. The `directory` form names +the directory and one of the file's reasons (`owned by another user.`, +`writable by other users.` or one of the two `group-writable` ones), and +`cannot be checked` a directory or ACL that could not be read. + +Some launch errors name configured values, and only appear with a +non-default configuration: + +``` +codex-as: the configured codex executable VALUE was not found. +claude-as: the configured claude executable VALUE was not found. +claude-as: PARENT must be an owned, real directory without group/other write access. +claude-as: SECRETS_DIR must be an owned, real directory with permissions 0700. +``` + +With the defaults the texts are unchanged (`Codex is not installed on PATH.`, +`real Claude executable not found on PATH.`, `HOME must be an owned, real +user directory without group/other write access.` and `.secrets must be an +owned, real directory with permissions 0700.`). The directory errors of +`codex-as` already name the full path. A parent that a launcher checks +(above) and that does not exist, `$HOME` included, or that lies below a +regular file, stops it with: + +``` +TOOL: PARENT does not exist; create it, owned by you and not group or other writable. +``` + +## --show-config and --list-accounts + +Both are the launcher's own queries, accepted only as the single argument. +They read metadata only: no environment or argument checks, no directory is +created, no native CLI starts and nothing touches the network. + +`TOOL --show-config` prints the effective values and exits 0: + +``` +$ codex-as --show-config +config = /home/u/.config/coding-agent-kit/config.toml (not found; defaults) +codex = codex (/usr/bin/codex) +codex_home = /home/u/.codex +secrets_dir = /home/u/.secrets +$ claude-as --show-config +config = /home/u/.config/coding-agent-kit/config.toml (not found; defaults) +claude = claude (/home/u/.local/bin/claude) +secrets_dir = /home/u/.secrets +``` + +- The `config` line shows the file that was looked for and one of `found`, + `not found; defaults` or `from CODING_AGENT_KIT_CONFIG`. +- The executable line shows the value, then the file `PATH` lookup finds, or + `not found on PATH` for a bare name, or `not an executable file` for a + configured path that is missing or not executable. +- Paths are shown expanded and normalized, exactly as the launchers use them. +- Warnings go to stderr first. An invalid file prints its error and exits 1. + +`TOOL --list-accounts` prints one account name per line, sorted, from +`secrets_dir`: the `codex-NAME.json` or `claude-NAME` files that are regular, +owned by you, mode 0600 and not hard-linked. The Bash and zsh completions run +`command TOOL --list-accounts 2>/dev/null` and offer nothing else for the +account argument. An invalid configuration or an unsafe `secrets_dir` prints +nothing on stdout and an error on stderr, and exits 1, so completion offers +no names. + +## Moving codex_home and secrets_dir + +Choose both before the first `codex-as NAME`. Each account view under +`codex_home/accounts/NAME` holds absolute links: `auth.json` to +`secrets_dir/codex-NAME.json`, and the shared configuration, sessions, +history and other shared state to `codex_home`. Every launch checks each link's text +and refuses one that differs (`managed link PATH is missing/replaced; preserve +it and restore the link before launch.`), and the launchers never rewrite +them. Changing either value after enrolment therefore stops every existing +Codex account until its links are recreated by hand. `claude-as` keeps no +links; its tokens are the plain files `secrets_dir/claude-NAME`. + +To start with other locations: + +1. Create the parent directories, owned by you and not group or other + writable. A missing one stops the launch with `PARENT does not exist`. +2. Write the configuration file, and check it with `codex-as --show-config` + and `claude-as --show-config`. +3. Enroll with `codex-as NAME` and `claude-as NAME`. The launchers create + `codex_home`, `codex_home/accounts` and `secrets_dir` (0700) as needed. + +Native Codex knows nothing of this file. After moving `codex_home`, export +`CODEX_HOME=` for plain `codex`, the desktop app and IDE clients, +so that the root login, its configuration and its daemon live where +`codex-as` shares them from. `codex-as` accepts an inherited `CODEX_HOME` +equal to `codex_home` or to an initialized account home below +`codex_home/accounts`, and refuses any other. Root help, version, `update` and +`completion` run with `CODEX_HOME=`. Daemon takeover talks only to +a root daemon whose Codex home is `codex_home`; any other daemon is not used, +and native Codex shows its conflict screen instead. diff --git a/common/claude/.claude/dotfiles/check-git-hooks.cjs b/helpers/check-git-hooks.cjs similarity index 100% rename from common/claude/.claude/dotfiles/check-git-hooks.cjs rename to helpers/check-git-hooks.cjs diff --git a/common/claude/.claude/dotfiles/notify.cjs b/helpers/notify.cjs similarity index 100% rename from common/claude/.claude/dotfiles/notify.cjs rename to helpers/notify.cjs diff --git a/common/claude/.claude/dotfiles/statusline.cjs b/helpers/statusline.cjs similarity index 100% rename from common/claude/.claude/dotfiles/statusline.cjs rename to helpers/statusline.cjs diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6151eb0 --- /dev/null +++ b/install.sh @@ -0,0 +1,153 @@ +#!/bin/sh +# install.sh [--prefix DIR] [--uninstall]: link this checkout's launchers and +# completions. It replaces or removes only its own links: those whose text +# starts with this checkout's physical path (as pwd -P prints it) and has no +# . or .. components, the form it writes, even when that target has since +# moved or gone. A file, a directory, another checkout's link, a link made +# through another spelling of this checkout's path and every relative link +# (Stow's or one made by hand) are never touched. It places no link through a +# relative directory link below HOME or the prefix's parent, the form Stow +# folding writes; HOME itself and links above it never count. It checks every +# destination before it changes anything. +set -eu +fail() { + printf 'install.sh: %s\n' "$2" >&2 + exit "$1" +} +usage() { + printf '%s\n' 'Usage: install.sh [--prefix DIR] [--uninstall]' \ + 'Links bin/{codex-as,claude-as} into DIR/bin (default DIR: ~/.local), their bash completions into' \ + '${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-~/.local/share}/bash-completion}/completions and their' \ + 'zsh completions into DIR/share/zsh/site-functions. Refuses any other existing path there.' >&"$1" + exit "$2" +} +unset prefix +uninstall=0 +while [ "$#" -gt 0 ]; do + case $1 in + --prefix) case ${2-} in /*) prefix=${2%/} && shift ;; *) fail 2 '--prefix needs an absolute directory.' ;; esac ;; + --uninstall) uninstall=1 ;; + --help) usage 1 0 ;; + *) usage 2 2 ;; + esac + shift +done +case ${HOME-} in /*) ;; *) fail 1 'HOME must be set to an absolute path.' ;; esac +prefix=${prefix-$HOME/.local} +bash_dir=${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion}/completions +zsh_dir=$prefix/share/zsh/site-functions +case $bash_dir in /*) ;; *) fail 1 "the bash completion directory $bash_dir is not absolute." ;; esac +# check looks for relative directory links strictly below these two, so links +# above them (macOS /var -> private/var) never count; the prefix stands in for /. +home=${HOME%/} base=$(dirname -- "$prefix") +[ -n "$home" ] || home=$prefix +[ "$base" != / ] || base=$prefix +# The root is the physical directory of this file after its links (no readlink -f). +self=$0 hops=0 +case $self in */*) ;; *) [ -e "$self" ] || self=$(command -v "$self") || fail 1 "cannot find $0." ;; esac +while [ -L "$self" ]; do + [ "$((hops += 1))" -le 40 ] || fail 1 'symlink loop (more than 40 links).' + link=$(readlink "$self") || fail 1 "cannot resolve the link $self." + case $link in /*) self=$link ;; *) self=$(dirname -- "$self")/$link ;; esac +done +root=$(CDPATH='' cd -P -- "$(dirname -- "$self")" && pwd) || fail 1 "cannot resolve the directory of $self." +# ours PATH: PATH is a link whose text is an absolute path in root, the form place writes. +ours() { + [ -L "$1" ] && target=$(readlink "$1") || return 1 + case /$target/ in */../* | */./*) return 1 ;; esac + case $target in "$root"/?*) return 0 ;; *) return 1 ;; esac +} +what() { + if [ -L "$1" ]; then + printf 'a symlink to %s' "$(readlink "$1")" + elif [ -d "$1" ]; then + printf 'a directory' + else + printf 'a file' + fi +} +each() { + for tool in codex-as claude-as; do + "$1" "$prefix/bin/$tool" "bin/$tool" + "$1" "$bash_dir/$tool" "completions/bash/$tool" + "$1" "$zsh_dir/_$tool" "completions/zsh/_$tool" + done +} +check() { + [ -f "$root/$2" ] || fail 1 "$root is not a complete coding-agent-kit checkout (missing $2)." + if { [ -e "$1" ] || [ -L "$1" ]; } && ! ours "$1"; then + printf 'install.sh: refusing %s: %s, not an absolute link into %s\n' "$1" "$(what "$1")" "$root" >&2 + refused=1 + return 0 + fi + # A relative directory link on the way is what Stow folding writes (a + # missing ~/.local/bin becomes a link into another repository's package): + # a link placed through it would land in that repository. Absolute + # aliases are fine. + dir=$(dirname -- "$1") + while :; do + case $dir in "$home"/?* | "$base"/?*) ;; *) break ;; esac + case ${HOME%/}/ in "$dir"/*) break ;; esac # HOME and its ancestors never count + if [ -L "$dir" ]; then + case $(readlink "$dir") in + /*) ;; + *) + printf 'install.sh: refusing %s: %s is a relative link to %s, the form Stow folding writes\n' \ + "$1" "$dir" "$(readlink "$dir")" >&2 + refused=1 + return 0 + ;; + esac + fi + dir=$(dirname -- "$dir") + done + # place must be able to create the missing directories and the link. + dir=$(dirname -- "$1") + while [ ! -e "$dir" ] && [ ! -L "$dir" ]; do dir=$(dirname -- "$dir"); done + if [ ! -d "$dir" ] || [ ! -w "$dir" ] || [ ! -x "$dir" ]; then + printf 'install.sh: refusing %s: its nearest existing parent %s is not a writable directory\n' "$1" "$dir" >&2 + refused=1 + fi +} +place() { + mkdir -p -- "$(dirname -- "$1")" + rm -f -- "$1" + ln -s -- "$root/$2" "$1" + printf '%s -> %s\n' "$1" "$root/$2" +} +# uncheck PATH: --uninstall can tell whether PATH is its own link, and remove it. +uncheck() { + dir=$(dirname -- "$1") + while [ ! -e "$dir" ]; do dir=$(dirname -- "$dir"); done + if [ -d "$dir" ] && [ ! -x "$dir" ]; then + printf 'install.sh: refusing to look for %s: %s is not a searchable directory\n' "$1" "$dir" >&2 + refused=1 + elif ours "$1" && [ ! -w "$dir" ]; then + printf 'install.sh: refusing to remove %s: %s is not a writable directory\n' "$1" "$dir" >&2 + refused=1 + fi +} +remove() { + if ours "$1"; then + rm -f -- "$1" + printf 'removed %s\n' "$1" + elif [ -e "$1" ] || [ -L "$1" ]; then + printf 'kept %s: %s, not an absolute link into %s\n' "$1" "$(what "$1")" "$root" + fi +} +refused=0 +if [ "$uninstall" = 1 ]; then + each uncheck + [ "$refused" = 0 ] || fail 1 'nothing changed; make those directories writable and searchable first.' + each remove + exit 0 +fi +each check +[ "$refused" = 0 ] || fail 1 'nothing changed; move those paths aside or choose another --prefix.' +each place +printf '%s\n' 'zsh: unless fpath has it, add this before compinit (with oh-my-zsh, before sourcing it):' \ + " fpath=(\"$zsh_dir\" \$fpath)" 'Claude Code helpers, as commands in settings.json:' \ + " statusLine: node \"$root/helpers/statusline.cjs\"" \ + " Notification hook: node \"$root/helpers/notify.cjs\"" \ + " PreToolUse hook (Bash): node \"$root/helpers/check-git-hooks.cjs\"" +case :${PATH-}: in *":$prefix/bin:"*) ;; *) printf 'PATH: add %s\n' "$prefix/bin" ;; esac diff --git a/common/claude/.local/lib/claude-as/launch.py b/lib/claude-as/launch.py similarity index 84% rename from common/claude/.local/lib/claude-as/launch.py rename to lib/claude-as/launch.py index 67d9099..a1a084d 100644 --- a/common/claude/.local/lib/claude-as/launch.py +++ b/lib/claude-as/launch.py @@ -1,6 +1,7 @@ """Small native-login adapter; no OAuth endpoints, refresh, logs or credential output.""" import codecs +import importlib.util import json import os from pathlib import Path @@ -38,8 +39,8 @@ def styled(text, style, fd): - """Use the same auto/always/never policy as lib/terminal.sh per destination.""" - mode = os.environ.get("DOTFILES_COLOR", "auto") + """CODING_AGENT_KIT_COLOR=auto|always|never per destination; NO_COLOR or TERM=dumb turn it off.""" + mode = os.environ.get("CODING_AGENT_KIT_COLOR", "auto") enabled = (not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb" and (mode == "always" or (mode != "never" and os.isatty(fd)))) return f"\033[{style}m{text}\033[0m" if enabled and text.strip() else text @@ -49,11 +50,12 @@ def fail(message): raise ValueError(message) -def directory(path): +def directory(path, config): info = path.lstat() if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700): - fail(".secrets must be an owned, real directory with permissions 0700.") + name = ".secrets" if path == config.home / ".secrets" else str(path) + fail(name + " must be an owned, real directory with permissions 0700.") def read_secret(path): @@ -76,10 +78,10 @@ def validate(token): fail("unrecognized/empty OAuth token structure; this is not server authentication.") -def accounts(root): +def accounts(root, config): if not os.path.lexists(root): return [] - directory(root) + directory(root, config) result = [] for path in root.iterdir(): name = path.name.removeprefix("claude-") @@ -155,13 +157,13 @@ def inspect_config(cfg): fail("settings.env conflicts with account authentication (values hidden).") -def preflight(args, home, enrollment): +def preflight(args, kit, enrollment): conflicts = sorted(CONFLICTS.intersection(os.environ)) if conflicts: fail("authentication/provider environment conflict: " + ", ".join(conflicts) + " (values hidden).") # Native paths use env || default and Node path joining, not shell '~' # expansion. Inspect exactly the configuration the real CLI will load. - config = Path(os.environ.get("CLAUDE_CONFIG_DIR") or str(home / ".claude")).absolute() + config = Path(os.environ.get("CLAUDE_CONFIG_DIR") or str(kit.home / ".claude")).absolute() sources = [config / "settings.json", config / "settings.local.json", Path("/etc/claude-code/managed-settings.json"), Path("/Library/Application Support/ClaudeCode/managed-settings.json")] @@ -179,7 +181,7 @@ def preflight(args, home, enrollment): if enrollment and (config / "remote-settings-helper-consent").exists(): fail("managed policy helper present; automatic enrollment cannot preserve its loading chain.") if enrollment: - profiles = Path(os.environ.get("ANTHROPIC_CONFIG_DIR") or str(Path(os.environ.get("XDG_CONFIG_HOME") or str(home / ".config")) / "anthropic")) + profiles = Path(os.environ.get("ANTHROPIC_CONFIG_DIR") or str(Path(os.environ.get("XDG_CONFIG_HOME") or str(kit.home / ".config")) / "anthropic")) if (profiles / "active_config").exists() or (profiles / "configs/default.json").exists(): fail("Anthropic profile selection present; review its authentication before automatic enrollment.") values = {"--model", "--effort", "--output-format", "--input-format", "--system-prompt", @@ -215,7 +217,7 @@ def preflight(args, home, enrollment): if value.lstrip().startswith("{"): inspect_config(json.loads(value)) else: - config_check(Path(value).expanduser()) + config_check(kit.expand(value)) # Native Commander does not activate the variadic collector for # --flag=value syntax. A lone '-' is a value, not an option. if flag in variadic and not sep: @@ -341,39 +343,64 @@ def save(path, token): raise +def kit_config(): + """lib/coding-agent-kit/config.py by path, from the real file, loaded after the version check.""" + lib = Path(os.path.realpath(__file__)).parent.parent + spec = importlib.util.spec_from_file_location("claude_as_config", lib / "coding-agent-kit/config.py") + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except (OSError, ImportError, SyntaxError): + fail(f"incomplete install at {lib.parent}") + config = module.load() + for warning in config.warnings: + print(styled("claude-as: " + warning, "33", 2), file=sys.stderr) + return config + + def main(): - home = Path(os.environ["HOME"]) - secret = home / ".secrets" - if sys.argv[1:] == ["--list-accounts"]: - names = accounts(secret) - if names: - print("\n".join(names)) + config = kit_config() + secret = config.secrets_dir + if sys.argv[1:] in (["--list-accounts"], ["--show-config"]): + # Metadata only: no preflight, no directories and no native CLI. + text = "\n".join(accounts(secret, config)) if sys.argv[1] == "--list-accounts" else config.show("claude-as") + if text: + print(text) return account, *args = sys.argv[1:] - if not NAME.fullmatch(account): - fail("invalid account name (ASCII letters, digits, _ or - only).") - info = home.lstat() - if not home.is_absolute() or not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022: - fail("HOME must be an owned, real user directory without group/other write access.") + # As in codex-as, no name starts with -, so a mistyped option is never enrolled. + if not NAME.fullmatch(account) or account.startswith("-"): + fail("invalid account name (ASCII letters, digits, _ or -; no leading -).") + # The secrets parent (HOME by default) must not let others swap the secrets. Below a regular + # file (ENOTDIR) it does not exist either. + parent = secret.parent + try: + info = parent.lstat() + except (FileNotFoundError, NotADirectoryError): + fail(f"{parent} does not exist; create it, owned by you and not group or other writable.") + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022: + fail("HOME must be an owned, real user directory without group/other write access." if parent == config.home + else f"{parent} must be an owned, real directory without group/other write access.") query = args in (["--help"], ["-h"], ["--version"], ["-v"]) target = secret / ("claude-" + account) missing = not os.path.lexists(target) - preflight(args, home, enrollment=missing and not query) - executable = shutil.which("claude") + preflight(args, config, enrollment=missing and not query) + executable = config.which("claude") if not executable: - fail("real Claude executable not found on PATH.") + fail("real Claude executable not found on PATH." if config.claude == "claude" + else f"the configured claude executable {config.claude} was not found.") if query: os.execv(executable, [executable, *args]) if os.path.lexists(secret): - directory(secret) + directory(secret, config) if missing: tty = Terminal() try: - tty.write("Existing accounts: " + (", ".join(accounts(secret)) or "(none)") + "\n") + tty.write("Existing accounts: " + (", ".join(accounts(secret, config)) or "(none)") + "\n") tty.confirm(f"Account {account} does not exist. Login and save a new account?") token = enroll(executable, tty) secret.mkdir(mode=0o700, exist_ok=True) - directory(secret) + directory(secret, config) save(target, token) tty.write(f"Credentials saved for account {account}.\n", "32") finally: @@ -392,9 +419,10 @@ def main(): os.umask(0o077) try: main() - except (OSError, ValueError, TypeError, KeyError, UnicodeError): + except (OSError, ValueError, TypeError, KeyError, AttributeError, UnicodeError, RecursionError, RuntimeError): error = sys.exc_info()[1] - message = str(error) if type(error) is ValueError else "missing, unreadable or malformed token/settings; no unsafe repair performed." + own = type(error) is ValueError or getattr(error, "own_message", False) + message = str(error) if own else "missing, unreadable or malformed token/settings; no unsafe repair performed." print(styled("claude-as: " + message, "31", 2), file=sys.stderr) sys.exit(1) except KeyboardInterrupt: diff --git a/common/codex/.local/lib/codex-as/check.py b/lib/codex-as/check.py similarity index 93% rename from common/codex/.local/lib/codex-as/check.py rename to lib/codex-as/check.py index 7b84c22..2942847 100644 --- a/common/codex/.local/lib/codex-as/check.py +++ b/lib/codex-as/check.py @@ -3,6 +3,7 @@ import base64 from dataclasses import dataclass from datetime import datetime +import importlib.util import json import os from pathlib import Path @@ -13,8 +14,8 @@ def styled(text, style, fd): - """Use the same auto/always/never policy as lib/terminal.sh per destination.""" - mode = os.environ.get("DOTFILES_COLOR", "auto") + """CODING_AGENT_KIT_COLOR=auto|always|never per destination; NO_COLOR or TERM=dumb turn it off.""" + mode = os.environ.get("CODING_AGENT_KIT_COLOR", "auto") enabled = (not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb" and (mode == "always" or (mode != "never" and os.isatty(fd)))) return f"\033[{style}m{text}\033[0m" if enabled and text.strip() else text @@ -98,7 +99,7 @@ class Plan: no_daemon: bool # root --no-daemon given; native refuses a second one rootless: bool # root help or version, update and completion need no account unknown: tuple # options this scan does not know (typos fail in clap after any takeover) - profile: str | None # -p/--profile: layers ~/.codex/NAME.config.toml + profile: str | None # -p/--profile: layers codex_home/NAME.config.toml def session_id(text): @@ -157,7 +158,7 @@ def override(text): settings(cfg) -def arguments(args): +def arguments(args, config): # A bounded scan, not a replacement for clap: arguments are forwarded # unchanged. Values and prompts are never read as subcommands once a # subcommand is known. A root PROMPT does not end clap's subcommand @@ -239,7 +240,7 @@ def arguments(args): if flag in {"-c", "--config"}: override(value) elif flag in {"-C", "--cd"}: - cwd = Path(value).expanduser().absolute() + cwd = config.expand(value).absolute() elif flag in {"-p", "--profile"}: profile = value elif flag == "--enable" and value.strip() in FEATURES: @@ -345,9 +346,8 @@ def credential(path): return raw -def accounts(): +def accounts(root): """Metadata only: completion must never open credentials or start a CLI.""" - root = Path(os.environ["HOME"]) / ".secrets" if not os.path.lexists(root): return [] directory(root, private=True) @@ -411,12 +411,11 @@ def exclusive(path, raw): raise -def config_sources(cwd, profile=None): - home = Path(os.environ["HOME"]) - user = {home / ".codex/config.toml", Path("/etc/codex/config.toml"), Path("/etc/codex/managed_config.toml"), +def config_sources(root, cwd, profile=None): + user = {root / "config.toml", Path("/etc/codex/config.toml"), Path("/etc/codex/managed_config.toml"), Path("/etc/codex/requirements.toml")} if profile is not None: - user.add(home / ".codex" / (profile + ".config.toml")) + user.add(root / (profile + ".config.toml")) project = set() # Lexical and resolved parents: a symlinked -C has both. for place in (cwd, cwd.resolve()): @@ -435,8 +434,8 @@ def config_sources(cwd, profile=None): # Codex still loads/enforces its own system, workspace and cloud policies. -def preflight(account, plan): - expected = Path(os.environ["HOME"]) / ".codex" +def preflight(account, plan, config): + expected = config.codex_home allowed = (expected, expected / "accounts" / account) if "CODEX_HOME" in os.environ: inherited = Path(os.path.abspath(os.environ["CODEX_HOME"])) @@ -460,7 +459,7 @@ def preflight(account, plan): conflicts = [name for name in names if name in os.environ] if conflicts: fail("authentication/endpoint environment conflict: " + ", ".join(conflicts) + " (values hidden).") - config_sources(plan.cwd, plan.profile) + config_sources(expected, plan.cwd, plan.profile) def private_metadata(path): @@ -485,14 +484,26 @@ def report(error): if __name__ == "__main__": + # The completions of coding-agent-kit 0.1, still defined in shells opened + # before an update, run "check.py accounts"; codex-as --list-accounts is + # the same listing. try: action, = sys.argv[1:] - if action == "accounts": - names = accounts() - if names: - print("\n".join(names)) - else: + if action != "accounts": fail("unsupported internal action.") - except (OSError, ValueError, TypeError, KeyError, AttributeError, UnicodeError, RecursionError) as error: + lib = Path(os.path.realpath(__file__)).parent.parent + spec = importlib.util.spec_from_file_location("codex_as_config", lib / "coding-agent-kit/config.py") + settings = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(settings) + except (OSError, ImportError, SyntaxError): + fail(f"incomplete install at {lib.parent}") + config = settings.load() + for warning in config.warnings: + warn(warning) + names = accounts(config.secrets_dir) + if names: + print("\n".join(names)) + except (OSError, ValueError, TypeError, KeyError, AttributeError, UnicodeError, RecursionError, RuntimeError) as error: report(error) sys.exit(1) diff --git a/common/codex/.local/lib/codex-as/handoff.py b/lib/codex-as/handoff.py similarity index 100% rename from common/codex/.local/lib/codex-as/handoff.py rename to lib/codex-as/handoff.py diff --git a/common/codex/.local/lib/codex-as/launch.py b/lib/codex-as/launch.py similarity index 85% rename from common/codex/.local/lib/codex-as/launch.py rename to lib/codex-as/launch.py index 3f9b660..b4021fb 100644 --- a/common/codex/.local/lib/codex-as/launch.py +++ b/lib/codex-as/launch.py @@ -6,23 +6,33 @@ import os from pathlib import Path import re -import shutil import signal import subprocess import sys import tempfile -def load_module(name): - spec = importlib.util.spec_from_file_location("codex_as_" + name, Path(__file__).with_name(name + ".py")) +# python3 -I puts no script directory on sys.path, so the modules load by path, +# from the real file: a linked launcher uses the kit it belongs to. +LIB = Path(os.path.realpath(__file__)).parent.parent + + +def load_module(name, path): + spec = importlib.util.spec_from_file_location("codex_as_" + name, LIB / path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -guard = load_module("check") -storage = load_module("layout") -handoff = load_module("handoff") +try: + guard = load_module("check", "codex-as/check.py") # ends the run first on Python before 3.11 + storage = load_module("layout", "codex-as/layout.py") + handoff = load_module("handoff", "codex-as/handoff.py") + settings = load_module("config", "coding-agent-kit/config.py") +except (OSError, ImportError, SyntaxError): + message = f"codex-as: incomplete install at {LIB.parent}" + print(guard.styled(message, "31", 2) if "guard" in globals() else message, file=sys.stderr) + sys.exit(1) def enroll(layout, codex): @@ -31,7 +41,7 @@ def enroll(layout, codex): return tty = guard.terminal() try: - tty.write("Existing accounts: " + (", ".join(guard.accounts()) or "(none)") + "\n") + tty.write("Existing accounts: " + (", ".join(guard.accounts(layout.secrets)) or "(none)") + "\n") guard.confirm(tty, f"Account {layout.name} does not exist. Login and save a new account?") with tempfile.TemporaryDirectory(prefix="codex-as-login-") as temp: env = os.environ.copy() @@ -144,23 +154,33 @@ def post_run(layout, baseline): def main(args): + config = settings.load() + for warning in config.warnings: + guard.warn(warning) + if args in (["--list-accounts"], ["--show-config"]): + # Metadata only: no preflight, no directories and no native CLI. + text = "\n".join(guard.accounts(config.secrets_dir)) if args[0] == "--list-accounts" else config.show("codex-as") + if text: + print(text) + return 0 if not args or not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_-]*", args[0]): guard.fail("invalid account name (ASCII letters, digits, _ or -; no leading -).") account, *native_args = args - plan = guard.arguments(native_args) - codex = shutil.which("codex") + plan = guard.arguments(native_args, config) + codex = config.which("codex") if codex is None: - guard.fail("Codex is not installed on PATH.") + guard.fail("Codex is not installed on PATH." if config.codex == "codex" + else f"the configured codex executable {config.codex} was not found.") if plan.rootless: # Help, version, update and completion are the same for every login; an update # installs under CODEX_HOME, so it goes to the root home like plain codex update. env = os.environ.copy() - env["CODEX_HOME"] = str(Path(os.environ["HOME"]) / ".codex") + env["CODEX_HOME"] = str(config.codex_home) env.pop("CODEX_SQLITE_HOME", None) with Supervision(): return run_native([codex, *native_args], env) - guard.preflight(account, plan) - layout = storage.Layout(guard, account) + guard.preflight(account, plan, config) + layout = storage.Layout(guard, account, config) sqlite = storage.shared_sqlite(guard, layout.root, plan.cwd, plan.profile) layout.root_check() version = subprocess.run([codex, "--version"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, diff --git a/common/codex/.local/lib/codex-as/layout.py b/lib/codex-as/layout.py similarity index 94% rename from common/codex/.local/lib/codex-as/layout.py rename to lib/codex-as/layout.py index b251ad6..61a745a 100644 --- a/common/codex/.local/lib/codex-as/layout.py +++ b/lib/codex-as/layout.py @@ -1,6 +1,6 @@ """Fixed account views over shared Codex state; private credentials stay in secrets. -The root ~/.codex/auth.json belongs to native clients and is never read or changed. +The root codex_home/auth.json belongs to native clients and is never read or changed. """ import base64 @@ -101,11 +101,11 @@ def same_identity(left, right): class Layout: - def __init__(self, guard, account): + def __init__(self, guard, account, config): self.g = guard self.name = account - self.root = Path(os.environ["HOME"]) / ".codex" - self.secrets = self.root.parent / ".secrets" + self.root = config.codex_home + self.secrets = config.secrets_dir self.home = self.root / "accounts" / account self.auth = self.home / "auth.json" self.target = self.secrets / f"codex-{account}.json" @@ -128,7 +128,13 @@ def finish_setup(self): (self.root / "accounts/.locks" / f".setup-{self.name}.json").unlink() def root_check(self): - self.g.directory(self.root.parent) + # Their parents (HOME by default) must not let others swap them. Below a regular file + # (ENOTDIR) a parent does not exist either. + for parent in dict.fromkeys((self.root.parent, self.secrets.parent)): + try: + self.g.directory(parent) + except (FileNotFoundError, NotADirectoryError): + self.g.fail(f"{parent} does not exist; create it, owned by you and not group or other writable.") if os.path.lexists(self.root): self.g.directory(self.root) if os.path.lexists(self.secrets): @@ -244,7 +250,7 @@ def init_home(self): self.replace_bytes(marker, b"codex-as v2\n") def link_profile(self, profile): - """Show ~/.codex/PROFILE.config.toml in the account home, where native -p reads it.""" + """Show codex_home/PROFILE.config.toml in the account home, where native -p reads it.""" target = self.root / (profile + ".config.toml") if os.path.exists(target): try: diff --git a/lib/coding-agent-kit/config.py b/lib/coding-agent-kit/config.py new file mode 100644 index 0000000..97260df --- /dev/null +++ b/lib/coding-agent-kit/config.py @@ -0,0 +1,281 @@ +"""coding-agent-kit configuration: one optional TOML file, read once per run. + +Public API (load this file by path; it is never on sys.path): + + load() -> Config + Find, read and validate the configuration file. The launchers call it + once per run, after their Python version check. Raises ConfigError. + Config, a frozen dataclass: + file Path of the configuration file that was looked for + source "found", "not found; defaults" or "from CODING_AGENT_KIT_CONFIG" + home Path of $HOME, absolute; no other kit code reads HOME + codex str: the codex executable, a bare name looked up on PATH or + an absolute path (~/ already expanded) + claude str: the claude executable, the same rule + codex_home Path, absolute; the account root is codex_home / "accounts" + secrets_dir Path, absolute; neither equal to codex_home nor nested with it + warnings tuple of ": unknown key ignored", one per unknown key + expand(path) -> Path: Path(path).expanduser() with this run's HOME + which(key) -> str | None: shutil.which() of the "codex" or "claude" value + show(tool) -> str: the --show-config lines for "codex-as" or "claude-as" + ConfigError(ValueError): one line, ": : " or + ": ", without the ": " prefix and never quoting file + contents; own_message is True, so the launchers print it as written. + DEFAULTS: the four keys and their default values, as written in a file. + +Location: CODING_AGENT_KIT_CONFIG when set and non-empty (absolute, and the +file must exist); else $XDG_CONFIG_HOME/coding-agent-kit/config.toml when +XDG_CONFIG_HOME is absolute; else $HOME/.config/coding-agent-kit/config.toml. +A missing file, or a path component that is a file, means the defaults; a +dangling symlink on the way is an error. Other users must not be able to change +the file, nor the directories and symlinks on its way (unsafe, exposed). +Values are normalised by pathlib only (plus a leading run of slashes collapsed +to one), never resolved, so they stay byte-stable in the absolute links the +launchers create. +""" + +from dataclasses import dataclass +import errno +import functools +import grp +import json +import os +from pathlib import Path, PurePosixPath +import pwd +import re +import shutil +import stat +import tomllib + + +DEFAULTS = {"codex": "codex", "claude": "claude", "codex_home": "~/.codex", "secrets_dir": "~/.secrets"} +VARIABLE = "CODING_AGENT_KIT_CONFIG" +LIMIT = 64 * 1024 +COMMAND = re.compile(r"[A-Za-z0-9._+-]+") +CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]") # C0, DEL and C1: values are printed and passed on as is + + +class ConfigError(ValueError): + own_message = True + + +@dataclass(frozen=True) +class Config: + file: Path + source: str + home: Path + codex: str + claude: str + codex_home: Path + secrets_dir: Path + warnings: tuple = () + + def expand(self, path): + """Path(path).expanduser(), with this run's HOME for ~ and ~/.""" + path = Path(path) + if path.parts[:1] == ("~",): + return self.home.joinpath(*path.parts[1:]) + return path.expanduser() # ~user: the password database, never HOME + + def which(self, key): + return shutil.which(getattr(self, key)) + + def show(self, tool): + key = "codex" if tool == "codex-as" else "claude" + value = getattr(self, key) + found = self.which(key) or ("not found on PATH" if COMMAND.fullmatch(value) else "not an executable file") + lines = [f"config = {self.file} ({self.source})", f"{key} = {value} ({found})"] + if key == "codex": + lines.append(f"codex_home = {self.codex_home}") + return "\n".join([*lines, f"secrets_dir = {self.secrets_dir}"]) + + +@functools.lru_cache(maxsize=None) +def private_group(gid): + """Debian's user private group: the user's login group in the password database (newgrp + and sg change the real gid, not this), named after the user, with no other member, neither + listed nor by primary group (as OpenSSH's secure_permissions checks). Cached per gid for + one load(), which clears it: listing every account can be slow on a directory service.""" + try: + me = pwd.getpwuid(os.getuid()) + group = grp.getgrgid(gid) + except KeyError: + return False + if gid != me.pw_gid or group.gr_name != me.pw_name or not set(group.gr_mem) <= {me.pw_name}: + return False + return not any(entry.pw_gid == gid and entry.pw_uid != os.getuid() for entry in pwd.getpwall()) + + +def access_acl(target): + """Whether the fd or directory TARGET has a POSIX or NFSv4 access ACL; its group bits + are then the ACL mask. No os.listxattr (macOS) or no xattr support means none.""" + try: + names = os.listxattr(target) + except AttributeError: + return False + except OSError as error: + if error.errno in (errno.ENOTSUP, errno.EOPNOTSUPP): + return False + raise + return "system.posix_acl_access" in names or "system.nfs4_acl" in names + + +def unsafe(info, target): + """Why other users could change this file or directory (TARGET, its fd or path), or None.""" + if info.st_uid not in (os.getuid(), 0): + return "owned by another user." + if stat.S_ISDIR(info.st_mode) and info.st_mode & stat.S_ISVTX: + return None # sticky, like /tmp: others cannot rename or remove what they do not own + if info.st_mode & stat.S_IWOTH: + return "writable by other users." + if info.st_mode & stat.S_IWGRP: + if not private_group(info.st_gid): + return "group-writable, and the group is not your private group." + if access_acl(target): + return "group-writable with an access ACL; remove the ACL (setfacl -b) or the group write (chmod g-w)." + return None + + +def exposed(path, home): + """Why other users could replace the file through a directory or symlink on its way, or None. + + Checked: PATH itself when it is a symlink, and every directory from its parent up to + HOME when it lies below HOME, else up to /, both as written and after realpath.""" + homes = {home, Path(os.path.realpath(home))} + places = [path] + for start in (path, Path(os.path.realpath(path))): + for parent in start.parents: + places.append(parent) + if parent in homes: + break + for place in dict.fromkeys(places): + info = os.lstat(place) + if stat.S_ISLNK(info.st_mode): + if info.st_uid not in (os.getuid(), 0): + return f"symlink {place}: owned by another user." + elif place != path: + problem = unsafe(info, place) + if problem: + return f"directory {place}: {problem}" + return None + + +def read(path, explicit, home): + """The file's bytes, checked on the opened inode, or None when it is absent.""" + try: + fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_CLOEXEC) + except (FileNotFoundError, NotADirectoryError): + # A component that is a file means, like a missing one, that no file can be there. + for place in (path, *path.parents): + if os.path.islink(place) and not os.path.exists(place): + raise ConfigError(f"{path}: dangling symlink" + ("." if place == path else f" {place}.")) from None + if explicit: + raise ConfigError(f"{path}: not found ({VARIABLE}).") from None + return None + except OSError as error: + raise ConfigError(f"{path}: cannot be opened ({error.strerror}).") from None + try: + info = os.fstat(fd) + try: + if not stat.S_ISREG(info.st_mode): + problem = "not a regular file." + else: + problem = unsafe(info, fd) or exposed(path, home) + except OSError as error: + problem = f"cannot be checked ({error.strerror})." + if problem: + raise ConfigError(f"{path}: {problem}") + chunks, size = [], 0 + while size <= LIMIT: + chunk = os.read(fd, LIMIT + 1 - size) + if not chunk: + break + chunks.append(chunk) + size += len(chunk) + finally: + os.close(fd) + if size > LIMIT: + raise ConfigError(f"{path}: larger than 64 KiB.") + return b"".join(chunks) + + +def parse(path, raw): + try: + return tomllib.loads(raw.decode("utf-8")) + except UnicodeDecodeError: + raise ConfigError(f"{path}: not UTF-8 text.") from None + except tomllib.TOMLDecodeError as error: + # The parser's message can quote the file; keep only the position. + where = re.search(r"\(at (line \d+, column \d+|end of document)\)$", str(error)) + raise ConfigError(f"{path}: invalid TOML" + (f" at {where[1]}." if where else ".")) from None + except (ValueError, RecursionError): + # Nesting deeper than the parser's recursion, or an integer past Python's digit limit. + raise ConfigError(f"{path}: invalid TOML.") from None + + +def place(home, value): + """An absolute or ~/ value as a Path, normalised by pathlib only; None otherwise. + + pathlib keeps exactly two leading slashes as a root of their own, which Linux and + macOS read as /, so a leading run of slashes becomes one.""" + if value.startswith("~/"): + return home.joinpath(value[2:].lstrip("/")) + return Path("/" + value.lstrip("/")) if value.startswith("/") else None + + +def single(path): + """PATH with a leading run of slashes as one, for comparisons; HOME may start with //.""" + return Path("/" + str(path).lstrip("/")) + + +def load(): + private_group.cache_clear() # one listing of the accounts per run, never one from an earlier load + value = os.environ.get("HOME", "") + if not Path(value).is_absolute(): + raise ConfigError("HOME must be set to an absolute path.") + home = Path(value) + explicit = os.environ.get(VARIABLE, "") + if explicit: + path = Path(explicit) + if not path.is_absolute(): + raise ConfigError(f"{explicit}: {VARIABLE} must be an absolute path.") + else: + xdg = Path(os.environ.get("XDG_CONFIG_HOME", "")) + path = (xdg if xdg.is_absolute() else home / ".config") / "coding-agent-kit/config.toml" + raw = read(path, bool(explicit), home) + data = {} if raw is None else parse(path, raw) + values, warnings = dict(DEFAULTS), [] + for key, item in data.items(): + if key not in DEFAULTS: + shown = key if re.fullmatch(r"[A-Za-z0-9_-]+", key) else json.dumps(key) + warnings.append(f"{path}: unknown key {shown} ignored") + elif not isinstance(item, str): + raise ConfigError(f"{path}: {key}: must be a string.") + elif not item: + raise ConfigError(f"{path}: {key}: must not be empty.") + elif CONTROL.search(item): + raise ConfigError(f"{path}: {key}: must not contain control characters.") + else: + values[key] = item + for key in ("codex", "claude"): + if not COMMAND.fullmatch(values[key]): + where = place(home, values[key]) + if where is None: + raise ConfigError(f"{path}: {key}: must be a command name ({COMMAND.pattern}) or an absolute or ~/ path.") + values[key] = str(where) + for key in ("codex_home", "secrets_dir"): + where = place(home, values[key]) + if where is None: + raise ConfigError(f"{path}: {key}: must be an absolute or ~/ path.") + if ".." in PurePosixPath(values[key]).parts: + raise ConfigError(f"{path}: {key}: must not contain a .. component.") + values[key] = where + root, secrets = single(values["codex_home"]), single(values["secrets_dir"]) + if root == secrets: + raise ConfigError(f"{path}: secrets_dir: must differ from codex_home.") + if root in secrets.parents: + raise ConfigError(f"{path}: secrets_dir: must not be inside codex_home.") + if secrets in root.parents: + raise ConfigError(f"{path}: codex_home: must not be inside secrets_dir.") + source = f"from {VARIABLE}" if explicit else "found" if raw is not None else "not found; defaults" + return Config(path, source, home, warnings=tuple(warnings), **values) diff --git a/lib/config_sync.py b/lib/config_sync.py deleted file mode 100644 index ec453f9..0000000 --- a/lib/config_sync.py +++ /dev/null @@ -1,531 +0,0 @@ -"""Small, offline configuration transforms behind the shell sync entrypoints.""" - -from __future__ import annotations - -import argparse -import copy -import importlib.metadata -import json -import os -from pathlib import Path -import stat -import sys -import tempfile -from collections.abc import MutableMapping - -TOMLKIT_VERSION = "0.15.1" - -# Every owned field is required. Keep ownership, validation and migration in -# one policy, expressed as parsed TOML paths (not source-code regexes). -CODEX_POLICY = ( - (("model",), str), - (("model_reasoning_effort",), str), - (("plan_mode_reasoning_effort",), str), - (("personality",), str), - (("default_permissions",), str), - (("approval_policy",), str), - (("approvals_reviewer",), str), - (("project_doc_fallback_filenames",), list), - (("features", "network_proxy"), bool), - (("features", "multi_agent"), bool), - (("features", "memories"), bool), - (("permissions", "workspace-net", "description"), str), - (("permissions", "workspace-net", "extends"), str), - (("permissions", "workspace-net", "network", "enabled"), bool), - (("permissions", "workspace-net", "network", "allow_local_binding"), bool), - (("permissions", "workspace-net", "network", "domains", "*"), str), - (("memories", "generate_memories"), bool), - (("memories", "use_memories"), bool), -) -CODEX_RETIRED = ( - ("sandbox_mode",), - ("sandbox_workspace_write",), - ("features", "js_repl"), -) -# Optional hook ownership: absent from the baseline means host runtime state stays -# untouched. Inline hooks use the documented on-disk `timeout`, not timeoutSec. -CODEX_OPTIONAL_POLICY = {"hooks": dict} -HOOK_EVENTS = {"PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", - "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart", "SubagentStop", - "Stop", "Interrupt"} -# First line of the one generated Codex instructions file. Only a regular file -# that starts with exactly this line is ours to rewrite or remove. -INSTRUCTIONS_MARKER = (b"") -SUBMODULE_HINT = "git submodule update --init --recursive" - - -class SyncError(ValueError): - """An unsafe or invalid synchronization request.""" - - -def require_runtime(*, toml=True): - if sys.version_info < (3, 11): - raise SyncError("Python 3.11 or newer is required; run setup-sync explicitly") - if not toml: - return - try: - installed = importlib.metadata.version("tomlkit") - except importlib.metadata.PackageNotFoundError as exc: - raise SyncError("missing tomlkit; run setup-sync.sh or setup-sync.ps1 explicitly") from exc - if installed != TOMLKIT_VERSION: - raise SyncError(f"tomlkit {TOMLKIT_VERSION} required, found {installed}; rerun setup-sync explicitly") - - -def parse_toml(data: bytes): - import tomlkit - - return tomlkit.parse(data.decode("utf-8")) - - -def plain(value): - return value.unwrap() if hasattr(value, "unwrap") else value - - -def codex_values(portable): - values = [] - for path, expected in CODEX_POLICY: - value = portable - for key in path: - if not isinstance(value, MutableMapping) or key not in value: - raise SyncError(f"missing portable key: {'.'.join(path)}") - value = value[key] - unwrapped = plain(value) - if type(unwrapped) is not expected or ( - expected is list and not all(isinstance(item, str) for item in unwrapped) - ): - raise SyncError(f"invalid portable value type: {'.'.join(path)}") - values.append((path, value)) - return values - - -def set_toml_path(document, path, value): - import tomlkit - - table = document - for key in path[:-1]: - if key not in table: - table[key] = tomlkit.table() - table = table[key] - if not isinstance(table, MutableMapping): - raise SyncError(f"live table conflicts with portable key: {'.'.join(path)}") - key = path[-1] - if key not in table or type(plain(table[key])) is not type(plain(value)) or plain(table[key]) != plain(value): - table[key] = copy.deepcopy(value) - - -def retain_missing(source, target): - """Explicit migration keeps old source-only state; current live wins conflicts.""" - for key, value in source.items(): - if key not in target: - target[key] = copy.deepcopy(value) - elif isinstance(value, MutableMapping) and isinstance(target[key], MutableMapping): - retain_missing(value, target[key]) - - -def merge_codex(portable_bytes: bytes, live_bytes: bytes, *, migrate=False, scoped_hooks=None) -> tuple[bytes, bytes]: - import tomlkit - - portable = parse_toml(portable_bytes) - values = codex_values(portable) - live = parse_toml(live_bytes) - if migrate: - retain_missing(portable, live) - for path in CODEX_RETIRED: - table = live - for key in path[:-1]: - table = table.get(key) if isinstance(table, MutableMapping) else None - if isinstance(table, MutableMapping): - table.pop(path[-1], None) - clean = tomlkit.document() - for path, value in values: - set_toml_path(live, path, value) - set_toml_path(clean, path, value) - portable_hooks = plain(portable.get("hooks", {})) - if "hooks" in portable or scoped_hooks is not None: - if not isinstance(portable_hooks, CODEX_OPTIONAL_POLICY["hooks"]): - raise SyncError("optional portable Codex hooks must be an event object") - if portable_hooks: - validate_hook_events(portable_hooks, client="codex") - live_hooks = plain(live.get("hooks", {})) - if not isinstance(live_hooks, dict): - raise SyncError("live Codex hooks must be an event object") - combined = merge_hook_arrays(live_hooks, {}, portable_hooks, client="codex") if portable_hooks else copy.deepcopy(live_hooks) - if scoped_hooks is not None: - combined = merge_hook_arrays(combined, {}, scoped_hooks, client="codex") - set_toml_path(live, ("hooks",), tomlkit.item(combined)) - if "hooks" in portable: - set_toml_path(clean, ("hooks",), tomlkit.item(portable_hooks)) - merged = tomlkit.dumps(live).encode("utf-8") - canonical = tomlkit.dumps(clean).encode("utf-8") - # A parser successfully reading input is not enough: validate serialized - # candidates before any filesystem mutation (including --check). - parse_toml(merged) - parse_toml(canonical) - return merged, canonical - - -def unique_object(pairs): - result = {} - for key, value in pairs: - if key in result: - raise SyncError(f"duplicate JSON key: {key}") - result[key] = value - return result - - -def invalid_constant(value): - raise SyncError(f"invalid JSON constant: {value}") - - -def parse_json(data: bytes): - value = json.loads(data.decode("utf-8"), object_pairs_hook=unique_object, parse_constant=invalid_constant) - if not isinstance(value, dict): - raise SyncError("both settings documents must be JSON objects") - return value - - -def deep_merge(base, override): - if isinstance(base, dict) and isinstance(override, dict): - result = dict(base) - for key, value in override.items(): - result[key] = deep_merge(base[key], value) if key in base else copy.deepcopy(value) - return result - return copy.deepcopy(override) - - -def same_json(left, right): - # bool is a subclass of int in Python: plain == mistakes false for 0, - # including values nested inside arrays and objects. - if type(left) is not type(right): - return False - if isinstance(left, dict): - return left.keys() == right.keys() and all(same_json(value, right[key]) for key, value in left.items()) - if isinstance(left, list): - return len(left) == len(right) and all(same_json(a, b) for a, b in zip(left, right)) - return left == right - - -def validate_hook_events(scoped, *, client): - """Validate supported command registrations for the client's on-disk schema.""" - if not isinstance(scoped, dict) or not scoped: - raise SyncError(f"scoped {client} hooks must be a nonempty event object") - for event, registrations in scoped.items(): - if client == "codex" and event not in HOOK_EVENTS: - raise SyncError(f"unsupported Codex hook event: {event}") - if not isinstance(event, str) or not event or not isinstance(registrations, list) or not registrations: - raise SyncError(f"scoped {client} hook events require nonempty registration arrays") - for registration in registrations: - if not isinstance(registration, dict) or set(registration) - {"matcher", "hooks"}: - raise SyncError(f"invalid scoped {client} hook registration") - if "matcher" in registration and not isinstance(registration["matcher"], str): - raise SyncError(f"scoped {client} hook matcher must be a string") - hooks = registration.get("hooks") - if not isinstance(hooks, list) or not hooks: - raise SyncError(f"scoped {client} registration requires hooks") - for hook in hooks: - if not isinstance(hook, dict) or hook.get("type") != "command" or not isinstance(hook.get("command"), str) or not hook["command"]: - raise SyncError(f"scoped {client} hooks must be command objects") - allowed = {"type", "command", "timeout", "statusMessage", "async"} - if client == "codex": - allowed |= {"commandWindows", "command_windows", "additionalContextLimit"} - if set(hook) - allowed: - raise SyncError(f"unsupported scoped {client} command hook key") - if "commandWindows" in hook and "command_windows" in hook: - raise SyncError("use only one Codex Windows command spelling") - if "timeout" in hook and (type(hook["timeout"]) not in ((int,) if client == "codex" else (int, float)) or hook["timeout"] <= 0): - raise SyncError(f"invalid scoped {client} hook timeout") - if client == "codex" and event in {"SessionEnd", "Interrupt"} and hook.get("timeout", 1) > 3: - raise SyncError("Codex SessionEnd/Interrupt timeout may not exceed 3 seconds") - for key in ("commandWindows", "command_windows"): - if key in hook and (not isinstance(hook[key], str) or not hook[key]): - raise SyncError(f"invalid scoped Codex hook {key}") - if "additionalContextLimit" in hook and (type(hook["additionalContextLimit"]) is not int or hook["additionalContextLimit"] < 0): - raise SyncError("invalid scoped Codex additionalContextLimit") - if "statusMessage" in hook and not isinstance(hook["statusMessage"], str): - raise SyncError(f"invalid scoped {client} hook statusMessage") - if "async" in hook and type(hook["async"]) is not bool: - raise SyncError(f"invalid scoped {client} hook async") - - -def merge_hook_arrays(live, portable, scoped, *, client): - """Opt-in events retain local registrations; other baseline arrays stay authoritative.""" - validate_hook_events(scoped, client=client) - result = deep_merge(live, portable) - for event in scoped: - combined = [] - for document in (live, portable, scoped): - existing = document.get(event, []) - if not isinstance(existing, list) or not all(isinstance(item, dict) for item in existing): - raise SyncError(f"invalid existing {client} hook registrations: {event}") - for registration in existing: - if not any(same_json(registration, previous) for previous in combined): - combined.append(copy.deepcopy(registration)) - result[event] = combined - return result - - -def merge_claude(portable_bytes: bytes, live_bytes: bytes, *, scoped_hooks=None) -> bytes: - portable = parse_json(portable_bytes) - live = parse_json(live_bytes or b"{}") - permissions = portable.get("permissions") - if not isinstance(permissions, dict) or any( - not isinstance(permissions.get(key), list) - or not all(isinstance(item, str) for item in permissions[key]) - for key in ("allow", "ask") - ): - raise SyncError("portable permissions.allow and permissions.ask must be string arrays") - if "model" in portable: - raise SyncError("portable Claude model is not allowed; keep model selection machine-local") - merged = deep_merge(live, portable) - if scoped_hooks is not None: - live_hooks, portable_hooks = live.get("hooks", {}), portable.get("hooks", {}) - if not isinstance(live_hooks, dict) or not isinstance(portable_hooks, dict): - raise SyncError("opt-in hook sync requires existing hooks objects") - merged["hooks"] = merge_hook_arrays(live_hooks, portable_hooks, scoped_hooks, client="claude") - # Preserve the original bytes on a semantic no-op, including whitespace. - if same_json(merged, live) and live_bytes: - return live_bytes - result = (json.dumps(merged, ensure_ascii=False, indent=2, allow_nan=False) + "\n").encode("utf-8") - parse_json(result) - return result - - -def read_regular(path: Path, *, missing_ok: bool = False) -> bytes | None: - try: - path.lstat() - except FileNotFoundError: - if missing_ok: - return None - raise SyncError(f"missing portable file: {path}") from None - try: - info = path.stat() - except FileNotFoundError: - raise SyncError(f"dangling symlink is not a missing live file: {path}") from None - if not stat.S_ISREG(info.st_mode): - raise SyncError(f"not a regular file: {path}") - if os.name != "nt" and not info.st_mode & 0o444: - raise SyncError(f"unreadable file: {path}") - return path.read_bytes() - - -def atomic_write(path: Path, data: bytes, mode: int, previous: bytes | None): - """Replace the directory entry, never unlink or write through a live link.""" - # Defaults are an upper bound, not permission grants. Preserve a regular - # file's tighter mode on both no-ops and replacements; symlinks use the - # defaults without inheriting or changing their portable target's mode. - if os.name != "nt" and previous is not None: - existing = path.lstat() - if stat.S_ISREG(existing.st_mode): - mode &= stat.S_IMODE(existing.st_mode) - if not path.is_symlink() and previous == data: - if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) != mode: - path.chmod(mode) - return - path.parent.mkdir(parents=True, exist_ok=True) - fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - temporary = Path(name) - try: - with os.fdopen(fd, "wb") as handle: - handle.write(data) - handle.flush() - os.fsync(handle.fileno()) - if os.name != "nt": - temporary.chmod(mode) - if read_regular(path, missing_ok=True) != previous: - raise SyncError(f"live file changed during synchronization; retry: {path}") - os.replace(temporary, path) - finally: - temporary.unlink(missing_ok=True) - - -def ensure_distinct(source: Path, live: Path, label="portable"): - if source.absolute() == live.absolute() or ( - live.is_file() and not live.is_symlink() and os.path.samefile(source, live) - ): - raise SyncError(f"{label} and live must not be the same regular file") - - -def synchronize(kind: str, portable: Path, live: Path, *, check=False, migrate=False, hooks=None): - portable_bytes = read_regular(portable) - live_bytes = read_regular(live, missing_ok=True) - ensure_distinct(portable, live) - if migrate and portable.is_symlink(): - raise SyncError("explicit migration requires a regular portable source, not a symlink") - cleaned = None - if kind == "codex-config-sync": - scoped = parse_json(read_regular(hooks)) if hooks is not None else None - merged, cleaned = merge_codex(portable_bytes, live_bytes or b"", migrate=migrate, scoped_hooks=scoped) - mode = 0o600 - elif kind == "claude-settings-sync": - scoped = parse_json(read_regular(hooks)) if hooks is not None else None - merged = merge_claude(portable_bytes, live_bytes or b"", scoped_hooks=scoped) - mode = 0o644 - else: - if not portable_bytes: - raise SyncError("missing or empty portable rules") - merged = portable_bytes - mode = 0o644 - if not check: - atomic_write(live, merged, mode, live_bytes) - # The live snapshot is materialized first so a polluted legacy source - # can be cleaned without losing the runtime-only state it contained. - if migrate: - atomic_write(portable, cleaned, 0o644, portable_bytes) - - -class ForeignInstructions(SyncError): - """A live instructions entry this helper did not generate; never replaced.""" - - -def read_instructions_source(path: Path, label: str, *, hint: str = "") -> bytes: - """Follow the whole link chain to a nonempty UTF-8 regular file.""" - if not os.path.lexists(path): - raise SyncError(f"missing {label}: {path}") - if path.is_symlink() and not path.exists(): - raise SyncError(f"{label} dangles: {path}{hint}") - data = read_regular(path) - try: - data.decode("utf-8") - except UnicodeDecodeError as exc: - raise SyncError(f"{label} is not UTF-8: {path} ({exc.reason})") from None - if not data.strip(): - raise SyncError(f"empty {label}: {path}") - return data - - -def generated_live(live: Path) -> bytes | None: - """Return the bytes of a generated LIVE, None when absent; anything else is foreign.""" - try: - info = live.lstat() - except FileNotFoundError: - return None - if stat.S_ISLNK(info.st_mode): - raise ForeignInstructions(f"refusing to replace a symlink: {live}") - if not stat.S_ISREG(info.st_mode): - raise ForeignInstructions(f"not a regular file: {live}") - try: - data = read_regular(live) - except (OSError, SyncError) as exc: - raise ForeignInstructions(f"unreadable, so not provably generated: {live} ({exc})") from exc - if data.split(b"\n", 1)[0] != INSTRUCTIONS_MARKER: - raise ForeignInstructions(f"refusing to overwrite {live}: not generated by codex-instructions-sync; " - "move it aside to let stow-all.sh generate it") - return data - - -def retire_instructions(live: Path, *, check: bool) -> str: - """Without host rules, remove only a generated LIVE; keep every foreign entry.""" - try: - previous = generated_live(live) - except ForeignInstructions: - return "foreign" - if previous is None: - return "none" - if not check: - if generated_live(live) != previous: - raise SyncError(f"live file changed during synchronization; retry: {live}") - live.unlink() - return "remove" - - -def synchronize_instructions(portable: Path, live: Path, *, rules: Path | None, check=False) -> str: - """Generate LIVE = marker + portable + host rules, or retire it; return the action taken or planned.""" - portable_bytes = read_instructions_source(portable, "portable Codex instructions") - rules_bytes = None - if rules is not None: - rules_bytes = read_instructions_source(rules, "host rules link", hint=f"; run {SUBMODULE_HINT}") - ensure_distinct(rules, live, "host rules") - ensure_distinct(portable, live) - if rules_bytes is None: - return retire_instructions(live, check=check) - desired = (INSTRUCTIONS_MARKER + b"\n" + portable_bytes.rstrip(b"\n") + b"\n\n" - + rules_bytes.rstrip(b"\n") + b"\n") - previous = generated_live(live) - if not check: - atomic_write(live, desired, 0o644, previous) - return "current" if previous == desired else "write" - - -def report_instructions(state: str, live: Path, *, check: bool, quiet: bool): - if state == "foreign" and not check: - print(f"config-sync: warning: left foreign {live} unchanged; Codex reads it instead of the " - "portable AGENTS.md", file=sys.stderr) - return - if quiet: - return - if check: - detail = {"current": "current", "none": "current", "write": "stale", "remove": "stale", - "foreign": "foreign file left unchanged"}[state] - print(f"Validated host Codex instructions into {live} ({detail})") - elif state == "remove": - print(f"Removed generated host Codex instructions from {live}") - elif state == "none": - print(f"No generated host Codex instructions at {live}") - else: - print(f"Synchronized host Codex instructions into {live}") - - -def build_parser(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("kind", choices=("codex-config-sync", "claude-settings-sync", "codex-rules-sync", - "codex-instructions-sync")) - options = parser.add_mutually_exclusive_group() - options.add_argument("--check", action="store_true", help="validate the complete merge without writing") - options.add_argument("--migrate-portable", action="store_true", help="explicitly clean an old polluted Codex baseline after preserving its live state") - parser.add_argument("--quiet", action="store_true", help="suppress success messages, but always report errors") - parser.add_argument("--hooks", type=Path, help="explicit scoped client hook event JSON; preserves local registrations for selected events") - parser.add_argument("--append", type=Path, metavar="RULES", help="codex-instructions-sync only: append these host rules; without it a generated LIVE is removed") - parser.add_argument("portable", type=Path) - parser.add_argument("live", type=Path) - return parser - - -def main(argv=None): - arguments = list(sys.argv[1:] if argv is None else argv) - args = None - try: - if arguments == ["--runtime-check"]: - require_runtime() - print(f"config-sync runtime: Python {sys.version.split()[0]}, tomlkit {TOMLKIT_VERSION}") - return 0 - parser = build_parser() - args = parser.parse_args(arguments) - if args.migrate_portable and args.kind != "codex-config-sync": - parser.error("--migrate-portable is only supported by codex-config-sync") - if args.hooks is not None and args.kind in ("codex-rules-sync", "codex-instructions-sync"): - parser.error("--hooks requires claude-settings-sync or codex-config-sync") - if args.append is not None and args.kind != "codex-instructions-sync": - parser.error("--append is only supported by codex-instructions-sync") - require_runtime(toml=args.kind == "codex-config-sync") - if args.kind == "codex-instructions-sync": - state = synchronize_instructions(args.portable, args.live, rules=args.append, check=args.check) - report_instructions(state, args.live, check=args.check, quiet=args.quiet) - return 0 - synchronize(args.kind, args.portable, args.live, check=args.check, migrate=args.migrate_portable, hooks=args.hooks) - if not args.quiet: - label = { - "codex-config-sync": "portable Codex settings", - "claude-settings-sync": "portable Claude settings", - "codex-rules-sync": "portable Codex rules", - }[args.kind] - print(f"{'Validated' if args.check else 'Synchronized'} {label} into {args.live}") - return 0 - except (OSError, ValueError, ImportError) as exc: - print(f"config-sync: {exc}", file=sys.stderr) - return 1 - except Exception as exc: - # tomlkit's parser errors do not all inherit ValueError. Keep malformed - # input fail-closed and report its diagnostic without a traceback. - if args is None or args.kind != "codex-config-sync": - raise - from tomlkit.exceptions import TOMLKitError - - if isinstance(exc, TOMLKitError): - print(f"config-sync: {exc}", file=sys.stderr) - return 1 - raise - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/lib/sync-runtime.sh b/lib/sync-runtime.sh deleted file mode 100644 index 7b3cfe2..0000000 --- a/lib/sync-runtime.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -# Sourced by the resolved sync wrapper; never installs or activates anything. -: "${sync_root:?sync wrapper must set the checkout root}" -: "${sync_kind:?sync wrapper must set its helper kind}" - -if [ "${DOTFILES_SYNC_PYTHON+x}" = x ]; then - sync_python=$DOTFILES_SYNC_PYTHON - # PowerShell/Windows Python emits native paths; Git Bash executes POSIX paths. - if command -v cygpath >/dev/null 2>&1 && [ -n "$sync_python" ]; then - sync_python=$(cygpath -u "$sync_python") - fi -elif [ -x "$sync_root/.venv-sync/bin/python" ]; then - sync_python=$sync_root/.venv-sync/bin/python -else - sync_python=$sync_root/.venv-sync/Scripts/python.exe -fi - -if [ ! -x "$sync_python" ]; then - echo "config-sync: Python runtime unavailable; run $sync_root/setup-sync.sh (or setup-sync.ps1) explicitly, or set DOTFILES_SYNC_PYTHON to a provisioned interpreter path" >&2 - exit 1 -fi - -exec "$sync_python" -I -B -X utf8 "$sync_root/lib/config_sync.py" "$sync_kind" "$@" diff --git a/requirements-sync.txt b/requirements-sync.txt deleted file mode 100644 index 53a1712..0000000 --- a/requirements-sync.txt +++ /dev/null @@ -1,3 +0,0 @@ -# One pure-Python runtime dependency; install only through explicit setup. -tomlkit==0.15.1 \ - --hash=sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304 diff --git a/setup-sync.ps1 b/setup-sync.ps1 deleted file mode 100644 index e9179b4..0000000 --- a/setup-sync.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -#Requires -Version 7.0 -<# Explicit checkout-local setup; never invoked by automatic update or login. #> -[CmdletBinding()] -param([string]$Python = 'python') - -$ErrorActionPreference = 'Stop' -$SyncVenv = Join-Path $PSScriptRoot '.venv-sync' -$SyncVenvItem = Get-Item -LiteralPath $SyncVenv -Force -ErrorAction SilentlyContinue -if ($null -ne $SyncVenvItem -and ($SyncVenvItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { - throw "Refusing a symlinked or junction virtual environment: $SyncVenv" -} -& $Python -I -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else "Python 3.11 or newer is required")' -if ($LASTEXITCODE -ne 0) { throw 'Python 3.11 or newer is required.' } -if ((Test-Path -LiteralPath $SyncVenv) -and -not (Test-Path -LiteralPath (Join-Path $SyncVenv 'pyvenv.cfg') -PathType Leaf)) { - throw "Refusing to reuse a non-venv directory: $SyncVenv" -} -& $Python -I -m venv $SyncVenv -if ($LASTEXITCODE -ne 0) { throw 'Could not create the AI-sync virtual environment.' } -$SyncRuntime = if ($IsWindows) { Join-Path $SyncVenv 'Scripts/python.exe' } else { Join-Path $SyncVenv 'bin/python' } -& $SyncRuntime -I -m pip install --disable-pip-version-check --require-hashes --only-binary=:all: --no-deps -r (Join-Path $PSScriptRoot 'requirements-sync.txt') -if ($LASTEXITCODE -ne 0) { throw 'Could not install the pinned AI-sync dependency.' } -& $SyncRuntime -I -B (Join-Path $PSScriptRoot 'lib/config_sync.py') --runtime-check -if ($LASTEXITCODE -ne 0) { throw 'AI-sync runtime validation failed.' } -Write-Host "AI-sync runtime ready in $SyncVenv; no shell activation is needed." diff --git a/setup-sync.sh b/setup-sync.sh deleted file mode 100755 index 4502246..0000000 --- a/setup-sync.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh -# Explicit, checkout-local AI-sync dependency setup. Never called by a profile. -set -eu - -setup_python=python3 -if [ "$#" -eq 2 ] && [ "$1" = --python ]; then - setup_python=$2 -elif [ "$#" -ne 0 ]; then - echo "usage: setup-sync.sh [--python PYTHON_EXECUTABLE]" >&2 - exit 2 -fi - -setup_root=$(CDPATH='' cd -P -- "$(dirname -- "$0")" && pwd) -setup_venv=$setup_root/.venv-sync -if [ -L "$setup_venv" ]; then - echo "setup-sync: refusing a symlinked virtual environment: $setup_venv" >&2 - exit 1 -fi -"$setup_python" -I -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else "Python 3.11 or newer is required")' -if [ -e "$setup_venv" ] && [ ! -f "$setup_venv/pyvenv.cfg" ]; then - echo "setup-sync: refusing to reuse a non-venv directory: $setup_venv" >&2 - exit 1 -fi -"$setup_python" -I -m venv "$setup_venv" -if [ -x "$setup_venv/bin/python" ]; then - setup_runtime=$setup_venv/bin/python -else - setup_runtime=$setup_venv/Scripts/python.exe -fi -"$setup_runtime" -I -m pip install --disable-pip-version-check --require-hashes --only-binary=:all: --no-deps -r "$setup_root/requirements-sync.txt" -"$setup_runtime" -I -B "$setup_root/lib/config_sync.py" --runtime-check -echo "AI-sync runtime ready in $setup_venv; no shell activation is needed." diff --git a/tests/COVERAGE.md b/tests/COVERAGE.md index dfaefcc..1432735 100644 --- a/tests/COVERAGE.md +++ b/tests/COVERAGE.md @@ -1,15 +1,15 @@ # Behavioral test coverage `./tests/run.sh` is the Unix entry point; `./tests/run.ps1` is the PowerShell -entry point. CI requires `--ci` on Unix and `-CI` on native Windows. Provision -the pinned parser with `setup-sync.sh` / `setup-sync.ps1` first. The pre-commit -hooks run the same focused Unix tests when their production sources change. +entry point. CI requires `--ci` on Unix and `-CI` on native Windows. The +pre-commit hooks run the same focused Unix tests when their production sources +change. -The suites read only the frozen fixtures in `tests/fixtures/`, byte copies of -the dotfiles settings data. Dotfiles replaces `codex-config.toml`, -`claude-settings.json` and `portable.rules` there with its real files and -reruns `config-sync.sh` and `claude-customizations.cjs`; the checks of what -that data contains, Stow wiring and the installers stay in dotfiles. +The suites read only the frozen fixtures in `tests/fixtures/`. +`claude-settings.json` there is a byte copy of the dotfiles Claude settings; +dotfiles replaces it with its real file and reruns `claude-customizations.cjs`. +Checks of what a consumer's own data contains and how it wires the kit stay +with that consumer. Shell line-coverage is not used as a hard gate: these programs mix sourced POSIX shell, Bash, Python, Node, child processes, and PowerShell, for @@ -18,14 +18,15 @@ source-to-test behavior matrix instead. | Production target | Test | Covered behavior | | --- | --- | --- | -| `lib/config_sync.py` and sync runtime/wrappers | `test_config_sync.py`, `config-sync.sh` | parsed TOML multiline/quoted/dotted/inline/AoT forms, required/retired ownership, JSON authoritative arrays and type-sensitive no-op, source immutability, explicit migration, read-only preflight, invalid/unreadable inputs, adjacent replacement and failure cleanup, stricter permissions retained on no-op/write, quiet success but visible errors, JSON/rules with stdlib-only runtime | -| `setup-sync.sh` / `setup-sync.ps1` | `test_config_sync.py`, CI setup steps | explicit pinned runtime, dependency validation, no runtime auto-install, unsafe environment reuse rejection | -| `common/claude/.local/bin/claude-settings-sync` | `ai-config-sync.sh` | portable-wins deep merge, project-instructions plugin config, live-only state, malformed/mistyped input, fresh and empty live files, permissions, idempotence, legacy symlink | -| `common/claude/.claude/dotfiles/*.cjs` | `claude-customizations.cjs` | status-line fallbacks and Git branches, terminal notification routing and escaping, Git bypass blocks and allowed commands, portable launchers with spaced paths; desktop delivery needs an interactive terminal | -| `common/codex/.local/bin/codex-config-sync` | `codex-config-sync.sh` | portable public-network profile ownership, runtime top-level/table/array-table preservation, legacy sandbox removal, removed compatibility flags, fresh live file, permissions, idempotence, missing-key failure, legacy symlink | -| `common/codex/.local/bin/codex-rules-sync` | `ai-config-sync.sh` | create/no-op, empty source failure, sibling preservation, permissions, matching legacy symlink | -| `common/codex/.local/bin/codex-instructions-sync` (the `codex-instructions-sync` kind in `lib/config_sync.py`) | `test_config_sync.py` (InstructionsTests) | MARKER contract, portable plus host rules written 0644 and never widened, newline normalization, byte-identical no-op, stale regeneration, foreign or symlinked LIVE refused with `--append` and kept with a warning without it, removal of a generated LIVE only, `--check` writing nothing and reporting current/stale/foreign, dangling rules with the submodule hint, empty or non-UTF-8 inputs, option rejection, no tomlkit | -| `tests/run.sh`, `tests/run.ps1` and CI | `test-entrypoints.sh`, three native CI jobs | required dependencies fail visibly, local optional skips reported, native Windows job cannot run on Unix, shared backend suite required, every entry point unsets the same dotfiles knobs (covering each `DOTFILES_*`/`AWESOME_SKILLS_*` name in any tracked file outside tests, docs, `.github` and Markdown, in shell, PowerShell or Python spelling) and a suite passes under the provisioning exports | +| `lib/coding-agent-kit/config.py` | `test_config.py` (`Loader`), `config.sh` | location precedence (`CODING_AGENT_KIT_CONFIG`, an absolute `XDG_CONFIG_HOME`, `~/.config`; empty and relative values ignored), a missing file and a path component that is a regular file give the defaults (an explicit file under one is not found), an explicit file must be absolute and exist, resolving and dangling symlinks (the file and a directory on the way), a FIFO refused without blocking, an unreadable file, an unsearchable directory and a symlink loop fail closed with `cannot be opened`, umask 002/022/077, group write only for the user private group (the login group from the password database: accepted with another real gid, as after `newgrp` or `sg`, and refused when the login group differs, for the file and the directories; listed members and, through a patched `getpwall`, accounts whose primary group it is; the accounts listed once per load for three group-writable places), group write with an access ACL refused (patched `listxattr`: POSIX and NFSv4 names, no `listxattr`, ENOTSUP, a read error; and, where `setfacl` works, a named group entry, a directory default ACL and an ACL on a directory), other-writable files and foreign owners refused, the directories on the way up to HOME (not above, also when HOME is a symlink into a shared group directory, through the link or physically spelled, and the HOME link must be yours) or to `/` for a file outside HOME, as written and resolved: group-writable non-private, other-writable and foreign-owned refused, sticky and an Ubuntu 0775 user private group accepted, a stowed link into a checkout, symlinks owned by another user refused and by root accepted, the 64 KiB limit and UTF-8, syntax errors that name the position and never the contents, nesting past the parser's recursion and an integer past the digit limit as `invalid TOML`, unknown keys warn and continue, value errors (type, empty, C0, DEL and C1 control characters such as CSI and NEL, escaped or raw, relative, `..`, `/` in a command name), executables as names or absolute or `~/` paths, `codex_home` and `secrets_dir` never equal or nested, also when spelled with a leading `//` or under a HOME spelled with one, a leading slash run collapsed in stored values, an absolute HOME required, `expand` with the loaded HOME, the `--show-config` lines; golden defaults: an explicit-defaults file loads equal to no file | +| `bin/codex-as`, `bin/claude-as`: location, help and metadata queries | `test_config.py` (`Launchers`), `config.sh` | help names the kit's docs, or the GitHub URL for a lone copy or a symlink loop, and needs no python3, while a launch without python3 fails once and writes nothing (`codex-as` with its Python 3.11+ message, `claude-as` with the shell's exec error, exit 127, as in 0.1.0); resolution cases: direct, PATH lookup, relative, relative upwards, two-hop Stow-style links, a symlinked directory alias, a second hop through a symlinked directory with a lexical decoy, and under `bash`, `sh`, `/bin/sh` and `dash` a path, a name in the current directory, two-hop links and that second hop; 40 links accepted, 41 links and a cycle refused with one message; not on PATH and unsearchable directories fail with one message; incomplete installs name the root without a traceback (shell side, Python side and the 0.1 `check.py accounts`); `--list-accounts` and `--show-config` start nothing and ignore launch policy; a bad configuration gives empty listings and completions; linked installs, their completions and the 0.1 helper entry points follow the configuration; configured paths, executables and parent checks for both launchers; a missing parent and one below a regular file are named; a `~/.config` that is a file launches with the defaults; deep nesting, a huge integer and a control character in the file, and an unknown user in `claude-as --settings`, print one line and no traceback from both launchers and the 0.1 `check.py accounts`; unknown keys warn once and the launch continues; golden defaults: no file, an explicit `~/` defaults file and an absolute-spelled one give identical argv, environment, HOME tree and outputs; no kit code outside the loader reads HOME | +| `bin/codex-as`, `lib/codex-as/*.py` | `test_codex_as.py`, `test_codex_sharing_native.py`, `codex-as.sh` | fixed account views and shared state, switching and refresh writeback, root `auth.json` never read or changed, argument forwarding with I/O, cwd, exit status and umask preserved, refused commands, settings, environment and session names, profiles, plain-codex commands, version acceptance, daemon takeover (idle, possibly running, picker scope, `--last`, journals, torn and interrupted takeovers, identity and version checks), leases and concurrent clients, signals, credential repair and divergence, drift and permission refusals that name the path, interrupted setup; with a real Codex 0.161.0 or newer on PATH, offline app-server checks of history, discovery, writer locks, shared config writes and token refresh through the account link | +| `bin/claude-as`, `lib/claude-as/launch.py`, `completions/*` | `test_account_enrollment.py`, `account-enrollment.sh` | enrolment of both tools through `/dev/tty` with the original command continued, decline, failure and no terminal, Claude token capture and manual fallback, conflicts and refused options, Claude account names starting with `-` refused with codex-as's message and nothing started or written, completion lists safe names without opening secrets or starting a CLI; colour: `CODING_AGENT_KIT_COLOR` auto, always, never and an invalid value, `NO_COLOR` and `TERM=dumb`, `DOTFILES_COLOR` ignored, each message styled only for its own destination, `--list-accounts` output plain | +| `install.sh` | `standalone-e2e.sh` | runs under `/bin/sh`, bash and dash (when present), directly, through a link, through a relative link from another depth, by relative name and through a directory alias of the checkout, from a checkout path with spaces and pattern characters (`kit [with] spaces*`); 40 links followed and 41 refused; the printed links, `fpath`, helper and PATH lines; idempotent reinstall; `--prefix` with a space, `XDG_DATA_HOME` and `BASH_COMPLETION_USER_DIR`; refusal, with nothing changed and `--uninstall` keeping each, of a file, a directory, a link into another checkout, a Stow-style two-hop link, a dangling link to a deleted checkout, a link into a prefix sibling, an absolute text leaving the checkout through `..`, a hand-made relative link into it and a hand-made link through a directory alias of it; refusal of every destination reached through a relative directory link: hand-made below HOME (named with pattern characters, and spelled with a trailing slash beside a `--prefix` outside it) and below a `--prefix` outside HOME (an own link already there included) and (with GNU Stow) a folded `~/.local/bin` holding a package link and a folded `~/.local/bin` and `~/.local/share` of a package without one, whose Git repository stays clean; an absolute `~/.local` link and a HOME below a relative link install and uninstall, also with a `--prefix` whose parent lies above that link and with that link, an ancestor of HOME, as the `--prefix`; a HOME that is itself a relative link (named with pattern characters) installs and uninstalls with the default prefix and with `--prefix` HOME; HOME=/ with `BASH_COMPLETION_USER_DIR` in the test directory: a `--prefix` below a relative link installs and uninstalls and a `--prefix` that is one is refused; not covered: the fallback for a `--prefix` directly under `/`, which needs root to make a link there; links of an older layout replaced and removed; a destination whose nearest existing parent is a file, a dangling link, read-only or unsearchable, and this checkout's own links in a read-only directory, refused before any link; `--uninstall` refusing, with nothing removed, own links in a read-only directory and an unsearchable directory on the way (read-only and unsearchable cases skipped as root); an incomplete checkout and `sh /dev/null 2>&1; then - shasum -a 256 "$1" | awk '{print $1}' - else - sha256sum "$1" | awk '{print $1}' - fi -} - -assert_mode() { - python3 - "$1" "$2" <<'PY' -import os -import sys - -actual = os.stat(sys.argv[1]).st_mode & 0o777 -expected = int(sys.argv[2], 8) -assert actual == expected, f"{sys.argv[1]}: mode {actual:o}, expected {expected:o}" -PY -} - -assert_json_policy() { - python3 - "$CLAUDE_PORTABLE" "$1" <<'PY' -import json -import sys - -with open(sys.argv[1]) as handle: - portable = json.load(handle) -with open(sys.argv[2]) as handle: - live = json.load(handle) - -assert live["permissions"]["defaultMode"] == "auto" -assert live["autoMode"]["classifyAllShell"] is True -assert live["autoMode"]["environment"] == ["machine-only context"] -assert live["effortLevel"] == "xhigh" -assert live["statusLine"] == portable["statusLine"] -assert live["hooks"]["PreToolUse"] == portable["hooks"]["PreToolUse"] -assert live["hooks"]["Notification"] == portable["hooks"]["Notification"] -assert live["hooks"]["SessionStart"] == [] -assert live["hooks"]["Stop"] == [] -assert live["hooks"]["SessionEnd"] == [] -assert live["hooks"]["PostToolUse"] == [] -assert live["permissions"]["allow"] == portable["permissions"]["allow"] -assert live["permissions"]["ask"] == portable["permissions"]["ask"] -assert live["permissions"]["additionalDirectories"] == ["/machine-only/project"] -assert live["runtimeOnly"] == {"keep": True} -assert live["model"] == "machine-local-model" - -# The stub import is external when launched from a subdirectory; older clients -# read only the legacy plugin ID, so native AGENTS.md loading needs both IDs. -for plugin_id in ("agents-md@builtin", "cc-plugin-agents-md@builtin"): - assert portable["pluginConfigs"][plugin_id] == { - "options": {"instructionFiles": "claude-md-and-agents-md"} - } - assert live["pluginConfigs"][plugin_id] == portable["pluginConfigs"][plugin_id] -PY -} - -# Preflight validates a complete candidate without creating an absent target -# parent, materializing a file, or touching either portable source. -claude_source_before="$(hash_file "$CLAUDE_PORTABLE")" -rules_source_before="$(hash_file "$CODEX_RULES_PORTABLE")" -"$CLAUDE_SYNC" --check "$CLAUDE_PORTABLE" "$TEST_TMP/check-only/claude/settings.json" >/dev/null -"$CODEX_RULES_SYNC" --check "$CODEX_RULES_PORTABLE" "$TEST_TMP/check-only/codex/portable.rules" >/dev/null -[[ ! -e "$TEST_TMP/check-only" ]] -[[ "$claude_source_before" == "$(hash_file "$CLAUDE_PORTABLE")" ]] -[[ "$rules_source_before" == "$(hash_file "$CODEX_RULES_PORTABLE")" ]] - -# Explicit source migration belongs only to Codex TOML, not JSON or rules. -if "$CLAUDE_SYNC" --migrate-portable "$CLAUDE_PORTABLE" "$TEST_TMP/check-only/settings.json" >/dev/null 2>&1; then - echo "ERROR: Claude unexpectedly accepted Codex-only migration" >&2 - exit 1 -fi -[[ ! -e "$TEST_TMP/check-only" ]] - -cat >"$TEST_TMP/claude-live.json" <<'JSON' -{ - "permissions": { - "defaultMode": "acceptEdits", - "allow": ["Bash(local-only *)"], - "ask": ["Bash(old-policy *)"], - "additionalDirectories": ["/machine-only/project"] - }, - "runtimeOnly": {"keep": true}, - "autoMode": {"classifyAllShell": false, "environment": ["machine-only context"]}, - "effortLevel": "medium", - "statusLine": {"type": "command", "command": "old-status-line"}, - "hooks": { - "PreToolUse": [{"hooks": [{"type": "command", "command": "npx old-guard"}]}], - "SessionStart": [{"hooks": [{"type": "command", "command": "old-plugin-copy"}]}], - "Stop": [{"hooks": [{"type": "command", "command": "old-plugin-copy"}]}], - "SessionEnd": [{"hooks": [{"type": "command", "command": "old-plugin-copy"}]}], - "PostToolUse": [] - }, - "model": "machine-local-model" -} -JSON - -"$CLAUDE_SYNC" "$CLAUDE_PORTABLE" "$TEST_TMP/claude-live.json" >/dev/null -assert_json_policy "$TEST_TMP/claude-live.json" - -claude_before="$(hash_file "$TEST_TMP/claude-live.json")" -"$CLAUDE_SYNC" "$CLAUDE_PORTABLE" "$TEST_TMP/claude-live.json" >/dev/null -claude_after="$(hash_file "$TEST_TMP/claude-live.json")" -[[ "$claude_before" == "$claude_after" ]] - -printf '%s\n' '{"permissions":' >"$TEST_TMP/claude-malformed.json" -if "$CLAUDE_SYNC" \ - "$TEST_TMP/claude-malformed.json" \ - "$TEST_TMP/claude-live.json" >/dev/null 2>&1; then - echo "ERROR: malformed Claude baseline unexpectedly succeeded" >&2 - exit 1 -fi -[[ "$claude_after" == "$(hash_file "$TEST_TMP/claude-live.json")" ]] - -printf '%s\n' '{"permissions":{"allow":[]}}' >"$TEST_TMP/claude-missing-ask.json" -if "$CLAUDE_SYNC" \ - "$TEST_TMP/claude-missing-ask.json" \ - "$TEST_TMP/claude-live.json" >/dev/null 2>&1; then - echo "ERROR: incomplete Claude permission baseline unexpectedly succeeded" >&2 - exit 1 -fi -[[ "$claude_after" == "$(hash_file "$TEST_TMP/claude-live.json")" ]] - -printf '%s\n' '{"runtimeOnly":' >"$TEST_TMP/claude-malformed-live.json" -malformed_live_before="$(hash_file "$TEST_TMP/claude-malformed-live.json")" -if "$CLAUDE_SYNC" \ - "$CLAUDE_PORTABLE" \ - "$TEST_TMP/claude-malformed-live.json" >/dev/null 2>&1; then - echo "ERROR: malformed Claude live settings unexpectedly succeeded" >&2 - exit 1 -fi -[[ "$malformed_live_before" == "$(hash_file "$TEST_TMP/claude-malformed-live.json")" ]] - -printf '%s\n' '[]' >"$TEST_TMP/claude-nonobject.json" -if "$CLAUDE_SYNC" \ - "$TEST_TMP/claude-nonobject.json" \ - "$TEST_TMP/claude-live.json" >/dev/null 2>&1; then - echo "ERROR: non-object Claude baseline unexpectedly succeeded" >&2 - exit 1 -fi -[[ "$claude_after" == "$(hash_file "$TEST_TMP/claude-live.json")" ]] - -cat >"$TEST_TMP/claude-invalid-array.json" <<'JSON' -{"permissions":{"allow":[42],"ask":[]}} -JSON -if "$CLAUDE_SYNC" \ - "$TEST_TMP/claude-invalid-array.json" \ - "$TEST_TMP/claude-live.json" >/dev/null 2>&1; then - echo "ERROR: non-string Claude permission unexpectedly succeeded" >&2 - exit 1 -fi -[[ "$claude_after" == "$(hash_file "$TEST_TMP/claude-live.json")" ]] - -# Missing/empty live files seed a fresh host and create their parent directory. -"$CLAUDE_SYNC" \ - "$CLAUDE_PORTABLE" \ - "$TEST_TMP/fresh/.claude/settings.json" >/dev/null -cmp -s "$CLAUDE_PORTABLE" "$TEST_TMP/fresh/.claude/settings.json" -assert_mode "$TEST_TMP/fresh/.claude/settings.json" 644 - -mkdir -p "$TEST_TMP/empty/.claude" -: >"$TEST_TMP/empty/.claude/settings.json" -"$CLAUDE_SYNC" \ - "$CLAUDE_PORTABLE" \ - "$TEST_TMP/empty/.claude/settings.json" >/dev/null -cmp -s "$CLAUDE_PORTABLE" "$TEST_TMP/empty/.claude/settings.json" - -# Migrating a matching legacy Stow link must leave the baseline untouched and -# replace the live path with a regular file. -mkdir -p "$TEST_TMP/claude-symlink" -cp "$CLAUDE_PORTABLE" "$TEST_TMP/claude-symlink/portable.json" -ln -s portable.json "$TEST_TMP/claude-symlink/live.json" -claude_symlink_target_before="$(hash_file "$TEST_TMP/claude-symlink/portable.json")" -"$CLAUDE_SYNC" \ - "$TEST_TMP/claude-symlink/portable.json" \ - "$TEST_TMP/claude-symlink/live.json" >/dev/null -[[ ! -L "$TEST_TMP/claude-symlink/live.json" ]] -[[ "$claude_symlink_target_before" == "$(hash_file "$TEST_TMP/claude-symlink/portable.json")" ]] -cmp -s \ - "$TEST_TMP/claude-symlink/portable.json" \ - "$TEST_TMP/claude-symlink/live.json" - -mkdir -p "$TEST_TMP/codex/rules" -printf '%s\n' \ - 'prefix_rule(pattern=["host-only"], decision="allow")' \ - >"$TEST_TMP/codex/rules/default.rules" -default_before="$(hash_file "$TEST_TMP/codex/rules/default.rules")" - -"$CODEX_RULES_SYNC" \ - "$CODEX_RULES_PORTABLE" \ - "$TEST_TMP/codex/rules/portable.rules" >/dev/null -cmp -s "$CODEX_RULES_PORTABLE" "$TEST_TMP/codex/rules/portable.rules" -[[ "$default_before" == "$(hash_file "$TEST_TMP/codex/rules/default.rules")" ]] - -portable_before="$(hash_file "$TEST_TMP/codex/rules/portable.rules")" -"$CODEX_RULES_SYNC" \ - "$CODEX_RULES_PORTABLE" \ - "$TEST_TMP/codex/rules/portable.rules" >/dev/null -[[ "$portable_before" == "$(hash_file "$TEST_TMP/codex/rules/portable.rules")" ]] -assert_mode "$TEST_TMP/codex/rules/portable.rules" 644 - -printf '%s\n' 'host-local-sentinel' >"$TEST_TMP/codex/rules/host-local.rules" -"$CODEX_RULES_SYNC" \ - "$CODEX_RULES_PORTABLE" \ - "$TEST_TMP/codex/rules/portable.rules" >/dev/null -grep -Fxq 'host-local-sentinel' "$TEST_TMP/codex/rules/host-local.rules" - -# A content-identical legacy link is not a valid materialized live rules file. -mkdir -p "$TEST_TMP/rules-symlink" -cp "$CODEX_RULES_PORTABLE" "$TEST_TMP/rules-symlink/source.rules" -ln -s source.rules "$TEST_TMP/rules-symlink/live.rules" -rules_symlink_target_before="$(hash_file "$TEST_TMP/rules-symlink/source.rules")" -"$CODEX_RULES_SYNC" \ - "$TEST_TMP/rules-symlink/source.rules" \ - "$TEST_TMP/rules-symlink/live.rules" >/dev/null -[[ ! -L "$TEST_TMP/rules-symlink/live.rules" ]] -[[ "$rules_symlink_target_before" == "$(hash_file "$TEST_TMP/rules-symlink/source.rules")" ]] -cmp -s \ - "$TEST_TMP/rules-symlink/source.rules" \ - "$TEST_TMP/rules-symlink/live.rules" - -: >"$TEST_TMP/empty.rules" -if "$CODEX_RULES_SYNC" \ - "$TEST_TMP/empty.rules" \ - "$TEST_TMP/codex/rules/portable.rules" >/dev/null 2>&1; then - echo "ERROR: empty Codex rules baseline unexpectedly succeeded" >&2 - exit 1 -fi -[[ "$portable_before" == "$(hash_file "$TEST_TMP/codex/rules/portable.rules")" ]] - -echo "ai-config-sync=PASS" diff --git a/tests/claude-customizations.cjs b/tests/claude-customizations.cjs index e23845a..22b8f8b 100644 --- a/tests/claude-customizations.cjs +++ b/tests/claude-customizations.cjs @@ -23,7 +23,7 @@ process.env.GIT_CONFIG_NOSYSTEM = '1'; process.env.GIT_TEMPLATE_DIR = path.join(gitEnvironment, 'empty-template'); fs.mkdirSync(process.env.GIT_TEMPLATE_DIR); -const scripts = path.resolve(__dirname, '../common/claude/.claude/dotfiles'); +const scripts = path.resolve(__dirname, '../helpers'); const { formatStatus, gitBranch } = require(path.join(scripts, 'statusline.cjs')); const { notification } = require(path.join(scripts, 'notify.cjs')); const { bypassReason } = require(path.join(scripts, 'check-git-hooks.cjs')); @@ -149,15 +149,19 @@ test('Git guard permits ordinary commands and non-bypass -n flags', () => { test('portable launchers resolve stowed helpers through a home path containing spaces', () => { const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'claude home ')); try { - const target = path.join(temporary, '.claude', 'dotfiles'); - fs.mkdirSync(target, { recursive: true }); - for (const name of fs.readdirSync(scripts)) fs.copyFileSync(path.join(scripts, name), path.join(target, name)); + // Each command names its install path under the home directory (dotfiles + // uses ~/.claude/dotfiles); install the helper it names there. const execute = (command, input) => { const match = command.match(/^node -e "([^"]+)"$/); assert.ok(match, command); + const installed = match[1].match(/^require\(require\('os'\)\.homedir\(\) \+ '\/([^']+\/)?([^'/]+\.cjs)'\)\.main\(\)$/); + assert.ok(installed && !(installed[1] || '').split('/').includes('..'), command); + const target = path.join(temporary, ...(installed[1] || '').split('/')); + fs.mkdirSync(target, { recursive: true }); + fs.copyFileSync(path.join(scripts, installed[2]), path.join(target, installed[2])); return spawnSync(process.execPath, ['-e', - 'require("os").homedir = () => process.env.CLAUDE_DOTFILES_TEST_HOME; ' + match[1]], { - env: { ...process.env, CLAUDE_DOTFILES_TEST_HOME: temporary }, + 'require("os").homedir = () => process.env.KIT_HELPER_TEST_HOME; ' + match[1]], { + env: { ...process.env, KIT_HELPER_TEST_HOME: temporary }, input: JSON.stringify(input), encoding: 'utf8', timeout: 5000, }); }; diff --git a/tests/codex-as.sh b/tests/codex-as.sh index 5edc2fd..b02c191 100755 --- a/tests/codex-as.sh +++ b/tests/codex-as.sh @@ -2,9 +2,9 @@ set -euo pipefail -unset DOTFILES_AUTO_UPDATE DOTFILES_AUTO_STOW DOTFILES_HOST DOTFILES_DIR _DOTFILES_CHECKED \ - DOTFILES_STOW_WITHOUT_OH_MY_ZSH DOTFILES_COLOR AWESOME_SKILLS_AUTO_UPDATE AWESOME_SKILLS_FORCE \ - AWESOME_SKILLS_BG AWESOME_SKILLS_INSTALLER_URL AWESOME_SKILLS_REFRESH_DAYS _AWESOME_SKILLS_CHECKED +# Hermetic: a caller's kit configuration, colour choice and XDG directories +# never reach the code under test; each case sets what it needs. +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" python3 -I -B "$TEST_DIR/test_codex_as.py" diff --git a/tests/codex-config-sync.sh b/tests/codex-config-sync.sh deleted file mode 100755 index 0ce8309..0000000 --- a/tests/codex-config-sync.sh +++ /dev/null @@ -1,399 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# Hermetic: the provisioning exports (DOTFILES_AUTO_UPDATE=0 and the like, -# docs/bootstrap.md) and other dotfiles knobs never reach the code under -# test from the caller; each case sets what it needs. -unset DOTFILES_AUTO_UPDATE DOTFILES_AUTO_STOW DOTFILES_HOST DOTFILES_DIR _DOTFILES_CHECKED \ - DOTFILES_STOW_WITHOUT_OH_MY_ZSH DOTFILES_COLOR AWESOME_SKILLS_AUTO_UPDATE AWESOME_SKILLS_FORCE \ - AWESOME_SKILLS_BG AWESOME_SKILLS_INSTALLER_URL AWESOME_SKILLS_REFRESH_DAYS _AWESOME_SKILLS_CHECKED - -REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -SYNC="$REPO_ROOT/common/codex/.local/bin/codex-config-sync" -SYNC_PYTHON=${DOTFILES_SYNC_PYTHON:-$REPO_ROOT/.venv-sync/bin/python} -TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/dotfiles-codex-sync.XXXXXX")" -trap 'rm -rf "$TEST_TMP"' EXIT HUP INT TERM - -hash_file() { - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$1" | awk '{print $1}' - else - sha256sum "$1" | awk '{print $1}' - fi -} - -assert_mode() { - python3 - "$1" "$2" <<'PY' -import os -import sys - -actual = os.stat(sys.argv[1]).st_mode & 0o777 -expected = int(sys.argv[2], 8) -assert actual == expected, f"{sys.argv[1]}: mode {actual:o}, expected {expected:o}" -PY -} - -assert_toml_equal() { - "$SYNC_PYTHON" - "$1" "$2" <<'PY' -import sys -import tomllib - -with open(sys.argv[1], "rb") as handle: - expected = tomllib.load(handle) -with open(sys.argv[2], "rb") as handle: - actual = tomllib.load(handle) -assert actual == expected, (actual, expected) -PY -} - -assert_status() { - expected=$1 - shift - set +e - "$@" >"$TEST_TMP/status.stdout" 2>"$TEST_TMP/status.stderr" - actual=$? - set -e - if [[ "$actual" -ne "$expected" ]]; then - echo "ERROR: expected exit $expected, got $actual: $*" >&2 - sed 's/^/stdout: /' "$TEST_TMP/status.stdout" >&2 - sed 's/^/stderr: /' "$TEST_TMP/status.stderr" >&2 - exit 1 - fi -} - -cat >"$TEST_TMP/portable.toml" <<'TOML' -# Runtime state left by an older symlink layout must not stay in the baseline. -model = "gpt-6-astra" -model_reasoning_effort = "xhigh" -plan_mode_reasoning_effort = "xhigh" -personality = "pragmatic" -default_permissions = "workspace-net" -sandbox_mode = "workspace-write" -approval_policy = "on-request" -approvals_reviewer = "auto_review" -project_doc_fallback_filenames = ["CLAUDE.md"] - -[sandbox_workspace_write] -network_access = true -source_only = "drop-me" - -[features] -network_proxy = true -memories = true -multi_agent = true -js_repl = true -source_only = "drop-me" - -[permissions.workspace-net] -description = "Workspace editing with unrestricted public network access." -extends = ":workspace" -source_only = "drop-me" - -[permissions.workspace-net.network] -enabled = true -allow_local_binding = false -source_only = "drop-me" - -[permissions.workspace-net.network.domains] -"*" = "allow" -"source-only.example.com" = "deny" - -[memories] -use_memories = true -generate_memories = true -source_only = "drop-me" - -[source_only] -drop = true -TOML - -cat >"$TEST_TMP/live.toml" <<'TOML' -model_reasoning_effort = "low" -plan_mode_reasoning_effort = "medium" -personality = "friendly" -default_permissions = ":read-only" -sandbox_mode = "read-only" -approval_policy = "never" -approvals_reviewer = "manual" -project_doc_fallback_filenames = ["AGENTS.md"] -model = "machine-local-model" -runtime_flag = true - -[sandbox_workspace_write] -network_access = false -writable_roots = ["/machine-only/project"] - -[permissions.machine_local] -extends = ":workspace" - -[permissions.workspace-net] -description = "live description" -extends = ":read-only" -runtime_profile = "keep-me" - -[permissions.workspace-net.network] -enabled = false -allow_local_binding = true -runtime_network = "keep-me" - -[permissions.workspace-net.network.domains] -"*" = "deny" -"private.example.com" = "deny" - -[features] -network_proxy = false -multi_agent = false -memories = false -js_repl = true -runtime_feature = "keep-me" - -[memories] -generate_memories = false -use_memories = false -retention_days = 30 - -[projects."/machine-only/project"] -trust_level = "trusted" - -[profiles.custom] -model = "profile-specific-model" -model_reasoning_effort = "low" -plan_mode_reasoning_effort = "low" - -[[mcp_servers.demo.tools]] -name = "runtime-tool" -model = "tool-specific-model" -TOML - -chmod 600 "$TEST_TMP/portable.toml" -cp "$TEST_TMP/portable.toml" "$TEST_TMP/portable-before.toml" -"$SYNC" "$TEST_TMP/portable.toml" "$TEST_TMP/live.toml" >/dev/null -cmp -s "$TEST_TMP/portable-before.toml" "$TEST_TMP/portable.toml" -assert_mode "$TEST_TMP/portable.toml" 600 - -cat >"$TEST_TMP/expected-portable.toml" <<'TOML' -model = "gpt-6-astra" -model_reasoning_effort = "xhigh" -plan_mode_reasoning_effort = "xhigh" -personality = "pragmatic" -default_permissions = "workspace-net" -approval_policy = "on-request" -approvals_reviewer = "auto_review" -project_doc_fallback_filenames = ["CLAUDE.md"] - -[features] -network_proxy = true -multi_agent = true -memories = true - -[permissions.workspace-net] -description = "Workspace editing with unrestricted public network access." -extends = ":workspace" - -[permissions.workspace-net.network] -enabled = true -allow_local_binding = false - -[permissions.workspace-net.network.domains] -"*" = "allow" - -[memories] -generate_memories = true -use_memories = true -TOML - -cat >"$TEST_TMP/expected-live.toml" <<'TOML' -model = "gpt-6-astra" -model_reasoning_effort = "xhigh" -plan_mode_reasoning_effort = "xhigh" -personality = "pragmatic" -default_permissions = "workspace-net" -approval_policy = "on-request" -approvals_reviewer = "auto_review" -project_doc_fallback_filenames = ["CLAUDE.md"] -runtime_flag = true - -[features] -network_proxy = true -multi_agent = true -memories = true -runtime_feature = "keep-me" - -[permissions.workspace-net] -description = "Workspace editing with unrestricted public network access." -extends = ":workspace" -runtime_profile = "keep-me" - -[permissions.workspace-net.network] -enabled = true -allow_local_binding = false -runtime_network = "keep-me" - -[permissions.workspace-net.network.domains] -"*" = "allow" -"private.example.com" = "deny" - -[memories] -generate_memories = true -use_memories = true -retention_days = 30 - -[permissions.machine_local] -extends = ":workspace" - -[projects."/machine-only/project"] -trust_level = "trusted" - -[profiles.custom] -model = "profile-specific-model" -model_reasoning_effort = "low" -plan_mode_reasoning_effort = "low" - -[[mcp_servers.demo.tools]] -name = "runtime-tool" -model = "tool-specific-model" -TOML - -assert_toml_equal "$TEST_TMP/expected-live.toml" "$TEST_TMP/live.toml" - -# Cleaning polluted tracked input is an explicit migration, never a side -# effect of regular sync or preflight. Its live-only state remains materialized. -"$SYNC" --migrate-portable "$TEST_TMP/portable.toml" "$TEST_TMP/live.toml" >/dev/null -assert_toml_equal "$TEST_TMP/expected-portable.toml" "$TEST_TMP/portable.toml" -"$SYNC_PYTHON" - "$TEST_TMP/expected-live.toml" "$TEST_TMP/live.toml" <<'PY' -import sys -import tomllib - -with open(sys.argv[1], "rb") as handle: - expected = tomllib.load(handle) -with open(sys.argv[2], "rb") as handle: - live = tomllib.load(handle) - -# Explicit migration retains the source-only runtime state before removing -# it from the tracked baseline. Current live values win any runtime conflicts. -expected["source_only"] = {"drop": True} -for table in (expected["features"], expected["memories"], - expected["permissions"]["workspace-net"], - expected["permissions"]["workspace-net"]["network"]): - table["source_only"] = "drop-me" -expected["permissions"]["workspace-net"]["network"]["domains"]["source-only.example.com"] = "deny" -assert live == expected, (live, expected) -PY - -assert_mode "$TEST_TMP/portable.toml" 600 -assert_mode "$TEST_TMP/live.toml" 600 - -# A no-op sync must not replace either file. -portable_hash="$(hash_file "$TEST_TMP/portable.toml")" -live_hash="$(hash_file "$TEST_TMP/live.toml")" -portable_inode="$(python3 -c 'import os, sys; print(os.stat(sys.argv[1]).st_ino)' "$TEST_TMP/portable.toml")" -live_inode="$(python3 -c 'import os, sys; print(os.stat(sys.argv[1]).st_ino)' "$TEST_TMP/live.toml")" -"$SYNC" "$TEST_TMP/portable.toml" "$TEST_TMP/live.toml" >/dev/null -[[ "$portable_hash" == "$(hash_file "$TEST_TMP/portable.toml")" ]] -[[ "$live_hash" == "$(hash_file "$TEST_TMP/live.toml")" ]] -[[ "$portable_inode" == "$(python3 -c 'import os, sys; print(os.stat(sys.argv[1]).st_ino)' "$TEST_TMP/portable.toml")" ]] -[[ "$live_inode" == "$(python3 -c 'import os, sys; print(os.stat(sys.argv[1]).st_ino)' "$TEST_TMP/live.toml")" ]] - -# A fresh host gets a parent directory and a complete live config. -mkdir -p "$TEST_TMP/fresh" -cp "$TEST_TMP/portable.toml" "$TEST_TMP/fresh/portable.toml" -"$SYNC" \ - "$TEST_TMP/fresh/portable.toml" \ - "$TEST_TMP/fresh/home/.codex/config.toml" >/dev/null -cmp -s \ - "$TEST_TMP/fresh/portable.toml" \ - "$TEST_TMP/fresh/home/.codex/config.toml" -assert_mode "$TEST_TMP/fresh/home/.codex/config.toml" 600 - -# Migrating an old Stow layout must replace the live symlink, not its target. -mkdir -p "$TEST_TMP/symlink" -cp "$TEST_TMP/portable.toml" "$TEST_TMP/symlink/portable.toml" -ln -s portable.toml "$TEST_TMP/symlink/live.toml" -symlink_target_hash="$(hash_file "$TEST_TMP/symlink/portable.toml")" -"$SYNC" \ - "$TEST_TMP/symlink/portable.toml" \ - "$TEST_TMP/symlink/live.toml" >/dev/null -[[ ! -L "$TEST_TMP/symlink/live.toml" ]] -[[ "$symlink_target_hash" == "$(hash_file "$TEST_TMP/symlink/portable.toml")" ]] -cmp -s "$TEST_TMP/symlink/portable.toml" "$TEST_TMP/symlink/live.toml" - -# Validation failures are fail-closed for both the portable source and live file. -cat >"$TEST_TMP/incomplete.toml" <<'TOML' -model = "gpt-6-astra" -model_reasoning_effort = "high" -plan_mode_reasoning_effort = "high" -personality = "pragmatic" -default_permissions = "workspace-net" -approval_policy = "on-request" -approvals_reviewer = "auto_review" -project_doc_fallback_filenames = ["CLAUDE.md"] - -[features] -network_proxy = true -multi_agent = true -memories = true - -[permissions.workspace-net] -description = "Workspace editing with unrestricted public network access." -extends = ":workspace" - -[permissions.workspace-net.network] -enabled = true -allow_local_binding = false - -[permissions.workspace-net.network.domains] -"*" = "allow" - -[memories] -generate_memories = true -TOML -printf '%s\n' 'live-sentinel = true' >"$TEST_TMP/failure-live.toml" -incomplete_hash="$(hash_file "$TEST_TMP/incomplete.toml")" -failure_live_hash="$(hash_file "$TEST_TMP/failure-live.toml")" -assert_status 1 "$SYNC" "$TEST_TMP/incomplete.toml" "$TEST_TMP/failure-live.toml" -[[ "$incomplete_hash" == "$(hash_file "$TEST_TMP/incomplete.toml")" ]] -[[ "$failure_live_hash" == "$(hash_file "$TEST_TMP/failure-live.toml")" ]] - -# Missing model or Plan-mode defaults must also leave both files unchanged. -for key in model plan_mode_reasoning_effort; do - sed "/^$key = /d" "$TEST_TMP/portable.toml" >"$TEST_TMP/missing-key.toml" - missing_key_hash="$(hash_file "$TEST_TMP/missing-key.toml")" - assert_status 1 "$SYNC" "$TEST_TMP/missing-key.toml" "$TEST_TMP/failure-live.toml" - [[ "$missing_key_hash" == "$(hash_file "$TEST_TMP/missing-key.toml")" ]] - [[ "$failure_live_hash" == "$(hash_file "$TEST_TMP/failure-live.toml")" ]] -done - -# A legacy-only baseline must fail closed instead of silently retaining an old -# sandbox_mode that would bypass the selected permission profile. -cat >"$TEST_TMP/legacy-only.toml" <<'TOML' -model = "gpt-6-astra" -model_reasoning_effort = "high" -plan_mode_reasoning_effort = "high" -personality = "pragmatic" -sandbox_mode = "workspace-write" -approval_policy = "on-request" -approvals_reviewer = "auto_review" -project_doc_fallback_filenames = ["CLAUDE.md"] - -[sandbox_workspace_write] -network_access = true - -[features] -multi_agent = true -memories = true - -[memories] -generate_memories = true -use_memories = true -TOML -legacy_hash="$(hash_file "$TEST_TMP/legacy-only.toml")" -assert_status 1 "$SYNC" "$TEST_TMP/legacy-only.toml" "$TEST_TMP/failure-live.toml" -[[ "$legacy_hash" == "$(hash_file "$TEST_TMP/legacy-only.toml")" ]] -[[ "$failure_live_hash" == "$(hash_file "$TEST_TMP/failure-live.toml")" ]] - -assert_status 1 "$SYNC" "$TEST_TMP/missing.toml" "$TEST_TMP/failure-live.toml" -assert_status 2 "$SYNC" -assert_status 2 "$SYNC" one two three - -echo "codex-config-sync=PASS" diff --git a/tests/config-sync.sh b/tests/config-sync.sh deleted file mode 100755 index b48fbf3..0000000 --- a/tests/config-sync.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# Hermetic: the provisioning exports (DOTFILES_AUTO_UPDATE=0 and the like, -# docs/bootstrap.md) and other dotfiles knobs never reach the code under -# test from the caller; each case sets what it needs. -unset DOTFILES_AUTO_UPDATE DOTFILES_AUTO_STOW DOTFILES_HOST DOTFILES_DIR _DOTFILES_CHECKED \ - DOTFILES_STOW_WITHOUT_OH_MY_ZSH DOTFILES_COLOR AWESOME_SKILLS_AUTO_UPDATE AWESOME_SKILLS_FORCE \ - AWESOME_SKILLS_BG AWESOME_SKILLS_INSTALLER_URL AWESOME_SKILLS_REFRESH_DAYS _AWESOME_SKILLS_CHECKED - -TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -if [[ ! -f "$TEST_DIR/test_config_sync.py" ]]; then - echo 'ERROR: required configuration backend test suite is missing.' >&2 - exit 1 -fi -sync_python=${DOTFILES_SYNC_PYTHON:-$TEST_DIR/../.venv-sync/bin/python} -if [[ ! -x "$sync_python" ]]; then - echo 'ERROR: AI-sync runtime missing; run ./setup-sync.sh explicitly.' >&2 - exit 1 -fi -export DOTFILES_SYNC_PYTHON="$sync_python" -exec "$sync_python" -I -B -m unittest discover -s "$TEST_DIR" -p 'test_config_sync.py' -v diff --git a/tests/config.sh b/tests/config.sh new file mode 100755 index 0000000..6c1847a --- /dev/null +++ b/tests/config.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -euo pipefail + +# Hermetic: a caller's kit configuration, colour choice and XDG directories +# never reach the code under test; each case sets what it needs. +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# Arguments go to unittest, for example -k Loader. +python3 -I -B -m unittest discover -s "$TEST_DIR" -p test_config.py -v "$@" diff --git a/tests/contract.sh b/tests/contract.sh new file mode 100755 index 0000000..9cb5c9c --- /dev/null +++ b/tests/contract.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# The public contract that consumers build on: dotfiles links these paths, +# overlays its real settings on the two test files and gates every bump on the +# three CI job names. Changing any of it is a breaking change. + +set -euo pipefail + +# Hermetic: a caller's kit configuration, colour choice and XDG directories +# never reach the code under test; each case sets what it needs. +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd -P -- "$TEST_DIR/.." && pwd)" + +die() { + printf 'ERROR: contract: %s\n' "$1" >&2 + exit 1 +} + +# Tracked mode and path, as git ls-files -s records them. +LAYOUT='100755 bin/codex-as +100755 bin/claude-as +100644 lib/codex-as/launch.py +100644 lib/codex-as/check.py +100644 lib/codex-as/layout.py +100644 lib/codex-as/handoff.py +100644 lib/claude-as/launch.py +100644 lib/coding-agent-kit/config.py +100644 completions/bash/codex-as +100644 completions/bash/claude-as +100644 completions/zsh/_codex-as +100644 completions/zsh/_claude-as +100644 helpers/statusline.cjs +100644 helpers/notify.cjs +100644 helpers/check-git-hooks.cjs +100755 install.sh +100644 docs/account-launchers.md +100644 docs/codex-as.md +100644 docs/configuration.md +100644 tests/claude-customizations.cjs +100644 tests/fixtures/claude-settings.json' +JOBS='Linux behavior and lint +Windows native PowerShell behavior +macOS system Bash behavior' + +git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "$ROOT is not a Git checkout." +paths=() +while read -r _ path; do + paths+=("$path") +done <<<"$LAYOUT" +tracked="$(git -C "$ROOT" ls-files -s -- "${paths[@]}" | awk -F '\t' '{ split($1, a, " "); print a[1], $2 }' | LC_ALL=C sort)" +if [[ "$tracked" != "$(LC_ALL=C sort <<<"$LAYOUT")" ]]; then + diff -u <(LC_ALL=C sort <<<"$LAYOUT") <(printf '%s\n' "$tracked") >&2 || true + die 'the tracked public paths or their modes differ from the contract (- expected, + tracked).' +fi +while read -r mode path; do + [[ -f "$ROOT/$path" && ! -L "$ROOT/$path" ]] || die "$path is not a regular file in the working tree." + [[ "$mode" == 100644 || -x "$ROOT/$path" ]] || die "$path is not executable in the working tree." +done <<<"$LAYOUT" +for entry in 'bin/codex-as #!/bin/bash' 'bin/claude-as #!/bin/sh' 'install.sh #!/bin/sh'; do + [[ "$(head -n 1 "$ROOT/${entry%% *}")" == "${entry#* }" ]] || die "${entry%% *} must start with ${entry#* }." +done + +# The kit tracks no symlinks: a checkout must work where links are unsupported. +links="$(git -C "$ROOT" ls-files -s | awk -F '\t' '$1 ~ /^120000 / { print $2 }')" +[[ -z "$links" ]] || die "tracked symlinks: $(tr '\n' ' ' <<<"$links")" + +# Every job of ci.yml has a name, and the names are exactly the three above. +workflow="$ROOT/.github/workflows/ci.yml" +[[ -f "$workflow" ]] || die '.github/workflows/ci.yml is missing.' +listing="$(awk ' + /^jobs:[ \t]*$/ { jobs = 1; next } + jobs && /^[^ \t#]/ { jobs = 0 } + jobs && /^ [A-Za-z0-9_-]+:[ \t]*$/ { if (job) print "UNNAMED " job; job = $1; next } + jobs && job && /^ name:/ { sub(/^ name:[ \t]*/, ""); gsub(/^["'\'']|["'\''][ \t]*$/, ""); print; job = "" } + END { if (job) print "UNNAMED " job } +' "$workflow" | LC_ALL=C sort)" +if [[ "$listing" != "$JOBS" ]]; then + diff -u <(printf '%s\n' "$JOBS") <(printf '%s\n' "$listing") >&2 || true + die 'ci.yml job names differ from the contract (- expected, + found).' +fi + +printf 'contract=PASS\n' diff --git a/tests/fixtures/codex-config.toml b/tests/fixtures/codex-config.toml deleted file mode 100644 index 7720710..0000000 --- a/tests/fixtures/codex-config.toml +++ /dev/null @@ -1,28 +0,0 @@ -model = "gpt-6-astra" -model_reasoning_effort = "xhigh" -plan_mode_reasoning_effort = "xhigh" -personality = "pragmatic" -default_permissions = "workspace-net" -approval_policy = "on-request" -approvals_reviewer = "auto_review" -project_doc_fallback_filenames = ["CLAUDE.md"] - -[features] -network_proxy = true -multi_agent = true -memories = true - -[permissions.workspace-net] -description = "Workspace editing with unrestricted public network access." -extends = ":workspace" - -[permissions.workspace-net.network] -enabled = true -allow_local_binding = false - -[permissions.workspace-net.network.domains] -"*" = "allow" - -[memories] -generate_memories = true -use_memories = true diff --git a/tests/fixtures/portable.rules b/tests/fixtures/portable.rules deleted file mode 100644 index 38a6ac9..0000000 --- a/tests/fixtures/portable.rules +++ /dev/null @@ -1,180 +0,0 @@ -# Cross-host guardrails. Host-generated approvals remain in default.rules. -# Routine workspace execution, simple file deletion, empty-directory removal, -# and moves stay low-friction. Destructive commands are reviewable rather than -# forbidden; the configured automatic reviewer handles these prompts. - -prefix_rule( - pattern = [ - ["git", "git.exe", "/bin/git", "/usr/bin/git"], - "clean", - ], - decision = "prompt", - justification = "git clean can irreversibly delete untracked work and requires automatic review.", - match = ["git clean -fd"], - not_match = [ - "git status --short", - "git reset --hard HEAD~1", - "git diff", - ], -) - -prefix_rule( - pattern = [ - ["git", "git.exe", "/bin/git", "/usr/bin/git"], - "reset", - "--hard", - ], - decision = "prompt", - justification = "git reset --hard can discard worktree and index state and requires automatic review.", - match = ["git reset --hard HEAD~1"], - not_match = [ - "git reset --soft HEAD~1", - "git reset HEAD README.md", - "git status --short", - "git clean -fd", - "git diff", - ], -) - -prefix_rule( - pattern = [ - [ - "rm", - "/bin/rm", - "/usr/bin/rm", - ], - [ - "-r", - "-R", - "-rf", - "-fr", - "-rv", - "-vr", - "-rfv", - "-rvf", - "-frv", - "-fvr", - "-vrf", - "-vfr", - "--recursive", - ], - ], - decision = "prompt", - justification = "Recursive deletion can remove a large tree and requires automatic review.", - match = [ - "rm -rf build", - "/usr/bin/rm --recursive generated", - ], - not_match = [ - "rm artifact.txt", - "rm -f artifact.txt", - "rmdir empty-directory", - "mv source.txt destination.txt", - ], -) - -prefix_rule( - pattern = [ - ["rm", "/bin/rm", "/usr/bin/rm"], - ["-f", "--force"], - ["-r", "-R", "--recursive"], - ], - decision = "prompt", - justification = "Forced recursive deletion can remove a large tree and requires automatic review.", - match = [ - "rm -f -r build", - "rm --force --recursive generated", - ], - not_match = ["rm -f artifact.txt"], -) - -prefix_rule( - pattern = [ - [ - "Remove-Item", - "remove-item", - "rm", - "ri", - ], - ["-Recurse", "-recurse"], - ], - decision = "prompt", - justification = "Recursive PowerShell deletion requires automatic review.", - match = ["Remove-Item -Recurse build"], - not_match = ["Remove-Item artifact.txt"], -) - -prefix_rule( - pattern = [ - ["Remove-Item", "remove-item", "rm", "ri"], - ["-Force", "-force"], - ["-Recurse", "-recurse"], - ], - decision = "prompt", - justification = "Forced recursive PowerShell deletion requires automatic review.", - match = ["Remove-Item -Force -Recurse build"], - not_match = ["Remove-Item -Force artifact.txt"], -) - -prefix_rule( - pattern = [ - ["del", "erase", "rd", "rmdir"], - ["/s", "/S"], - ], - decision = "prompt", - justification = "Recursive Windows deletion requires automatic review.", - match = [ - "rd /s build", - "del /s generated", - ], - not_match = ["rmdir empty-directory"], -) - -prefix_rule( - pattern = [ - [ - "shred", - "/usr/bin/shred", - "truncate", - "/usr/bin/truncate", - "dd", - "/bin/dd", - "/usr/bin/dd", - "mkfs", - "mkfs.ext2", - "mkfs.ext3", - "mkfs.ext4", - "mkfs.xfs", - "mkfs.btrfs", - "mkswap", - "wipefs", - "fdisk", - "parted", - "diskpart", - ], - ], - decision = "prompt", - justification = "Irreversible overwrite or disk-management commands require automatic review and may also require explicit user authorization.", - match = [ - "shred secret.txt", - "truncate -s 0 results.csv", - "dd if=image.raw of=device.img", - "mkfs /dev/example", - ], - not_match = [ - "cp source.txt destination.txt", - "mv source.txt /tmp/source.txt", - ], -) - -prefix_rule( - pattern = [["sudo", "/usr/bin/sudo", "su", "/bin/su", "doas"]], - decision = "prompt", - justification = "Privilege escalation always requires review.", - match = [ - "sudo apt-get update", - "su root", - "doas pkg_add package", - ], - not_match = ["id"], -) diff --git a/tests/inspect_codex_hooks.py b/tests/inspect_codex_hooks.py deleted file mode 100644 index 10db001..0000000 --- a/tests/inspect_codex_hooks.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Explicit offline hooks/list probe with an isolated Codex home; no model call.""" -import argparse -import json -import os -from pathlib import Path -import queue -import subprocess -import threading -import time - - -def inspect(home, cwd, executable="codex"): - home, cwd = Path(home).resolve(), Path(cwd).resolve() - if home == Path.home() / ".codex" or not (home / "config.toml").is_file(): - raise ValueError("explicit disposable Codex home with config.toml required") - process = subprocess.Popen([executable, "app-server", "--strict-config"], stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=dict(os.environ, CODEX_HOME=str(home))) - messages = queue.Queue() - - def read(): - for line in process.stdout: - messages.put(json.loads(line)) - - reader = threading.Thread(target=read, daemon=True) - reader.start() - requests = [{"id": 1, "method": "initialize", "params": { - "clientInfo": {"name": "dotfiles-hook-inspection", "version": "1"}, - "capabilities": {"experimentalApi": True}}}, {"method": "initialized"}, - {"id": 2, "method": "hooks/list", "params": {"cwds": [str(cwd)]}}] - try: - for request in requests: - process.stdin.write(json.dumps(request) + "\n") - process.stdin.flush() - deadline = time.monotonic() + 15 - while time.monotonic() < deadline: - try: - response = messages.get(timeout=max(.01, deadline - time.monotonic())) - except queue.Empty: - break - if response.get("id") == 2: - if "error" in response: - raise ValueError(f"hooks/list rejected the configuration: {response['error']}") - return response["result"] - raise ValueError("hooks/list response was unavailable before the deadline") - finally: - process.terminate() - process.wait(timeout=5) - reader.join(timeout=1) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--home", type=Path, required=True) - parser.add_argument("--cwd", type=Path, required=True) - parser.add_argument("--codex", default="codex") - parser.add_argument("--command", help="report only hooks registered with this exact command") - args = parser.parse_args() - result = inspect(args.home, args.cwd, args.codex) - summary = [] - for entry in result["data"]: - summary.append({"errors": entry["errors"], "warnings": entry["warnings"], "hooks": [ - {key: hook.get(key) for key in ("command", "commandWindows", "matcher", "timeoutSec", "enabled", "trustStatus", "currentHash")} - for hook in entry["hooks"] if args.command is None or hook.get("command") == args.command]}) - print(json.dumps(summary, indent=2)) diff --git a/tests/run.ps1 b/tests/run.ps1 index 2f86872..5beebf5 100644 --- a/tests/run.ps1 +++ b/tests/run.ps1 @@ -6,13 +6,10 @@ param([switch]$CI, [switch]$CheckPrerequisites) $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false -# Hermetic: the provisioning exports (DOTFILES_AUTO_UPDATE=0 and the like, -# dotfiles docs/bootstrap.md) and other dotfiles knobs never reach the suites -# from the caller; each case sets what it needs. The same list as tests/*.sh. -foreach ($knob in @('DOTFILES_AUTO_UPDATE', 'DOTFILES_AUTO_STOW', 'DOTFILES_HOST', 'DOTFILES_DIR', - '_DOTFILES_CHECKED', 'DOTFILES_STOW_WITHOUT_OH_MY_ZSH', 'DOTFILES_COLOR', - 'AWESOME_SKILLS_AUTO_UPDATE', 'AWESOME_SKILLS_FORCE', 'AWESOME_SKILLS_BG', - 'AWESOME_SKILLS_INSTALLER_URL', 'AWESOME_SKILLS_REFRESH_DAYS', '_AWESOME_SKILLS_CHECKED')) { +# Hermetic: a caller's kit configuration, colour choice and XDG directories +# never reach the suites; each case sets what it needs. The same names, in +# the same order, as the unset statement of tests/*.sh. +foreach ($knob in @('CODING_AGENT_KIT_CONFIG', 'CODING_AGENT_KIT_COLOR', 'XDG_CONFIG_HOME', 'NO_COLOR')) { Remove-Item -LiteralPath "Env:$knob" -ErrorAction Ignore } @@ -25,28 +22,11 @@ foreach ($dependency in @('git', 'node')) { throw "Required test dependency not found: $dependency" } } -$repoRoot = Split-Path -Parent $PSScriptRoot -$syncPython = if ($env:DOTFILES_SYNC_PYTHON) { $env:DOTFILES_SYNC_PYTHON } - elseif ($IsWindows) { Join-Path $repoRoot '.venv-sync/Scripts/python.exe' } - else { Join-Path $repoRoot '.venv-sync/bin/python' } -if (-not (Test-Path -LiteralPath $syncPython -PathType Leaf)) { - throw 'AI-sync runtime missing; run setup-sync.ps1 explicitly.' -} -$env:DOTFILES_SYNC_PYTHON = $syncPython -& $syncPython -I -B (Join-Path $repoRoot 'lib/config_sync.py') --runtime-check -if ($LASTEXITCODE -ne 0) { throw 'Configuration runtime check failed.' } if ($CheckPrerequisites) { Write-Output 'test-prerequisites=PASS' exit 0 } -Write-Output '==> test_config_sync.py' -if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'test_config_sync.py') -PathType Leaf)) { - throw 'Required configuration backend test suite is missing.' -} -& $syncPython -I -B -m unittest discover -s $PSScriptRoot -p 'test_config_sync.py' -v -if ($LASTEXITCODE -ne 0) { throw 'Configuration backend tests failed.' } - Write-Output '==> claude-customizations.cjs' & node --test (Join-Path $PSScriptRoot 'claude-customizations.cjs') if ($LASTEXITCODE -ne 0) { throw 'Claude customization tests failed.' } diff --git a/tests/run.sh b/tests/run.sh index ad174fb..8d8bb10 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -2,12 +2,9 @@ set -euo pipefail -# Hermetic: the provisioning exports (DOTFILES_AUTO_UPDATE=0 and the like, -# dotfiles docs/bootstrap.md) and other dotfiles knobs never reach the code -# under test from the caller; each case sets what it needs. -unset DOTFILES_AUTO_UPDATE DOTFILES_AUTO_STOW DOTFILES_HOST DOTFILES_DIR _DOTFILES_CHECKED \ - DOTFILES_STOW_WITHOUT_OH_MY_ZSH DOTFILES_COLOR AWESOME_SKILLS_AUTO_UPDATE AWESOME_SKILLS_FORCE \ - AWESOME_SKILLS_BG AWESOME_SKILLS_INSTALLER_URL AWESOME_SKILLS_REFRESH_DAYS _AWESOME_SKILLS_CHECKED +# Hermetic: a caller's kit configuration, colour choice and XDG directories +# never reach the code under test; each case sets what it needs. +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR # GitHub's runners start jobs with SIGPIPE ignored, so a writer into a pipe # whose reader stopped early prints "write error: Broken pipe" there instead @@ -16,16 +13,16 @@ trap '' PIPE TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ci=0 prerequisites_only=0 for argument in "$@"; do case "$argument" in - # CI needs no tool beyond the required set: the real Stow cases - # stay in dotfiles. The option keeps the dotfiles command line. - --ci) ;; + # CI installs zsh, so its launcher and completion cases cannot skip there. + --ci) ci=1 ;; --check-prerequisites) prerequisites_only=1 ;; --help) printf 'Usage: %s [--ci] [--check-prerequisites]\n' "$0" - printf 'CI mode requires nothing more; local optional skips are reported.\n' + printf 'CI mode also requires zsh; local optional skips are reported.\n' exit 0 ;; *) @@ -42,16 +39,13 @@ for dependency in bash sh git python3 node; do missing=1 fi done -sync_python=${DOTFILES_SYNC_PYTHON:-$TEST_DIR/../.venv-sync/bin/python} -if [[ ! -x "$sync_python" ]]; then - printf 'ERROR: AI-sync runtime missing; run ./setup-sync.sh explicitly.\n' >&2 - missing=1 -elif ! "$sync_python" -I -B "$TEST_DIR/../lib/config_sync.py" --runtime-check; then - missing=1 -fi -export DOTFILES_SYNC_PYTHON="$sync_python" if ! command -v zsh >/dev/null 2>&1; then - printf 'SKIP: optional zsh launcher and completion checks (install zsh to enable).\n' + if [[ "$ci" == 1 ]]; then + printf 'ERROR: required test dependency not found: zsh (CI runs the zsh launcher and completion checks).\n' >&2 + missing=1 + else + printf 'SKIP: optional zsh launcher and completion checks (install zsh to enable).\n' + fi fi if ! command -v codex >/dev/null 2>&1; then printf 'SKIP: optional installed-Codex native sharing checks.\n' @@ -66,11 +60,11 @@ node --test "$TEST_DIR/claude-customizations.cjs" tests=( test-entrypoints.sh - config-sync.sh - ai-config-sync.sh + contract.sh + config.sh codex-as.sh account-enrollment.sh - codex-config-sync.sh + standalone-e2e.sh ) for test_name in "${tests[@]}"; do diff --git a/tests/standalone-e2e.sh b/tests/standalone-e2e.sh new file mode 100755 index 0000000..a7e5740 --- /dev/null +++ b/tests/standalone-e2e.sh @@ -0,0 +1,732 @@ +#!/bin/bash +# A standalone install without dotfiles: install.sh under /bin/sh, bash and +# dash in temporary homes, then codex-as, claude-as and their completions +# through the installed links, with codex and claude fakes that only record. + +set -euo pipefail + +# Hermetic: a caller's kit configuration, colour choice and XDG directories +# never reach the code under test; each case sets what it needs. +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd -P -- "$TEST_DIR/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/kit-standalone-e2e.XXXXXX")" +# A failed case can leave a directory of a home read-only or unsearchable. +trap 'chmod -R u+rwx "$TMP" 2>/dev/null; rm -rf "$TMP"' EXIT +TMP="$(cd -P -- "$TMP" && pwd)" + +KIT="$TMP/kit [with] spaces*" +OTHER="$TMP/other checkout" +FAKES="$TMP/fake bin" +ALT="$TMP/alternative tools" +TOOLS="$TMP/tools" +OUT="$TMP/out" +WORK="$TMP/work" +RECORD="$TMP/record" +mkdir "$KIT" "$OTHER" "$FAKES" "$ALT" "$TOOLS" "$OUT" "$WORK" +# The checkout under test, copied: what a clone provides, at a path with spaces +# and pattern characters, so an unquoted path in a pattern shows. +cp -R "$ROOT/bin" "$ROOT/lib" "$ROOT/completions" "$ROOT/helpers" "$ROOT/docs" "$ROOT/install.sh" "$KIT/" +cp -R "$ROOT/bin" "$ROOT/completions" "$OTHER/" +KIT_TREE="$(cd -- "$KIT" && find . -print | LC_ALL=C sort)" + +# The commands under test see only these tools, the fakes and the install's bin. +SHELLS=(/bin/sh bash) +for tool in sh bash python3 dirname readlink mkdir ln rm cksum env dash zsh; do + if command -v "$tool" >/dev/null 2>&1; then + ln -s "$(command -v "$tool")" "$TOOLS/$tool" + elif [[ "$tool" != dash && "$tool" != zsh ]]; then + printf 'ERROR: standalone-e2e needs %s.\n' "$tool" >&2 + exit 1 + fi +done +if [[ -e "$TOOLS/dash" ]]; then + SHELLS+=(dash) +else + printf 'standalone-e2e: dash not installed; install.sh runs under /bin/sh and bash only.\n' +fi +[[ -e "$TOOLS/zsh" ]] || printf 'standalone-e2e: zsh not installed; zsh completion not checked.\n' + +# The fakes print their version, or record argv, CODEX_HOME and a checksum of +# the Claude token; they never touch the network or print a token. +cat >"$FAKES/codex" <<'EOF' +#!/bin/sh +if [ "$*" = --version ]; then + case ${0##*/} in codex*) echo 'codex-cli 0.163.0' ;; *) echo '2.1.296 (Claude Code)' ;; esac + exit 0 +fi +{ + printf 'run=%s\n' "${0##*/}" + for arg in "$@"; do printf 'arg=%s\n' "$arg"; done + printf 'CODEX_HOME=%s\n' "${CODEX_HOME-}" + printf 'token=%s\n' "$(printf %s "${CLAUDE_CODE_OAUTH_TOKEN-}" | cksum)" +} >>"$FAKE_RECORD" +EOF +chmod 755 "$FAKES/codex" +cp "$FAKES/codex" "$FAKES/claude" +cp "$FAKES/codex" "$ALT/codex-alt" +cp "$FAKES/codex" "$ALT/claude-alt" + +# A fresh session without a controlling terminal: a prompt fails instead of +# waiting, and a hang ends after 60 s. +DETACH='import os, signal, sys +pid = os.fork() +if pid == 0: + os.setsid() + os.execvp(sys.argv[1], sys.argv[1:]) +signal.signal(signal.SIGALRM, lambda *_: (os.killpg(pid, signal.SIGKILL), os._exit(124))) +signal.alarm(60) +status = os.waitstatus_to_exitcode(os.waitpid(pid, 0)[1]) +sys.exit(status if status >= 0 else 128 - status)' + +die() { + printf 'ERROR: standalone-e2e: %s\n' "$1" >&2 + exit 1 +} + +# run LABEL [NAME=VALUE...] COMMAND [ARG...]: COMMAND in an empty directory with +# only HOME ($H), PATH, TMPDIR, the record file and the given variables; sets +# rc, and leaves the output in $OUT/LABEL.out and $OUT/LABEL.err. +run() { + local label=$1 + shift + set +e + (cd -- "$WORK" && env -i HOME="$H" PATH="$FAKES:$H/.local/bin:$TOOLS" TMPDIR="$TMP" FAKE_RECORD="$RECORD" \ + "$TOOLS/python3" -I -c "$DETACH" "$TOOLS/env" "$@" >"$OUT/$label.out" 2>"$OUT/$label.err" &2 + else + printf '%s\n' "$2" | diff -u - "$1" >&2 && return + fi + die "unexpected content in ${1#"$OUT"/}" +} + +# expect LABEL RC STDOUT [STDERR]: the status and both outputs of run LABEL. +expect() { + if [[ "$rc" != "$2" ]]; then + cat "$OUT/$1.out" "$OUT/$1.err" >&2 + die "$1: exit $rc, expected $2" + fi + same "$OUT/$1.out" "$3" + same "$OUT/$1.err" "${4-}" +} + +# snapshot DIR: every path below DIR, with link texts. +snapshot() { + (cd -- "$1" && find . -print | LC_ALL=C sort | while IFS= read -r path; do + if [[ -L "$path" ]]; then + printf '%s -> %s\n' "$path" "$(readlink "$path")" + else + printf '%s\n' "$path" + fi + done) +} + +# home NAME: a new HOME ($H) with a private .codex and .secrets and one +# fabricated account for each tool. +home() { + H="$TMP/$1 home" + mkdir -m 700 "$H" "$H/.codex" "$H/.secrets" + seed "$H/.secrets" A work +} + +# seed DIR CODEX-NAME CLAUDE-NAME: fabricated 0600 credentials. +seed() { + ( + umask 077 + printf '{"auth_mode": "chatgpt", "last_refresh": "2026-10-01T00:00:00Z", "tokens": {%s, %s, %s, %s}}\n' \ + '"id_token": "fake.eyJzdWIiOiJlMmUifQ.fake"' '"access_token": "fabricated-access"' \ + '"refresh_token": "fabricated-refresh"' "\"account_id\": \"$2\"" >"$1/codex-$2.json" + printf '%s\n' "sk-ant-oat01-fabricated-e2e-$3" >"$1/claude-$3" + ) +} + +checksum() { + printf %s "sk-ant-oat01-fabricated-e2e-$1" | cksum +} + +# installed PREFIX BASH-DIR [PATH]: what install.sh prints; PATH when PREFIX/bin is not on it. +installed() { + local tool zsh_dir="$1/share/zsh/site-functions" + for tool in codex-as claude-as; do + printf '%s -> %s\n' "$1/bin/$tool" "$KIT/bin/$tool" "$2/$tool" "$KIT/completions/bash/$tool" \ + "$zsh_dir/_$tool" "$KIT/completions/zsh/_$tool" + done + printf '%s\n' 'zsh: unless fpath has it, add this before compinit (with oh-my-zsh, before sourcing it):' \ + " fpath=(\"$zsh_dir\" \$fpath)" 'Claude Code helpers, as commands in settings.json:' \ + " statusLine: node \"$KIT/helpers/statusline.cjs\"" \ + " Notification hook: node \"$KIT/helpers/notify.cjs\"" \ + " PreToolUse hook (Bash): node \"$KIT/helpers/check-git-hooks.cjs\"" + if [[ -n "${3-}" ]]; then + printf 'PATH: add %s\n' "$1/bin" + fi +} + +# refuse LABEL REFUSED KEPT: under every shell, install.sh refuses this home +# ($H) with the REFUSED lines and --uninstall keeps it with KEPT; neither +# changes a path below $H. +refuse() { + local shell before + before="$(snapshot "$H")" + for shell in "${SHELLS[@]}"; do + run "$1-${shell//\//}" "$shell" "$KIT/install.sh" + expect "$1-${shell//\//}" 1 '' "$2 +install.sh: nothing changed; move those paths aside or choose another --prefix." + run "$1-uninstall-${shell//\//}" "$shell" "$KIT/install.sh" --uninstall + expect "$1-uninstall-${shell//\//}" 0 "$3" + [[ "$(snapshot "$H")" == "$before" ]] || die "$1: install.sh under $shell changed a refused home" + done +} + +# offer SHELL TOOL: the account names that SHELL's installed completion offers. +offer() { + if [[ "$1" == bash ]]; then + run "complete-$1-$2" bash --noprofile --norc -c 'source "$1"; COMP_WORDS=("$2" ""); COMP_CWORD=1; "$3" + [ "${#COMPREPLY[@]}" -eq 0 ] || printf "%s\n" "${COMPREPLY[@]}"' \ + bash "$H/.local/share/bash-completion/completions/$2" "$2" "_${2//-/_}" + else + # The fpath line install.sh printed, then autoload as compinit would. + run "complete-$1-$2" zsh -f -c "$(sed -n 's/^ \(fpath=.*\)$/\1/p' "$OUT/install-sh.out")"' + autoload -Uz "_$1" + _describe() { [[ $1 == -t ]] || return 0; shift 3; print -rl -- "${(@P)1}"; } + CURRENT=2; words=("$1" ""); "_$1"' zsh "$2" + fi +} + +# completions CODEX-NAME CLAUDE-NAME: what the installed bash and zsh completions offer. +completions() { + local shell + for shell in bash zsh; do + [[ -e "$TOOLS/$shell" ]] || continue + offer "$shell" codex-as + expect "complete-$shell-codex-as" 0 "$1" + offer "$shell" claude-as + expect "complete-$shell-claude-as" 0 "$2" + done +} + +printf '==> install.sh under %s, run directly, by a relative name and through a link\n' "${SHELLS[*]}" +home default +DEFAULT_LINKS="$(installed "$H/.local" "$H/.local/share/bash-completion/completions")" +run install-sh /bin/sh "$KIT/install.sh" +expect install-sh 0 "$DEFAULT_LINKS" +LINKS="$(snapshot "$H")" +[[ ! -e "$H/.config" ]] || die 'install.sh created a configuration directory' +ln -s "$KIT/install.sh" "$TMP/linked install.sh" +for shell in "${SHELLS[@]}"; do + for label in "$shell" "$shell-linked"; do + script="$KIT/install.sh" + [[ "$label" == "$shell" ]] || script="$TMP/linked install.sh" + run "reinstall-${label//\//}" "$shell" "$script" + expect "reinstall-${label//\//}" 0 "$DEFAULT_LINKS" + [[ "$(snapshot "$H")" == "$LINKS" ]] || die "reinstall under $label changed the links" + done +done +run reinstall-direct "$KIT/install.sh" +expect reinstall-direct 0 "$DEFAULT_LINKS" +run reinstall-relative sh -c 'cd "$1" && exec sh install.sh' sh "$KIT" +expect reinstall-relative 0 "$DEFAULT_LINKS" +# A relative link two levels down, so its text does not resolve from the cwd ($WORK). +mkdir -p "$TMP/rel/link" +ln -s "../../kit [with] spaces*/install.sh" "$TMP/rel/link/install.sh" +for shell in "${SHELLS[@]}"; do + run "reinstall-rel-link-${shell//\//}" "$shell" "$TMP/rel/link/install.sh" + expect "reinstall-rel-link-${shell//\//}" 0 "$DEFAULT_LINKS" +done +# Through a directory alias of the checkout, as /var is /private/var on macOS: +# the links name the physical path, so every spelling of the checkout owns them. +ln -s "$KIT" "$TMP/kit alias" +for shell in "${SHELLS[@]}"; do + run "reinstall-alias-${shell//\//}" "$shell" "$TMP/kit alias/install.sh" + expect "reinstall-alias-${shell//\//}" 0 "$DEFAULT_LINKS" +done +# A chain of links: 40 are followed, 41 are refused. The kernel opens at most +# 40 (32 on macOS), so sh runs the script text with $0 set to the chain. +mkdir "$TMP/chain" +ln -s "$KIT/install.sh" "$TMP/chain/0" +for ((hop = 1; hop <= 40; hop++)); do + ln -s "$((hop - 1))" "$TMP/chain/$hop" +done +run reinstall-40-links /bin/sh -c "$(cat "$KIT/install.sh")" "$TMP/chain/39" +expect reinstall-40-links 0 "$DEFAULT_LINKS" +run reinstall-41-links /bin/sh -c "$(cat "$KIT/install.sh")" "$TMP/chain/40" +expect reinstall-41-links 1 '' 'install.sh: symlink loop (more than 40 links).' +[[ "$(snapshot "$H")" == "$LINKS" ]] || die 'a reinstall changed the links' + +printf '==> codex-as and claude-as through the installed links, default configuration\n' +for tool in codex-as claude-as; do + run "help-$tool" "$tool" --help + [[ "$rc" == 0 && ! -s "$OUT/help-$tool.err" ]] || die "$tool --help failed" + [[ "$(head -n 1 "$OUT/help-$tool.out")" == "Usage: $tool NAME ["* ]] || die "$tool --help usage line" + [[ "$(tail -n 1 "$OUT/help-$tool.out")" == "Details and manual repair: $KIT/docs/account-launchers.md" ]] || + die "$tool --help does not name the kit's documentation" +done +run show-codex codex-as --show-config +expect show-codex 0 "config = $H/.config/coding-agent-kit/config.toml (not found; defaults) +codex = codex ($FAKES/codex) +codex_home = $H/.codex +secrets_dir = $H/.secrets" +run show-claude claude-as --show-config +expect show-claude 0 "config = $H/.config/coding-agent-kit/config.toml (not found; defaults) +claude = claude ($FAKES/claude) +secrets_dir = $H/.secrets" +run list-codex codex-as --list-accounts +expect list-codex 0 A +run list-claude claude-as --list-accounts +expect list-claude 0 work +completions A work +: >"$RECORD" +run launch-codex codex-as A exec hello +expect launch-codex 0 '' +SQLITE="$(sed -n 's/^arg=sqlite_home=//p' "$RECORD")" +same "$RECORD" "run=codex +arg=-c +arg=cli_auth_credentials_store=\"file\" +arg=-c +arg=sqlite_home=$SQLITE +arg=exec +arg=hello +CODEX_HOME=$H/.codex/accounts/A +token=$(printf '' | cksum)" +[[ "$(readlink "$H/.codex/accounts/A/auth.json")" == "$H/.secrets/codex-A.json" ]] || die 'codex-as account link' +: >"$RECORD" +run launch-claude claude-as work -p hello +expect launch-claude 0 '' +same "$RECORD" "run=claude +arg=-p +arg=hello +CODEX_HOME= +token=$(checksum work)" + +printf '==> configuration file overrides: codex, claude, codex_home and secrets_dir\n' +mkdir -m 700 "$H/vault" "$H/alt codex" "$H/.config" "$H/.config/coding-agent-kit" "$TMP/xdg" "$TMP/xdg/coding-agent-kit" +seed "$H/vault" Z vault +( + umask 077 + printf '%s\n' "codex = \"$ALT/codex-alt\"" "claude = \"$ALT/claude-alt\"" 'codex_home = "~/alt codex"' \ + 'secrets_dir = "~/vault"' >"$H/.config/coding-agent-kit/config.toml" + printf '%s\n' 'secrets_dir = "~/vault"' >"$TMP/xdg/coding-agent-kit/config.toml" + printf '%s\n' 'codex_home = "~/alt codex"' >"$TMP/explicit config.toml" +) +run show-codex-file codex-as --show-config +expect show-codex-file 0 "config = $H/.config/coding-agent-kit/config.toml (found) +codex = $ALT/codex-alt ($ALT/codex-alt) +codex_home = $H/alt codex +secrets_dir = $H/vault" +run show-claude-file claude-as --show-config +expect show-claude-file 0 "config = $H/.config/coding-agent-kit/config.toml (found) +claude = $ALT/claude-alt ($ALT/claude-alt) +secrets_dir = $H/vault" +run list-codex-file codex-as --list-accounts +expect list-codex-file 0 Z +run list-claude-file claude-as --list-accounts +expect list-claude-file 0 vault +completions Z vault +: >"$RECORD" +run launch-codex-file codex-as Z exec hello +expect launch-codex-file 0 '' +grep -Fxq 'run=codex-alt' "$RECORD" || die 'codex-as did not run the configured codex' +grep -Fxq "CODEX_HOME=$H/alt codex/accounts/Z" "$RECORD" || die 'codex-as did not use the configured codex_home' +[[ "$(readlink "$H/alt codex/accounts/Z/auth.json")" == "$H/vault/codex-Z.json" ]] || die 'configured account link' +: >"$RECORD" +run launch-claude-file claude-as vault -p hello +expect launch-claude-file 0 '' +same "$RECORD" "run=claude-alt +arg=-p +arg=hello +CODEX_HOME= +token=$(checksum vault)" +# XDG_CONFIG_HOME replaces ~/.config, and CODING_AGENT_KIT_CONFIG both. +run show-codex-xdg XDG_CONFIG_HOME="$TMP/xdg" codex-as --show-config +expect show-codex-xdg 0 "config = $TMP/xdg/coding-agent-kit/config.toml (found) +codex = codex ($FAKES/codex) +codex_home = $H/.codex +secrets_dir = $H/vault" +run list-claude-xdg XDG_CONFIG_HOME="$TMP/xdg" claude-as --list-accounts +expect list-claude-xdg 0 vault +run show-codex-explicit XDG_CONFIG_HOME="$TMP/xdg" CODING_AGENT_KIT_CONFIG="$TMP/explicit config.toml" \ + codex-as --show-config +expect show-codex-explicit 0 "config = $TMP/explicit config.toml (from CODING_AGENT_KIT_CONFIG) +codex = codex ($FAKES/codex) +codex_home = $H/alt codex +secrets_dir = $H/.secrets" +: >"$RECORD" +run launch-codex-explicit CODING_AGENT_KIT_CONFIG="$TMP/explicit config.toml" codex-as A exec hello +expect launch-codex-explicit 0 '' +grep -Fxq 'run=codex' "$RECORD" || die 'codex-as did not run codex from PATH' +grep -Fxq "CODEX_HOME=$H/alt codex/accounts/A" "$RECORD" || die 'CODING_AGENT_KIT_CONFIG was not used' +[[ "$(readlink "$H/alt codex/accounts/A/auth.json")" == "$H/.secrets/codex-A.json" ]] || die 'explicit account link' + +printf '==> --uninstall removes only links into this checkout\n' +rm "$H/.local/share/bash-completion/completions/claude-as" +ln -s "$OTHER/completions/bash/claude-as" "$H/.local/share/bash-completion/completions/claude-as" +FOREIGN="kept $H/.local/share/bash-completion/completions/claude-as: a symlink to $OTHER/completions/bash/claude-as, not an absolute link into $KIT" +for shell in "${SHELLS[@]}"; do + run "install-foreign-${shell//\//}" "$shell" "$KIT/install.sh" + [[ "$rc" == 1 ]] || die "install.sh under $shell replaced a foreign link" + run "uninstall-${shell//\//}" "$shell" "$KIT/install.sh" --uninstall + if [[ "$shell" == "${SHELLS[0]}" ]]; then + expect "uninstall-${shell//\//}" 0 "removed $H/.local/bin/codex-as +removed $H/.local/share/bash-completion/completions/codex-as +removed $H/.local/share/zsh/site-functions/_codex-as +removed $H/.local/bin/claude-as +$FOREIGN +removed $H/.local/share/zsh/site-functions/_claude-as" + else + expect "uninstall-${shell//\//}" 0 "$FOREIGN" + fi +done +[[ "$(find "$H/.local" -type l | wc -l | tr -d ' ')" == 1 ]] || die 'uninstall left a kit link or removed the foreign one' +[[ "$(readlink "$H/.local/share/bash-completion/completions/claude-as")" == "$OTHER/completions/bash/claude-as" ]] || + die 'uninstall changed the foreign link' + +printf '==> refusals: a file, a directory, a link into another checkout, a Stow link into this one\n' +home refusal +mkdir -p "$H/.local/bin" "$H/.local/share/bash-completion/completions/codex-as" \ + "$H/.local/share/zsh/site-functions" "$H/dotfiles/common/codex/.local/bin" +printf 'not a link\n' >"$H/.local/bin/claude-as" +ln -s "$OTHER/completions/zsh/_claude-as" "$H/.local/share/zsh/site-functions/_claude-as" +# A dotfiles host: HOME link (Stow, relative) -> package link -> this checkout. +ln -s "$KIT/bin/codex-as" "$H/dotfiles/common/codex/.local/bin/codex-as" +ln -s ../../dotfiles/common/codex/.local/bin/codex-as "$H/.local/bin/codex-as" +refuse refusal "install.sh: refusing $H/.local/bin/codex-as: a symlink to ../../dotfiles/common/codex/.local/bin/codex-as, not an absolute link into $KIT +install.sh: refusing $H/.local/share/bash-completion/completions/codex-as: a directory, not an absolute link into $KIT +install.sh: refusing $H/.local/bin/claude-as: a file, not an absolute link into $KIT +install.sh: refusing $H/.local/share/zsh/site-functions/_claude-as: a symlink to $OTHER/completions/zsh/_claude-as, not an absolute link into $KIT" \ + "kept $H/.local/bin/codex-as: a symlink to ../../dotfiles/common/codex/.local/bin/codex-as, not an absolute link into $KIT +kept $H/.local/share/bash-completion/completions/codex-as: a directory, not an absolute link into $KIT +kept $H/.local/bin/claude-as: a file, not an absolute link into $KIT +kept $H/.local/share/zsh/site-functions/_claude-as: a symlink to $OTHER/completions/zsh/_claude-as, not an absolute link into $KIT" + +printf '==> refusals: dangling, prefix-sibling, escaping, relative and checkout-alias links\n' +home dangling +mkdir -p "$H/.local/bin" "$H/.local/share/bash-completion/completions" "$H/.local/share/zsh/site-functions" "$KIT-old" +cp -R "$KIT/bin" "$KIT-old/" +# A deleted checkout, a sibling whose path extends this one's, an absolute text +# that leaves this checkout through .., a hand-made relative link into it, and +# a hand-made link through another spelling of its path (the alias above). +ln -s "$TMP/gone/bin/codex-as" "$H/.local/bin/codex-as" +ln -s "$KIT-old/bin/claude-as" "$H/.local/bin/claude-as" +ln -s "$KIT/../other checkout/completions/bash/codex-as" "$H/.local/share/bash-completion/completions/codex-as" +ln -s "../../../../../kit [with] spaces*/completions/zsh/_codex-as" "$H/.local/share/zsh/site-functions/_codex-as" +ln -s "$TMP/kit alias/completions/bash/claude-as" "$H/.local/share/bash-completion/completions/claude-as" +[[ ! -e "$H/.local/bin/codex-as" && -e "$H/.local/bin/claude-as" && -e "$H/.local/share/zsh/site-functions/_codex-as" && + -e "$H/.local/share/bash-completion/completions/codex-as" && -e "$H/.local/share/bash-completion/completions/claude-as" ]] || + die 'the dangling-link fixture' +refuse dangling "install.sh: refusing $H/.local/bin/codex-as: a symlink to $TMP/gone/bin/codex-as, not an absolute link into $KIT +install.sh: refusing $H/.local/share/bash-completion/completions/codex-as: a symlink to $KIT/../other checkout/completions/bash/codex-as, not an absolute link into $KIT +install.sh: refusing $H/.local/share/zsh/site-functions/_codex-as: a symlink to ../../../../../kit [with] spaces*/completions/zsh/_codex-as, not an absolute link into $KIT +install.sh: refusing $H/.local/bin/claude-as: a symlink to $KIT-old/bin/claude-as, not an absolute link into $KIT +install.sh: refusing $H/.local/share/bash-completion/completions/claude-as: a symlink to $TMP/kit alias/completions/bash/claude-as, not an absolute link into $KIT" \ + "kept $H/.local/bin/codex-as: a symlink to $TMP/gone/bin/codex-as, not an absolute link into $KIT +kept $H/.local/share/bash-completion/completions/codex-as: a symlink to $KIT/../other checkout/completions/bash/codex-as, not an absolute link into $KIT +kept $H/.local/share/zsh/site-functions/_codex-as: a symlink to ../../../../../kit [with] spaces*/completions/zsh/_codex-as, not an absolute link into $KIT +kept $H/.local/bin/claude-as: a symlink to $KIT-old/bin/claude-as, not an absolute link into $KIT +kept $H/.local/share/bash-completion/completions/claude-as: a symlink to $TMP/kit alias/completions/bash/claude-as, not an absolute link into $KIT" + +printf '==> refusals: relative directory links on the way, below HOME and below a --prefix outside it\n' +# A home name with pattern characters, so an unquoted HOME in a pattern shows. +home 'relative [x]*' +RELATIVE="$TMP/relative prefix" +# Made by hand, in the form Stow folding writes: ~/.local/share and the +# prefix's bin are relative links to directories beside them. A link of this +# checkout already placed through one is refused too. +mkdir -p "$H/.local" "$H/shared" "$RELATIVE/kit" "$RELATIVE/store/bin" +ln -s ../shared "$H/.local/share" +ln -s ../store/bin "$RELATIVE/kit/bin" +ln -s "$KIT/bin/codex-as" "$RELATIVE/store/bin/codex-as" +FORM='the form Stow folding writes' +BEFORE="$(snapshot "$H")$(snapshot "$RELATIVE")" +for shell in "${SHELLS[@]}"; do + run "relative-${shell//\//}" "$shell" "$KIT/install.sh" --prefix "$RELATIVE/kit" + expect "relative-${shell//\//}" 1 '' "install.sh: refusing $RELATIVE/kit/bin/codex-as: $RELATIVE/kit/bin is a relative link to ../store/bin, $FORM +install.sh: refusing $H/.local/share/bash-completion/completions/codex-as: $H/.local/share is a relative link to ../shared, $FORM +install.sh: refusing $RELATIVE/kit/bin/claude-as: $RELATIVE/kit/bin is a relative link to ../store/bin, $FORM +install.sh: refusing $H/.local/share/bash-completion/completions/claude-as: $H/.local/share is a relative link to ../shared, $FORM +install.sh: nothing changed; move those paths aside or choose another --prefix." + [[ "$(snapshot "$H")$(snapshot "$RELATIVE")" == "$BEFORE" ]] || die "relative: install.sh under $shell placed a link" + # HOME spelled with a trailing slash bounds the walk as without one; the + # prefix's parent lies outside HOME. + run "relative-slash-${shell//\//}" HOME="$H/" XDG_DATA_HOME="$H/.local/share" "$shell" "$KIT/install.sh" --prefix "$TMP/deep/p" + expect "relative-slash-${shell//\//}" 1 '' "install.sh: refusing $H/.local/share/bash-completion/completions/codex-as: $H/.local/share is a relative link to ../shared, $FORM +install.sh: refusing $H/.local/share/bash-completion/completions/claude-as: $H/.local/share is a relative link to ../shared, $FORM +install.sh: nothing changed; move those paths aside or choose another --prefix." + [[ "$(snapshot "$H")$(snapshot "$RELATIVE")" == "$BEFORE" && ! -e "$TMP/deep" ]] || + die "relative-slash: install.sh under $shell placed a link" +done + +printf '==> absolute directory links, HOME a relative link, and relative ones above HOME, are accepted\n' +# HOME itself a relative link, as /home/u -> ../export/u: Stow never folds HOME +# or a directory above it, so neither counts, also with HOME as the prefix. +# Its name has pattern characters, so an unquoted directory in a pattern shows. +home real +ln -s 'real home' "$TMP/rel-link [x]* home" +H="$TMP/rel-link [x]* home" +for shell in "${SHELLS[@]}"; do + for rel_prefix in '' "$H"; do + label="rel-link-${shell//\//}${rel_prefix:+-prefix}" + links="$(installed "${rel_prefix:-$H/.local}" "$H/.local/share/bash-completion/completions" ${rel_prefix:+path})" + run "$label" "$shell" "$KIT/install.sh" ${rel_prefix:+--prefix "$rel_prefix"} + expect "$label" 0 "$links" + run "$label-uninstall" "$shell" "$KIT/install.sh" --uninstall ${rel_prefix:+--prefix "$rel_prefix"} + expect "$label-uninstall" 0 "$(sed -n 's/^\(.*\) -> .*$/removed \1/p' <<<"$links")" + [[ -z "$(find "$TMP/real home" -type l)" ]] || die "$label: --uninstall left a link" + done +done +# A home below a relative system link, as /var is private/var on macOS and +# /home is var/home on Fedora Atomic, whose ~/.local is an absolute link. +mkdir -p "$TMP/system/private/var" "$TMP/local store" +ln -s private/var "$TMP/system/var" +home system/var/alias +ln -s "$TMP/local store" "$H/.local" +ALIAS_LINKS="$(installed "$H/.local" "$H/.local/share/bash-completion/completions")" +for shell in "${SHELLS[@]}"; do + run "alias-${shell//\//}" "$shell" "$KIT/install.sh" + expect "alias-${shell//\//}" 0 "$ALIAS_LINKS" + [[ "$(readlink "$TMP/local store/bin/codex-as")" == "$KIT/bin/codex-as" ]] || die "alias: under $shell no link through ~/.local" + run "alias-uninstall-${shell//\//}" "$shell" "$KIT/install.sh" --uninstall + expect "alias-uninstall-${shell//\//}" 0 "$(sed -n 's/^\(.*\) -> .*$/removed \1/p' <<<"$ALIAS_LINKS")" + # A shared --prefix whose parent lies above that link, and the link itself + # as the prefix: an ancestor of HOME never counts. + for tools in "$TMP/system/tools" "$TMP/system/var"; do + label="alias-${tools##*/}-${shell//\//}" + links="$(installed "$tools" "$H/.local/share/bash-completion/completions" path)" + run "$label" "$shell" "$KIT/install.sh" --prefix "$tools" + expect "$label" 0 "$links" + run "$label-uninstall" "$shell" "$KIT/install.sh" --uninstall --prefix "$tools" + expect "$label-uninstall" 0 "$(sed -n 's/^\(.*\) -> .*$/removed \1/p' <<<"$links")" + done +done +# HOME=/, as a service account has it: the prefix stands in for HOME, so the +# relative link above the prefix's parent is accepted, and a prefix that is +# itself a relative link is refused as for any other HOME. +ROOT_HOME_LINKS="$(installed "$TMP/system/var/p" "$TMP/root completions/completions" path)" +ROOT_HOME_VAR="$TMP/system/var is a relative link to private/var, $FORM" +for shell in "${SHELLS[@]}"; do + label="root-home-${shell//\//}" + run "$label" HOME=/ BASH_COMPLETION_USER_DIR="$TMP/root completions" "$shell" "$KIT/install.sh" --prefix "$TMP/system/var/p" + expect "$label" 0 "$ROOT_HOME_LINKS" + run "$label-uninstall" HOME=/ BASH_COMPLETION_USER_DIR="$TMP/root completions" \ + "$shell" "$KIT/install.sh" --uninstall --prefix "$TMP/system/var/p" + expect "$label-uninstall" 0 "$(sed -n 's/^\(.*\) -> .*$/removed \1/p' <<<"$ROOT_HOME_LINKS")" + BEFORE="$(snapshot "$TMP/system/private")$(snapshot "$TMP/root completions")" + run "$label-relative" HOME=/ BASH_COMPLETION_USER_DIR="$TMP/root completions" "$shell" "$KIT/install.sh" --prefix "$TMP/system/var" + expect "$label-relative" 1 '' "install.sh: refusing $TMP/system/var/bin/codex-as: $ROOT_HOME_VAR +install.sh: refusing $TMP/system/var/share/zsh/site-functions/_codex-as: $ROOT_HOME_VAR +install.sh: refusing $TMP/system/var/bin/claude-as: $ROOT_HOME_VAR +install.sh: refusing $TMP/system/var/share/zsh/site-functions/_claude-as: $ROOT_HOME_VAR +install.sh: nothing changed; move those paths aside or choose another --prefix." + [[ "$(snapshot "$TMP/system/private")$(snapshot "$TMP/root completions")" == "$BEFORE" ]] || + die "$label-relative: install.sh under $shell placed a link" +done + +if command -v stow >/dev/null 2>&1; then + # CI's Linux job fails unless this heading is in the output. + printf '==> refusals: destinations in directories GNU Stow folded into another repository\n' + home folded + # GNU Stow folds the missing ~/.local/bin into the package directory of + # another repository, whose tracked relative link points into this checkout. + mkdir -p "$H/.local" "$H/dots/kitlinks/.local/bin" + ln -s "../../../../../kit [with] spaces*/bin/codex-as" "$H/dots/kitlinks/.local/bin/codex-as" + (cd -- "$WORK" && env -i HOME="$H" PATH="$PATH" stow -d "$H/dots" -t "$H" kitlinks) || die 'stow failed' + [[ "$(readlink "$H/.local/bin")" == ../dots/kitlinks/.local/bin && -e "$H/.local/bin/codex-as" ]] || + die 'stow did not fold ~/.local/bin into the package' + refuse folded "install.sh: refusing $H/.local/bin/codex-as: a symlink to ../../../../../kit [with] spaces*/bin/codex-as, not an absolute link into $KIT +install.sh: refusing $H/.local/bin/claude-as: $H/.local/bin is a relative link to ../dots/kitlinks/.local/bin, $FORM" \ + "kept $H/.local/bin/codex-as: a symlink to ../../../../../kit [with] spaces*/bin/codex-as, not an absolute link into $KIT" + home folded-clean + # A package with no kit link: Stow folds ~/.local/bin and ~/.local/share + # into a Git repository, where a new link would be an untracked file. + mkdir -p "$H/.local" "$H/dots/tools/.local/bin" "$H/dots/tools/.local/share/zsh/site-functions" + printf '#!/bin/sh\n' >"$H/dots/tools/.local/bin/mytool" + printf '#compdef mytool\n' >"$H/dots/tools/.local/share/zsh/site-functions/_mytool" + dots() { (cd -- "$WORK" && env -i HOME="$H" PATH="$PATH" GIT_CONFIG_NOSYSTEM=1 "$@"); } + dots git -c init.defaultBranch=main init -q "$H/dots" || die 'git init failed' + dots git -C "$H/dots" add -A || die 'git add failed' + dots git -C "$H/dots" -c user.name=e2e -c user.email=e2e@example.invalid commit -qm tools || die 'git commit failed' + dots stow -d "$H/dots" -t "$H" tools || die 'stow failed' + [[ "$(readlink "$H/.local/bin")" == ../dots/tools/.local/bin && "$(readlink "$H/.local/share")" == ../dots/tools/.local/share ]] || + die 'stow did not fold ~/.local/bin and ~/.local/share into the package' + FOLD_BIN="$H/.local/bin is a relative link to ../dots/tools/.local/bin, $FORM" + FOLD_SHARE="$H/.local/share is a relative link to ../dots/tools/.local/share, $FORM" + refuse folded-clean "install.sh: refusing $H/.local/bin/codex-as: $FOLD_BIN +install.sh: refusing $H/.local/share/bash-completion/completions/codex-as: $FOLD_SHARE +install.sh: refusing $H/.local/share/zsh/site-functions/_codex-as: $FOLD_SHARE +install.sh: refusing $H/.local/bin/claude-as: $FOLD_BIN +install.sh: refusing $H/.local/share/bash-completion/completions/claude-as: $FOLD_SHARE +install.sh: refusing $H/.local/share/zsh/site-functions/_claude-as: $FOLD_SHARE" '' + [[ -z "$(dots git -C "$H/dots" status --porcelain)" ]] || die 'folded-clean: install.sh wrote into the other repository' +else + printf 'standalone-e2e: GNU Stow not installed; the folded Stow cases are skipped.\n' +fi + +printf '==> links of an older layout of this checkout are replaced and removed\n' +home stale +mkdir -p "$H/.local/bin" "$H/.local/share/zsh/site-functions" +STALE_LINKS="$(installed "$H/.local" "$H/.local/share/bash-completion/completions")" +for shell in "${SHELLS[@]}"; do + # A directory and a file that this checkout no longer has. + ln -s "$KIT/share/zsh/_codex-as" "$H/.local/share/zsh/site-functions/_codex-as" + ln -s "$KIT/bin/old-claude-as" "$H/.local/bin/claude-as" + run "stale-${shell//\//}" "$shell" "$KIT/install.sh" + expect "stale-${shell//\//}" 0 "$STALE_LINKS" + [[ "$(readlink "$H/.local/share/zsh/site-functions/_codex-as")" == "$KIT/completions/zsh/_codex-as" ]] || + die "stale: under $shell the zsh completion link was not replaced" + [[ "$(readlink "$H/.local/bin/claude-as")" == "$KIT/bin/claude-as" ]] || die "stale: under $shell the claude-as link was not replaced" + run "stale-uninstall-${shell//\//}" "$shell" "$KIT/install.sh" --uninstall + expect "stale-uninstall-${shell//\//}" 0 "$(sed -n 's/^\(.*\) -> .*$/removed \1/p' <<<"$STALE_LINKS")" + ln -s "$KIT/share/zsh/_codex-as" "$H/.local/share/zsh/site-functions/_codex-as" + ln -s "$KIT/bin/old-claude-as" "$H/.local/bin/claude-as" + run "stale-uninstall-old-${shell//\//}" "$shell" "$KIT/install.sh" --uninstall + expect "stale-uninstall-old-${shell//\//}" 0 "removed $H/.local/share/zsh/site-functions/_codex-as +removed $H/.local/bin/claude-as" + [[ -z "$(find "$H" -type l)" ]] || die "stale: --uninstall under $shell left a link" +done + +printf '==> refusals: a destination directory that cannot be created or written\n' +home blocked +# A file where the zsh completion directory belongs, writable and executable +# like a directory. +mkdir -p "$H/.local/share/zsh" +printf 'not a directory\n' >"$H/.local/share/zsh/site-functions" +chmod 755 "$H/.local/share/zsh/site-functions" +refuse blocked "install.sh: refusing $H/.local/share/zsh/site-functions/_codex-as: its nearest existing parent $H/.local/share/zsh/site-functions is not a writable directory +install.sh: refusing $H/.local/share/zsh/site-functions/_claude-as: its nearest existing parent $H/.local/share/zsh/site-functions is not a writable directory" '' +home blocked-link +# A dangling link where the zsh completion directory belongs, as a removed +# Stow package can leave one. +mkdir -p "$H/.local/share/zsh" +ln -s "$TMP/gone" "$H/.local/share/zsh/site-functions" +refuse blocked-link "install.sh: refusing $H/.local/share/zsh/site-functions/_codex-as: its nearest existing parent $H/.local/share/zsh/site-functions is not a writable directory +install.sh: refusing $H/.local/share/zsh/site-functions/_claude-as: its nearest existing parent $H/.local/share/zsh/site-functions is not a writable directory" '' +if [[ "$EUID" == 0 ]]; then + printf 'standalone-e2e: running as root; the read-only directory cases are skipped.\n' +else + home readonly-own + # This checkout's links, of an older layout, in a read-only directory: a + # reinstall and --uninstall refuse them before they replace or remove any + # link. + mkdir -p "$H/.local/bin" "$H/.local/share/zsh/site-functions" + for tool in codex-as claude-as; do + ln -s "$KIT/bin/old-$tool" "$H/.local/bin/$tool" + ln -s "$KIT/share/zsh/_$tool" "$H/.local/share/zsh/site-functions/_$tool" + done + chmod 555 "$H/.local/share/zsh/site-functions" + BEFORE="$(snapshot "$H")" + for shell in "${SHELLS[@]}"; do + run "readonly-own-${shell//\//}" "$shell" "$KIT/install.sh" + expect "readonly-own-${shell//\//}" 1 '' "install.sh: refusing $H/.local/share/zsh/site-functions/_codex-as: its nearest existing parent $H/.local/share/zsh/site-functions is not a writable directory +install.sh: refusing $H/.local/share/zsh/site-functions/_claude-as: its nearest existing parent $H/.local/share/zsh/site-functions is not a writable directory +install.sh: nothing changed; move those paths aside or choose another --prefix." + run "readonly-own-uninstall-${shell//\//}" "$shell" "$KIT/install.sh" --uninstall + expect "readonly-own-uninstall-${shell//\//}" 1 '' "install.sh: refusing to remove $H/.local/share/zsh/site-functions/_codex-as: $H/.local/share/zsh/site-functions is not a writable directory +install.sh: refusing to remove $H/.local/share/zsh/site-functions/_claude-as: $H/.local/share/zsh/site-functions is not a writable directory +install.sh: nothing changed; make those directories writable and searchable first." + [[ "$(snapshot "$H")" == "$BEFORE" ]] || die "readonly-own: install.sh under $shell changed a link" + done + chmod 755 "$H/.local/share/zsh/site-functions" + home readonly + # A read-only ~/.local/share, as a stray sudo install can leave it. + mkdir -p "$H/.local/share" + chmod 555 "$H/.local/share" + READONLY="install.sh: refusing $H/.local/share/bash-completion/completions/codex-as: its nearest existing parent $H/.local/share is not a writable directory +install.sh: refusing $H/.local/share/zsh/site-functions/_codex-as: its nearest existing parent $H/.local/share is not a writable directory +install.sh: refusing $H/.local/share/bash-completion/completions/claude-as: its nearest existing parent $H/.local/share is not a writable directory +install.sh: refusing $H/.local/share/zsh/site-functions/_claude-as: its nearest existing parent $H/.local/share is not a writable directory" + refuse readonly "$READONLY" '' + # Writable but not searchable: nothing can be created below it either, and + # --uninstall cannot tell what is there. The snapshot cannot read it, so + # only the absence of ~/.local/bin is checked. + chmod 666 "$H/.local/share" + for shell in "${SHELLS[@]}"; do + run "unsearchable-${shell//\//}" "$shell" "$KIT/install.sh" + expect "unsearchable-${shell//\//}" 1 '' "$READONLY +install.sh: nothing changed; move those paths aside or choose another --prefix." + [[ ! -e "$H/.local/bin" ]] || die "unsearchable: install.sh under $shell placed a link" + run "unsearchable-uninstall-${shell//\//}" "$shell" "$KIT/install.sh" --uninstall + expect "unsearchable-uninstall-${shell//\//}" 1 '' "install.sh: refusing to look for $H/.local/share/bash-completion/completions/codex-as: $H/.local/share is not a searchable directory +install.sh: refusing to look for $H/.local/share/zsh/site-functions/_codex-as: $H/.local/share is not a searchable directory +install.sh: refusing to look for $H/.local/share/bash-completion/completions/claude-as: $H/.local/share is not a searchable directory +install.sh: refusing to look for $H/.local/share/zsh/site-functions/_claude-as: $H/.local/share is not a searchable directory +install.sh: nothing changed; make those directories writable and searchable first." + done + chmod 755 "$H/.local/share" +fi + +printf '==> refusals: an incomplete checkout and a piped install.sh, before any link\n' +home incomplete +INCOMPLETE="$TMP/incomplete kit" +mkdir "$INCOMPLETE" +cp -R "$KIT/completions" "$KIT/helpers" "$KIT/install.sh" "$INCOMPLETE/" +BEFORE="$(snapshot "$H")" +run incomplete /bin/sh "$INCOMPLETE/install.sh" +expect incomplete 1 '' "install.sh: $INCOMPLETE is not a complete coding-agent-kit checkout (missing bin/codex-as)." +# sh --prefix with a space, XDG_DATA_HOME and BASH_COMPLETION_USER_DIR\n' +home prefix +PREFIX="$TMP/pre fix" +for shell in "${SHELLS[@]}"; do + # BASH_COMPLETION_USER_DIR wins over XDG_DATA_HOME. + for user_dir in '' "$TMP/user completions"; do + label="prefix-${shell//\//}${user_dir:+-user}" + bash_dir="${user_dir:-$TMP/data home/bash-completion}/completions" + run "$label" XDG_DATA_HOME="$TMP/data home" ${user_dir:+BASH_COMPLETION_USER_DIR="$user_dir"} \ + "$shell" "$KIT/install.sh" --prefix "$PREFIX" + expect "$label" 0 "$(installed "$PREFIX" "$bash_dir" path)" + run "$label-help" "$PREFIX/bin/codex-as" --help + [[ "$(tail -n 1 "$OUT/$label-help.out")" == "Details and manual repair: $KIT/docs/account-launchers.md" ]] || + die "$label: codex-as --help through the prefix" + run "$label-uninstall" XDG_DATA_HOME="$TMP/data home" ${user_dir:+BASH_COMPLETION_USER_DIR="$user_dir"} \ + "$shell" "$KIT/install.sh" --uninstall --prefix "$PREFIX/" + expect "$label-uninstall" 0 "$(installed "$PREFIX" "$bash_dir" | sed -n 's/^\(.*\) -> .*$/removed \1/p')" + [[ -z "$(find "$PREFIX" "$TMP/data home" "$TMP/user completions" -type l 2>/dev/null)" ]] || + die "$label: --uninstall left a link" + done +done +[[ ! -e "$H/.local" && ! -e "$H/.config" ]] || die '--prefix wrote under HOME' + +printf '==> usage errors\n' +home usage +run usage-help /bin/sh "$KIT/install.sh" --help +[[ "$rc" == 0 && ! -s "$OUT/usage-help.err" ]] || die 'install.sh --help' +[[ "$(head -n 1 "$OUT/usage-help.out")" == 'Usage: install.sh [--prefix DIR] [--uninstall]' ]] || die 'install.sh usage line' +for args in --bogus extra '--uninstall --bogus' --prefix '--prefix relative/dir'; do + # shellcheck disable=SC2086 # each case is a word list + run usage /bin/sh "$KIT/install.sh" $args + [[ "$rc" == 2 && ! -s "$OUT/usage.out" && -s "$OUT/usage.err" ]] || die "install.sh $args: exit $rc, expected 2" +done +run usage-prefix /bin/sh "$KIT/install.sh" --prefix '' +expect usage-prefix 2 '' 'install.sh: --prefix needs an absolute directory.' +run usage-prefix-relative /bin/sh "$KIT/install.sh" --prefix relative/dir +expect usage-prefix-relative 2 '' 'install.sh: --prefix needs an absolute directory.' +run relative-xdg XDG_DATA_HOME=rel /bin/sh "$KIT/install.sh" +expect relative-xdg 1 '' 'install.sh: the bash completion directory rel/bash-completion/completions is not absolute.' +run relative-user-dir BASH_COMPLETION_USER_DIR=rel /bin/sh "$KIT/install.sh" +expect relative-user-dir 1 '' 'install.sh: the bash completion directory rel/completions is not absolute.' +run usage-no-home env -u HOME /bin/sh "$KIT/install.sh" --help +[[ "$rc" == 0 ]] || die 'install.sh --help needs HOME' +run no-home env -u HOME /bin/sh "$KIT/install.sh" --uninstall +expect no-home 1 '' 'install.sh: HOME must be set to an absolute path.' +[[ "$(snapshot "$H")" == "$(printf '.\n./.codex\n./.secrets\n./.secrets/claude-work\n./.secrets/codex-A.json')" ]] || + die 'a usage error wrote to HOME' + +[[ "$(cd -- "$KIT" && find . -print | LC_ALL=C sort)" == "$KIT_TREE" ]] || die 'a run wrote into the checkout' +[[ -z "$(ls -A "$WORK")" ]] || die 'a run wrote into its working directory' +if grep -rl -e sk-ant-oat01 -e fabricated-access "$OUT"; then + die 'an output above contains a fabricated token' +fi +printf 'standalone-e2e=PASS\n' diff --git a/tests/test-entrypoints.sh b/tests/test-entrypoints.sh index 60d1030..d7ec9f6 100755 --- a/tests/test-entrypoints.sh +++ b/tests/test-entrypoints.sh @@ -2,15 +2,12 @@ set -euo pipefail -# Hermetic: the provisioning exports (DOTFILES_AUTO_UPDATE=0 and the like, -# dotfiles docs/bootstrap.md) and other dotfiles knobs never reach the code -# under test from the caller; each case sets what it needs. -unset DOTFILES_AUTO_UPDATE DOTFILES_AUTO_STOW DOTFILES_HOST DOTFILES_DIR _DOTFILES_CHECKED \ - DOTFILES_STOW_WITHOUT_OH_MY_ZSH DOTFILES_COLOR AWESOME_SKILLS_AUTO_UPDATE AWESOME_SKILLS_FORCE \ - AWESOME_SKILLS_BG AWESOME_SKILLS_INSTALLER_URL AWESOME_SKILLS_REFRESH_DAYS _AWESOME_SKILLS_CHECKED +# Hermetic: a caller's kit configuration, colour choice and XDG directories +# never reach the code under test; each case sets what it needs. +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/dotfiles-test-entrypoints.XXXXXX")" +TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/kit-test-entrypoints.XXXXXX")" trap 'rm -rf "$TEST_TMP"' EXIT mkdir "$TEST_TMP/bin" @@ -18,13 +15,33 @@ mkdir "$TEST_TMP/bin" for dependency in bash sh git python3 node dirname; do ln -s "$(command -v "$dependency")" "$TEST_TMP/bin/$dependency" done -for mode in --ci ''; do - PATH="$TEST_TMP/bin" /bin/bash "$TEST_DIR/run.sh" ${mode:+"$mode"} --check-prerequisites \ - >"$TEST_TMP/local.stdout" 2>"$TEST_TMP/local.stderr" - grep -Fq 'SKIP: optional zsh launcher and completion checks' "$TEST_TMP/local.stdout" - grep -Fq 'SKIP: optional installed-Codex native sharing checks' "$TEST_TMP/local.stdout" - grep -Fxq 'test-prerequisites=PASS' "$TEST_TMP/local.stdout" -done +PATH="$TEST_TMP/bin" /bin/bash "$TEST_DIR/run.sh" --check-prerequisites \ + >"$TEST_TMP/local.stdout" 2>"$TEST_TMP/local.stderr" +grep -Fq 'SKIP: optional zsh launcher and completion checks' "$TEST_TMP/local.stdout" +grep -Fq 'SKIP: optional installed-Codex native sharing checks' "$TEST_TMP/local.stdout" +grep -Fxq 'test-prerequisites=PASS' "$TEST_TMP/local.stdout" + +# CI installs zsh, so there its launcher and completion cases cannot skip. +if PATH="$TEST_TMP/bin" /bin/bash "$TEST_DIR/run.sh" --ci --check-prerequisites \ + >"$TEST_TMP/ci.stdout" 2>"$TEST_TMP/ci.stderr"; then + printf 'ERROR: CI tests accepted a missing zsh.\n' >&2 + exit 1 +else + [[ "$?" == 1 ]] +fi +grep -Fq 'required test dependency not found: zsh' "$TEST_TMP/ci.stderr" +if command -v zsh >/dev/null 2>&1; then + ln -s "$(command -v zsh)" "$TEST_TMP/bin/zsh" + PATH="$TEST_TMP/bin" /bin/bash "$TEST_DIR/run.sh" --ci --check-prerequisites \ + >"$TEST_TMP/ci.stdout" 2>"$TEST_TMP/ci.stderr" + if grep -Fq zsh "$TEST_TMP/ci.stdout" "$TEST_TMP/ci.stderr"; then + printf 'ERROR: CI tests reported zsh although it is installed.\n' >&2 + exit 1 + fi + grep -Fq 'SKIP: optional installed-Codex native sharing checks' "$TEST_TMP/ci.stdout" + grep -Fxq 'test-prerequisites=PASS' "$TEST_TMP/ci.stdout" + rm "$TEST_TMP/bin/zsh" +fi rm "$TEST_TMP/bin/node" if PATH="$TEST_TMP/bin" /bin/bash "$TEST_DIR/run.sh" --check-prerequisites \ @@ -42,31 +59,39 @@ else [[ "$?" == 2 ]] fi -# Every entry point drops the same dotfiles knobs, tests/run.ps1 too, and the -# list names every DOTFILES_* and AWESOME_SKILLS_* variable the code can read -# (but DOTFILES_SYNC_PYTHON, which tests/run.sh provisions), so a shell that -# exports DOTFILES_AUTO_UPDATE=0 while provisioning cannot fail a suite. +# Every entry point drops the kit's knobs with the one statement of +# tests/run.sh, tests/run.ps1 the same names in the same order, and the +# statement names every CODING_AGENT_KIT_* variable the code can read, so a +# caller's configuration file or colour choice cannot fail a suite. REPO_ROOT="$(cd -- "$TEST_DIR/.." && pwd)" -# knob_list FILE: the names of FILE's "unset DOTFILES_AUTO_UPDATE ..." statement. -knob_list() { - sed -n '/^unset DOTFILES_AUTO_UPDATE /,/[^\\]$/p' "$1" | tr -d '\\' | tr -s ' ' '\n' | - grep -v -e '^unset$' -e '^$' | LC_ALL=C sort -} -KNOBS=$(knob_list "$TEST_DIR/run.sh") -[[ -n "$KNOBS" ]] || { - printf 'ERROR: tests/run.sh has no unset statement for the dotfiles knobs.\n' >&2 - exit 1 +# knob_statement FILE: FILE's "unset CODING_AGENT_KIT_..." lines. +knob_statement() { + grep -E '^unset CODING_AGENT_KIT_' "$1" || true } +STATEMENT=$(knob_statement "$TEST_DIR/run.sh") +KNOBS=${STATEMENT#unset } +case $KNOBS in + *$'\n'* | '' | *[!A-Z_\ ]*) + printf 'ERROR: tests/run.sh needs one unset statement for the kit knobs.\n' >&2 + exit 1 + ;; +esac +for knob in XDG_CONFIG_HOME NO_COLOR; do + if [[ " $KNOBS " != *" $knob "* ]]; then + printf 'ERROR: tests/run.sh does not unset %s, which the launchers read.\n' "$knob" >&2 + exit 1 + fi +done for test_file in "$TEST_DIR"/*.sh; do - if [[ "$(knob_list "$test_file")" != "$KNOBS" ]]; then - printf 'ERROR: %s does not unset the dotfiles knobs that tests/run.sh does.\n' "${test_file#"$REPO_ROOT"/}" >&2 + if [[ "$(knob_statement "$test_file")" != "$STATEMENT" ]]; then + printf 'ERROR: %s does not carry the kit knob statement of tests/run.sh.\n' "${test_file#"$REPO_ROOT"/}" >&2 exit 1 fi done PS_KNOBS=$(awk '/foreach \(\$knob in @\(/ { on = 1 } on { print } on && /\)\) \{/ { exit }' "$TEST_DIR/run.ps1" | - grep -oE "'[A-Z_]+'" | tr -d "'" | LC_ALL=C sort) -if [[ "$PS_KNOBS" != "$KNOBS" ]]; then - printf 'ERROR: tests/run.ps1 does not remove the dotfiles knobs that tests/run.sh unsets.\n' >&2 + grep -oE "'[A-Z_]+'" | tr -d "'" | tr '\n' ' ') +if [[ "$PS_KNOBS" != "$KNOBS " ]]; then + printf 'ERROR: tests/run.ps1 does not remove the kit knobs that tests/run.sh unsets.\n' >&2 exit 1 fi # The names come from every tracked file outside tests/, docs/, .github/ and @@ -74,30 +99,30 @@ fi # Env:NAME and GetEnvironmentVariable('NAME') in PowerShell, os.environ in # Python. A name embedded in a longer identifier does not count. if ! KNOB_NAMES=$(git -C "$REPO_ROOT" -c grep.lineNumber=false -c grep.column=false \ - grep -I -h -o --no-color -E '(^|[^A-Za-z0-9_])_?(DOTFILES|AWESOME_SKILLS)_[A-Z_]+' \ + grep -I -h -o --no-color -E '(^|[^A-Za-z0-9_])_?CODING_AGENT_KIT_[A-Z_]+' \ -- ':!tests' ':!docs' ':!.github' ':!*.md'); then - printf 'ERROR: git grep found no DOTFILES_* or AWESOME_SKILLS_* name in %s.\n' "$REPO_ROOT" >&2 + printf 'ERROR: git grep found no CODING_AGENT_KIT_* name in %s.\n' "$REPO_ROOT" >&2 exit 1 fi READ_KNOBS=$(printf '%s\n' "$KNOB_NAMES" | sed -E 's/^[^A-Z_]//' | LC_ALL=C sort -u) for knob in $READ_KNOBS; do - case $knob in - DOTFILES_SYNC_PYTHON | DOTFILES_TEST_*) continue ;; - esac - if ! grep -Fxq -- "$knob" <<<"$KNOBS"; then + if [[ " $KNOBS " != *" $knob "* ]]; then printf 'ERROR: tracked code reads %s, which the test entry points do not unset.\n' "$knob" >&2 exit 1 fi done -# The provisioning exports in the caller's shell change nothing. -if ! env DOTFILES_AUTO_UPDATE=0 AWESOME_SKILLS_AUTO_UPDATE=0 DOTFILES_HOST= _DOTFILES_CHECKED=1 \ - AWESOME_SKILLS_FORCE=1 AWESOME_SKILLS_BG=1 _AWESOME_SKILLS_CHECKED=1 \ - /bin/bash "$TEST_DIR/codex-config-sync.sh" >"$TEST_TMP/hermetic.stdout" 2>"$TEST_TMP/hermetic.stderr"; then +# The knobs in the caller's shell change nothing. The fast configuration and +# metadata cases stand in for every suite; they must run a test and pass. +if ! env CODING_AGENT_KIT_CONFIG="$TEST_TMP/missing/config.toml" CODING_AGENT_KIT_COLOR=always \ + XDG_CONFIG_HOME="$TEST_TMP/missing-xdg" NO_COLOR=1 \ + /bin/bash "$TEST_DIR/config.sh" -k Loader -k metadata_queries -k unknown_keys \ + >"$TEST_TMP/hermetic.stdout" 2>"$TEST_TMP/hermetic.stderr"; then cat "$TEST_TMP/hermetic.stdout" "$TEST_TMP/hermetic.stderr" >&2 - printf 'ERROR: tests/codex-config-sync.sh fails under the provisioning exports.\n' >&2 + printf 'ERROR: tests/config.sh fails when the caller exports the kit knobs.\n' >&2 exit 1 fi -grep -Fxq 'codex-config-sync=PASS' "$TEST_TMP/hermetic.stdout" +grep -Eq '^Ran [1-9][0-9]* tests? in ' "$TEST_TMP/hermetic.stderr" +grep -Eq '^OK( \(.*\))?$' "$TEST_TMP/hermetic.stderr" printf 'test-entrypoints=PASS\n' diff --git a/tests/test_account_enrollment.py b/tests/test_account_enrollment.py index 976f839..d01096a 100644 --- a/tests/test_account_enrollment.py +++ b/tests/test_account_enrollment.py @@ -45,7 +45,7 @@ def setUp(self): sys.exit(0) ''' + FAKE) self.env['FAKE_LOGIN_SEED'] = str(self.seed) - claude_source = ROOT / 'common/claude/.local' + claude_source = ROOT shutil.copy2(claude_source / 'bin/claude-as', self.command_bin / 'claude-as') if (claude_source / 'lib/claude-as').exists(): shutil.copytree(claude_source / 'lib/claude-as', self.home / '.local/lib/claude-as') @@ -91,11 +91,9 @@ def setUp(self): ''') claude.chmod(0o700) self.env.update(CLAUDE_FAKE_TOKEN=CLAUDE_TOKEN, FAKE_LOGIN_HOME=str(self.base / 'login-home')) - for tool in ('codex', 'claude'): - package = ROOT / f'common/{tool}' - for subdir in ('.oh-my-zsh/custom/completions', '.local/share/bash-completion/completions'): - if (package / subdir).exists(): - shutil.copytree(package / subdir, self.home / subdir, dirs_exist_ok=True) + for source, subdir in (('completions/zsh', '.oh-my-zsh/custom/completions'), + ('completions/bash', '.local/share/bash-completion/completions')): + shutil.copytree(ROOT / source, self.home / subdir, dirs_exist_ok=True) for name in ('CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY'): self.env.pop(name, None) @@ -186,14 +184,17 @@ def test_prompt_colors_respect_preferences_and_preserve_client_io(self): # would break terminal readability, NO_COLOR, or machine-readable I/O. cases = [ ({}, True), - ({'DOTFILES_COLOR': 'always'}, True), - ({'DOTFILES_COLOR': 'never'}, False), + ({'CODING_AGENT_KIT_COLOR': 'always'}, True), + ({'CODING_AGENT_KIT_COLOR': 'never'}, False), + ({'CODING_AGENT_KIT_COLOR': 'invalid'}, True), ({'NO_COLOR': '1'}, False), - ({'NO_COLOR': '1', 'DOTFILES_COLOR': 'always'}, False), + ({'NO_COLOR': '1', 'CODING_AGENT_KIT_COLOR': 'always'}, False), ({'TERM': 'dumb'}, False), - ({'TERM': 'dumb', 'DOTFILES_COLOR': 'always'}, False), + ({'TERM': 'dumb', 'CODING_AGENT_KIT_COLOR': 'always'}, False), + # The dotfiles-era knob is no longer read. + ({'DOTFILES_COLOR': 'never'}, True), ] - baseline = {'TERM': 'xterm-256color', 'NO_COLOR': '', 'DOTFILES_COLOR': 'auto'} + baseline = {'TERM': 'xterm-256color', 'NO_COLOR': '', 'CODING_AGENT_KIT_COLOR': 'auto'} for tool in ('codex-as', 'claude-as'): for preferences, colored in cases: with self.subTest(tool=tool, preferences=preferences): @@ -202,7 +203,7 @@ def test_prompt_colors_respect_preferences_and_preserve_client_io(self): self.assertNotEqual(code, 0) self.assertEqual(out, '') self.assertEqual('\x1b[' in tty, colored) - forced = env['DOTFILES_COLOR'] == 'always' and not env['NO_COLOR'] and env['TERM'] != 'dumb' + forced = env['CODING_AGENT_KIT_COLOR'] == 'always' and not env['NO_COLOR'] and env['TERM'] != 'dumb' self.assertEqual('\x1b[' in err, forced) self.assertFalse((self.secret / ('codex-color-account.json' if tool == 'codex-as' else 'claude-color-account')).exists()) @@ -216,15 +217,18 @@ def test_prompt_colors_respect_preferences_and_preserve_client_io(self): self.assertNotIn(CLAUDE_TOKEN, tty + out + err) def test_help_and_errors_color_only_their_output_destination(self): - baseline = {'TERM': 'xterm-256color', 'NO_COLOR': '', 'DOTFILES_COLOR': 'auto'} + baseline = {'TERM': 'xterm-256color', 'NO_COLOR': '', 'CODING_AGENT_KIT_COLOR': 'auto'} for tool in ('codex-as', 'claude-as'): for args, style, expected_code in [(['--help'], '36', 0), (['invalid/name'], '31', 2 if tool == 'codex-as' else 1)]: for attached, preferences, colored in [ (False, {}, False), (True, {}, True), - (False, {'DOTFILES_COLOR': 'always'}, True), - (True, {'DOTFILES_COLOR': 'never'}, False), - (True, {'NO_COLOR': '1', 'DOTFILES_COLOR': 'always'}, False), - (True, {'TERM': 'dumb', 'DOTFILES_COLOR': 'always'}, False), + (False, {'CODING_AGENT_KIT_COLOR': 'always'}, True), + (True, {'CODING_AGENT_KIT_COLOR': 'never'}, False), + (True, {'NO_COLOR': '1', 'CODING_AGENT_KIT_COLOR': 'always'}, False), + (True, {'TERM': 'dumb', 'CODING_AGENT_KIT_COLOR': 'always'}, False), + # The dotfiles-era knob is no longer read. + (False, {'DOTFILES_COLOR': 'always'}, False), + (True, {'DOTFILES_COLOR': 'never'}, True), ]: with self.subTest(tool=tool, args=args, attached=attached, preferences=preferences): code, out, err, tty = self.terminal(args, [], baseline | preferences, @@ -241,14 +245,16 @@ def test_help_and_errors_color_only_their_output_destination(self): self.assertEqual(err, '') else: self.assertEqual(out, '') - # Account-list output feeds completion even when color is forced. + # Account-list output feeds completion even when color is forced: the + # launcher's listing and the helper that 0.1 completions still call. helper = self.home / '.local/lib' / tool / ('check.py' if tool == 'codex-as' else 'launch.py') query = 'accounts' if tool == 'codex-as' else '--list-accounts' - result = subprocess.run([sys.executable, '-I', '-B', str(helper), query], - env=self.env | baseline | {'DOTFILES_COLOR': 'always'}, - capture_output=True, text=True, timeout=5) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertNotIn('\x1b', result.stdout + result.stderr) + for command in ([str(self.command_bin / tool), '--list-accounts'], + [sys.executable, '-I', '-B', str(helper), query]): + result = subprocess.run(command, env=self.env | baseline | {'CODING_AGENT_KIT_COLOR': 'always'}, + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn('\x1b', result.stdout + result.stderr) def test_codex_enroll_continues_original_command_and_stdin(self): code, out, err, tty = self.terminal(['work', 'exec', '--json', 'login "two words"'], @@ -334,6 +340,24 @@ def test_claude_existing_token_conflicts_and_safety(self): code, _, _, _ = self.terminal(['work'], [], tool='claude-as') self.assertNotEqual(code, 0) + def test_claude_refuses_names_that_start_with_a_dash(self): + # As in codex-as: a mistyped option is refused, never offered for enrolment. + before = sorted(path.name for path in self.secret.iterdir()) + for args in (['-x'], ['-'], ['-h'], ['--version'], ['-x', '--help'], + ['--list-accounts', 'x'], ['--show-config', 'x']): + with self.subTest(args=args): + self.record.unlink(missing_ok=True) + code, out, err, tty = self.terminal(args, [('[y/N]', 'y')], tool='claude-as') + self.assertEqual((code, out, err), (1, '', 'claude-as: invalid account name ' + '(ASCII letters, digits, _ or -; no leading -).\n')) + self.assertEqual(tty, '') + self.assertFalse(self.record.exists()) + self.assertEqual(sorted(path.name for path in self.secret.iterdir()), before) + # A name may still contain - after its first character. + code, _, _, tty = self.terminal(['work-2'], [('[y/N]', 'n')], tool='claude-as') + self.assertNotEqual(code, 0) + self.assertIn('Account work-2 does not exist.', tty) + def test_claude_wrapped_capture_policy_and_manual_fallback(self): code, _, err, tty = self.terminal(['work'], [('[y/N]', 'y')], {'CLAUDE_VARIANT': 'wrapped'}, tool='claude-as') @@ -376,7 +400,7 @@ def test_completion_lists_safe_names_without_opening_secrets_or_cli(self): self.assertFalse(self.record.exists(), 'completion launched the CLI') def test_capture_never_displays_incomplete_authorization_url(self): - path = ROOT / 'common/claude/.local/lib/claude-as/launch.py' + path = ROOT / 'lib/claude-as/launch.py' spec = importlib.util.spec_from_file_location('claude_capture', path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) diff --git a/tests/test_codex_as.py b/tests/test_codex_as.py index 4a5ec66..b3d0125 100644 --- a/tests/test_codex_as.py +++ b/tests/test_codex_as.py @@ -29,7 +29,7 @@ ROOT = Path(__file__).resolve().parents[1] -SOURCE = ROOT / "common/codex/.local" +SOURCE = ROOT SHELLS = [shutil.which(name) for name in ("bash", "zsh") if shutil.which(name)] TOKENS = ["fabricated-sensitive-access-A", "fabricated-sensitive-access-B", "fabricated-refreshed-A", "fabricated-root-native-access"] @@ -44,6 +44,8 @@ # Read by no supported native release; common in other OpenAI tools. # OPENAI_BASE_URL only routes the user's own API key for tool subprocesses. ENV_UNUSED = ("OPENAI_ORG_ID", "OPENAI_PROJECT_ID", "CODEX_CHATGPT_BASE_URL", "CODEX_CHATGPT_ACCOUNT_ID", "OPENAI_BASE_URL") +# The kit's own knobs: a caller's value never reaches a fixture, which sets what it needs. +KIT_KNOBS = ("CODING_AGENT_KIT_CONFIG", "CODING_AGENT_KIT_COLOR", "XDG_CONFIG_HOME", "NO_COLOR") FAKE = r''' import json, os, pathlib, sys, time, signal if sys.argv[1:] == ["--version"]: @@ -296,6 +298,7 @@ def setUp(self): self.auth = self.codex / "auth.json" self.install = self.home / ".local/lib/codex-as" shutil.copytree(SOURCE / "lib/codex-as", self.install) + shutil.copytree(SOURCE / "lib/coding-agent-kit", self.home / ".local/lib/coding-agent-kit") self.command_bin = self.home / ".local/bin" self.command_bin.mkdir() shutil.copy2(SOURCE / "bin/codex-as", self.command_bin / "codex-as") @@ -335,12 +338,20 @@ def setUp(self): self.root_auth = self.auth.read_bytes() self.shared = self.snapshot() self.env = os.environ.copy() - for name in (*ENV_CONFLICTS, "CODEX_HOME", "CODEX_SQLITE_HOME", "PYTHONPATH", "BASH_ENV", "ENV", "ZDOTDIR"): + for name in (*ENV_CONFLICTS, *KIT_KNOBS, "CODEX_HOME", "CODEX_SQLITE_HOME", "PYTHONPATH", "BASH_ENV", "ENV", "ZDOTDIR"): self.env.pop(name, None) self.record = self.base / "record.json" self.env.update(HOME=str(self.home), PATH=os.pathsep.join((str(self.bin), str(self.command_bin), self.env["PATH"])), FAKE_RECORD=str(self.record)) + def kit_config(self): + """The installed config.py's load() for this fixture's environment.""" + spec = importlib.util.spec_from_file_location('fixture_config', self.home / '.local/lib/coding-agent-kit/config.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + with patch.dict(os.environ, self.env, clear=True): + return module.load() + def write_credential(self, name, data): path = self.secret / f"codex-{name}.json" path.write_text(json.dumps(data)) @@ -666,6 +677,7 @@ def test_picker_scope_arguments(self): spec = importlib.util.spec_from_file_location('scope_test_check', self.install / 'check.py') check = importlib.util.module_from_spec(spec) spec.loader.exec_module(check) + config = self.kit_config() here, uuid_text = Path.cwd(), '12345678-1234-4234-8234-123456789abc' for args, scope in ((['resume'], here), (['resume', '--last'], here), (['resume', '--all'], 'all'), (['resume', '--all', '--last'], 'all'), (['resume', '-C', str(self.cwd)], self.cwd), @@ -678,10 +690,10 @@ def test_picker_scope_arguments(self): (['-i', 'a.png', 'resume'], None), (['resume', '-i', 'shot.png', uuid_text], here), (['exec', '-i', 'a.png', 'resume', uuid_text], None)): with self.subTest(args=args): - self.assertEqual(check.arguments(args).scope, scope) + self.assertEqual(check.arguments(args, config).scope, scope) for args in (['fork', uuid_text], ['exec', 'resume', uuid_text], ['exec', 'fork', uuid_text]): with self.subTest(args=args): - self.assertEqual(check.arguments(args).recover_id, uuid_text) + self.assertEqual(check.arguments(args, config).recover_id, uuid_text) def test_picker_leaves_possibly_running_sessions_and_names_them(self): daemon, ids, attempts, run = self.handoff_fixture() @@ -1193,7 +1205,7 @@ def load(name): with patch.dict(os.environ, self.env, clear=True), \ patch.object(guard, 'terminal', return_value=tty), patch.object(guard, 'confirm'): for account in ('A', 'B'): - layout = storage.Layout(guard, account) + layout = storage.Layout(guard, account, self.kit_config()) self.assertTrue(layout.acquire()) marker = layout.home / 'codex-as.initialized' raw = b'codex-as v2\n' diff --git a/tests/test_codex_sharing_native.py b/tests/test_codex_sharing_native.py index daa1f19..5eee2f3 100644 --- a/tests/test_codex_sharing_native.py +++ b/tests/test_codex_sharing_native.py @@ -29,7 +29,7 @@ from types import SimpleNamespace -HELPERS = Path(__file__).resolve().parents[1] / "common/codex/.local/lib/codex-as" +HELPERS = Path(__file__).resolve().parents[1] / "lib/codex-as" # The fixture config's offline model provider; native history filters by it. PROVIDER = "offline_fixture" @@ -218,7 +218,6 @@ def log_message(self, *args): "HTTP_PROXY": endpoint, "HTTPS_PROXY": endpoint, "ALL_PROXY": endpoint, "http_proxy": endpoint, "https_proxy": endpoint, "all_proxy": endpoint, "NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost", - "DOTFILES_AUTO_UPDATE": "0", "AWESOME_SKILLS_AUTO_UPDATE": "0", } self.servers = [] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..afd1106 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,1164 @@ +"""Kit configuration, launcher location and metadata queries. + +Loader cases run in process. Launcher cases run the real scripts from kit +copies in a temporary HOME, with fake codex and claude that only record. +""" + +import base64 +from contextlib import contextmanager +import errno +import grp +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import pwd +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from test_codex_as import ENV_CONFLICTS, KIT_KNOBS + + +ROOT = Path(__file__).resolve().parents[1] +URL = "https://github.com/FridrichMethod/coding-agent-kit/blob/main/docs/account-launchers.md" +CONFIGURATION = ("Configuration: ${XDG_CONFIG_HOME:-~/.config}/coding-agent-kit/config.toml; " + "--show-config prints the effective values.") +TOOLS = ("codex-as", "claude-as") +CLAUDE_TOKEN = "sk-ant-oat01-" + "fabricated-config-claude-" * 3 +DEFAULTS_FILE = 'codex = "codex"\nclaude = "claude"\ncodex_home = "~/.codex"\nsecrets_dir = "~/.secrets"\n' +# Records argv and environment, never a token; one JSON line per run. +FAKE = r''' +import hashlib, json, os, sys +if sys.argv[1:] == ["--version"]: + print(VERSION) + sys.exit(0) +env = dict(os.environ) +token = env.pop("CLAUDE_CODE_OAUTH_TOKEN", None) +record = {"argv": sys.argv, "env": env, "cwd": os.getcwd(), "umask": os.umask(0o077), + "token": token and hashlib.sha256(token.encode()).hexdigest()} +with open(os.environ["FAKE_RECORD"], "a") as stream: + stream.write(json.dumps(record) + "\n") +print("stdout sentinel") +''' +PACKAGES = ( # dotfiles package path (from common/) and kit path, as dotfiles links them + ("codex/.local/bin/codex-as", "bin/codex-as"), + *((f"codex/.local/lib/codex-as/{name}.py", f"lib/codex-as/{name}.py") + for name in ("launch", "check", "layout", "handoff")), + ("codex/.local/share/bash-completion/completions/codex-as", "completions/bash/codex-as"), + ("codex/.oh-my-zsh/custom/completions/_codex-as", "completions/zsh/_codex-as"), + ("claude/.local/bin/claude-as", "bin/claude-as"), + ("claude/.local/lib/claude-as/launch.py", "lib/claude-as/launch.py"), + ("claude/.local/share/bash-completion/completions/claude-as", "completions/bash/claude-as"), + ("claude/.oh-my-zsh/custom/completions/_claude-as", "completions/zsh/_claude-as"), +) + +spec = importlib.util.spec_from_file_location("kit_config_under_test", ROOT / "lib/coding-agent-kit/config.py") +config = importlib.util.module_from_spec(spec) +spec.loader.exec_module(config) + + +def private_group(gid): + """The documented rule: the user's login group in the password database, named after the user, + with no other members, neither listed nor accounts whose primary group it is.""" + me = pwd.getpwuid(os.getuid()) + try: + group = grp.getgrgid(gid) + except KeyError: + return False + return (gid == me.pw_gid and group.gr_name == me.pw_name and set(group.gr_mem) <= {me.pw_name} + and not any(entry.pw_gid == gid and entry.pw_uid != os.getuid() for entry in pwd.getpwall())) + + +def owned_by(uid, real, only=None): + """A stand-in for os.fstat or os.lstat that reports UID as the owner (of ONLY, when given).""" + def fake(target, *args, **kwargs): + info = real(target, *args, **kwargs) + if only is not None and Path(target) != only: + return info + values = list(info[:10]) + values[4] = uid + return os.stat_result(values) + return fake + + +@contextmanager +def upg(gid, members=(), primary=(), login=None): + """GID as the fixture user's own group, with these listed members and these other uids + whose primary group it is; the user's login group is LOGIN, by default GID.""" + user = pwd.struct_passwd(("fixture-user", "x", os.getuid(), gid if login is None else login, "", "/", "/bin/sh")) + others = [pwd.struct_passwd((f"other-{uid}", "x", uid, gid, "", "/", "/bin/sh")) for uid in primary] + group = grp.struct_group(("fixture-user", "x", gid, list(members))) + with patch.object(config.pwd, "getpwuid", return_value=user), \ + patch.object(config.pwd, "getpwall", return_value=[user, *others]), \ + patch.object(config.grp, "getgrgid", return_value=group): + yield + + +def shared_group(gid): + """A patch that makes GID a shared group with another member.""" + return patch.object(config.grp, "getgrgid", return_value=grp.struct_group(("staff", "x", gid, ["colleague"]))) + + +def install(target, parts=("bin", "lib", "docs")): + """A kit copy: what a checkout provides.""" + for part in parts: + shutil.copytree(ROOT / part, target / part) + return target + + +def write(path, text, mode=0o600): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(text) if isinstance(text, bytes) else path.write_text(text) + path.chmod(mode) + return path + + +def snapshot(top, skip=()): + """Every path below TOP: type, mode, link text and content hash, without following links.""" + result = {} + for directory, names, files in os.walk(top): + names[:] = [name for name in names if os.path.relpath(os.path.join(directory, name), top) not in skip] + for name in (*names, *files): + path = Path(directory, name) + info = path.lstat() + entry = [stat.S_IFMT(info.st_mode), stat.S_IMODE(info.st_mode)] + if path.is_symlink(): + entry.append(os.readlink(path)) + elif stat.S_ISREG(info.st_mode): + entry.append(hashlib.sha256(path.read_bytes()).hexdigest()) + result[str(path.relative_to(top))] = entry + return result + + +class Loader(unittest.TestCase): + def setUp(self): + # The directories the cases create are checked too; keep them independent of the caller's umask. + self.addCleanup(os.umask, os.umask(0o022)) + self.tmp = tempfile.TemporaryDirectory(prefix="kit-config-test-") + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name).resolve() + self.home = self.base / "home with spaces" + self.home.mkdir(mode=0o700) + self.file = self.home / ".config/coding-agent-kit/config.toml" + self.environ = {"HOME": str(self.home), "PATH": os.environ.get("PATH", "/usr/bin:/bin")} + + def load(self, **env): + with patch.dict(os.environ, self.environ | env, clear=True): + return config.load() + + def fails(self, **env): + with self.assertRaises(config.ConfigError) as caught: + self.load(**env) + message = str(caught.exception) + self.assertNotIn("\n", message) + return message + + def test_missing_file_gives_the_defaults(self): + loaded = self.load() + self.assertEqual((loaded.file, loaded.source, loaded.warnings), (self.file, "not found; defaults", ())) + self.assertEqual((loaded.codex, loaded.claude), ("codex", "claude")) + # The same Path spellings as the launchers before configuration: HOME / ".codex". + self.assertEqual(str(loaded.codex_home), str(Path(str(self.home)) / ".codex")) + self.assertEqual(str(loaded.secrets_dir), str(Path(str(self.home)) / ".secrets")) + self.assertEqual(loaded.home, self.home) + + def test_explicit_defaults_equal_no_file(self): + missing = self.load() + for text in (DEFAULTS_FILE, DEFAULTS_FILE.replace('"~/', '"~//').replace('" ', '/" '), + f'codex_home = "{self.home}//.codex/"\nsecrets_dir = "{self.home}/./.secrets"\n'): + with self.subTest(text=text): + write(self.file, text) + found = self.load() + self.assertEqual(found.source, "found") + for key in ("home", "codex", "claude", "codex_home", "secrets_dir", "warnings"): + self.assertEqual(str(getattr(found, key)), str(getattr(missing, key)), key) + + def test_location_precedence(self): + xdg = self.base / "xdg config" + explicit = write(self.base / "explicit.toml", 'codex = "from-explicit"\n') + write(xdg / "coding-agent-kit/config.toml", 'codex = "from-xdg"\n') + write(self.file, 'codex = "from-home"\n') + self.assertEqual(self.load(XDG_CONFIG_HOME=str(xdg)).codex, "from-xdg") + # A relative XDG_CONFIG_HOME is ignored, as the XDG specification says. + for value in ("relative/xdg", ""): + self.assertEqual(self.load(XDG_CONFIG_HOME=value).codex, "from-home") + loaded = self.load(XDG_CONFIG_HOME=str(xdg), CODING_AGENT_KIT_CONFIG=str(explicit)) + self.assertEqual((loaded.codex, loaded.file, loaded.source), + ("from-explicit", explicit, "from CODING_AGENT_KIT_CONFIG")) + self.assertEqual(self.load(CODING_AGENT_KIT_CONFIG="").codex, "from-home") + + def test_explicit_file_must_be_absolute_and_exist(self): + self.assertEqual(self.fails(CODING_AGENT_KIT_CONFIG="relative.toml"), + "relative.toml: CODING_AGENT_KIT_CONFIG must be an absolute path.") + missing = self.base / "missing.toml" + self.assertEqual(self.fails(CODING_AGENT_KIT_CONFIG=str(missing)), + f"{missing}: not found (CODING_AGENT_KIT_CONFIG).") + self.assertEqual(self.fails(CODING_AGENT_KIT_CONFIG=str(self.base)), f"{self.base}: not a regular file.") + + def test_symlinks_resolving_or_dangling(self): + target = write(self.base / "linked target.toml", 'claude = "claude-limited"\n') + self.file.parent.mkdir(parents=True) + self.file.symlink_to(target) + self.assertEqual((self.load().claude, self.load().source), ("claude-limited", "found")) + target.unlink() + self.assertEqual(self.fails(), f"{self.file}: dangling symlink.") + explicit = self.base / "explicit link.toml" + explicit.symlink_to(self.base / "nowhere.toml") + self.assertEqual(self.fails(CODING_AGENT_KIT_CONFIG=str(explicit)), f"{explicit}: dangling symlink.") + # A dangling directory on the way, like a stowed ~/.config whose target is gone. + shutil.rmtree(self.home / ".config") + (self.home / ".config").symlink_to(self.base / "gone", target_is_directory=True) + self.assertEqual(self.fails(), f"{self.file}: dangling symlink {self.home / '.config'}.") + + def test_umask_matrix(self): + for umask in (0o002, 0o022, 0o077): + with self.subTest(umask=oct(umask)): + self.file.unlink(missing_ok=True) + self.file.parent.mkdir(parents=True, exist_ok=True) + previous = os.umask(umask) + try: + self.file.write_text('codex_home = "~/elsewhere"\n') + finally: + os.umask(previous) + info = self.file.stat() + self.assertEqual(stat.S_IMODE(info.st_mode), 0o666 & ~umask) + if info.st_mode & stat.S_IWGRP and not private_group(info.st_gid): + self.assertEqual(self.fails(), f"{self.file}: group-writable, and the group is not your private group.") + else: + self.assertEqual(self.load().codex_home, self.home / "elsewhere") + + def test_group_write_only_for_the_private_group(self): + write(self.file, 'codex = "codex"\n', 0o660) + gid = self.file.stat().st_gid + for name, members, login_gid, accepted in ( + ("fixture-user", [], gid, True), ("fixture-user", ["fixture-user"], gid, True), + ("fixture-user", ["fixture-user", "colleague"], gid, False), ("staff", [], gid, False), + ("fixture-user", [], gid + 1, False), (None, [], gid, False)): + with self.subTest(name=name, members=members, login=login_gid == gid): + group = grp.struct_group((name, "x", gid, members)) if name else None + user = SimpleNamespace(pw_name="fixture-user", pw_gid=login_gid) + with patch.object(config.pwd, "getpwuid", return_value=user), \ + patch.object(config.pwd, "getpwall", return_value=[]), \ + patch.object(config.grp, "getgrgid", return_value=group, side_effect=None if name else KeyError(gid)): + if accepted: + self.assertEqual(self.load().source, "found") + else: + self.assertIn("group-writable", self.fails()) + # newgrp and sg change the real gid, not the login group in the password database, nor who + # is in the group: the login group decides, whatever id -g says in that shell. + for real_gid, login_gid, accepted in ((gid + 1, gid, True), (gid, gid + 1, False)): + with self.subTest(real=real_gid == gid, login=login_gid == gid), upg(gid, login=login_gid), \ + patch.object(config.os, "getgid", return_value=real_gid): + if accepted: + self.assertEqual(self.load().source, "found") + else: + self.assertEqual(self.fails(), f"{self.file}: group-writable, and the group is not your private group.") + # An account whose primary group it is never shows in the member list (OpenSSH checks + # the password database the same way); another entry with your own uid is you. + for primary, accepted in (((os.getuid(),), True), ((os.getuid() + 1,), False)): + with self.subTest(primary=primary), upg(gid, primary=primary): + if accepted: + self.assertEqual(self.load().source, "found") + else: + self.assertEqual(self.fails(), f"{self.file}: group-writable, and the group is not your private group.") + self.file.chmod(0o640) # Group-readable is fine whatever the group. + with shared_group(gid): + self.assertEqual(self.load().source, "found") + + def test_group_write_with_an_access_acl_is_refused(self): + acl = f"{self.file}: group-writable with an access ACL; remove the ACL (setfacl -b) or the group write (chmod g-w)." + write(self.file, 'codex = "codex"\n', 0o664) + gid = self.file.stat().st_gid + # Whatever the host: os.listxattr names the ACL, is missing (macOS) or the file system has no xattrs. + for listing, expected in ((["user.other"], None), (["system.posix_acl_access"], acl), (["system.nfs4_acl"], acl), + (AttributeError("listxattr"), None), (OSError(errno.ENOTSUP, "Not supported"), None), + (OSError(errno.EIO, "Input/output error"), f"{self.file}: cannot be checked (Input/output error).")): + raises = isinstance(listing, Exception) + with self.subTest(listing=listing), upg(gid), \ + patch.object(config.os, "listxattr", create=True, side_effect=listing if raises else None, + return_value=None if raises else listing): + if expected is None: + self.assertEqual(self.load().source, "found") + else: + self.assertEqual(self.fails(), expected) + self.file.chmod(0o644) # Without group write the ACL mask grants no one write access. + with upg(gid), patch.object(config.os, "listxattr", create=True, return_value=["system.posix_acl_access"]): + self.assertEqual(self.load().source, "found") + + @unittest.skipUnless(shutil.which("setfacl") and hasattr(os, "listxattr"), "needs setfacl and os.listxattr") + def test_real_access_acls_are_refused(self): + acl = "group-writable with an access ACL; remove the ACL (setfacl -b) or the group write (chmod g-w)." + write(self.file, 'codex = "codex"\n', 0o644) + gid = self.file.stat().st_gid + + def setfacl(*args): + result = subprocess.run(["setfacl", *map(str, args)], capture_output=True, text=True) + if result.returncode: + self.skipTest("no ACL support here: " + result.stderr.strip()) + + # A named group entry on a 0644 file: the group bits now show the mask, rw. + setfacl("-m", "g:0:rw", self.file) + self.assertTrue(self.file.stat().st_mode & stat.S_IWGRP) + with upg(gid): + self.assertEqual(self.fails(), f"{self.file}: {acl}") + setfacl("-b", self.file) + self.file.chmod(0o664) # the plain user private group file still loads + self.assertEqual(self.load().source, "found") + # A default ACL on the directory gives every new file such an entry, even under umask 022. + self.file.unlink() + setfacl("-d", "-m", "g:0:rwX", self.file.parent) + self.file.write_text('codex = "codex"\n') + self.assertEqual(self.fails(), f"{self.file}: {acl}") + # An access ACL on a directory on the way lets that group replace the file. + setfacl("-k", self.file.parent) + setfacl("-b", self.file) + self.file.chmod(0o644) + setfacl("-m", "g:0:rwx", self.file.parent) + self.assertEqual(self.fails(), f"{self.file}: directory {self.file.parent}: {acl}") + + def test_other_writable_and_foreign_owner_are_refused(self): + write(self.file, 'codex = "codex"\n', 0o606) + self.assertEqual(self.fails(), f"{self.file}: writable by other users.") + self.file.chmod(0o644) + with patch.object(config.os, "fstat", side_effect=owned_by(os.getuid() + 1, os.fstat)): + self.assertEqual(self.fails(), f"{self.file}: owned by another user.") + with patch.object(config.os, "fstat", side_effect=owned_by(0, os.fstat)): + self.assertEqual(self.load().source, "found") + + def test_a_file_in_the_way_means_no_configuration(self): + # No configuration file can exist below a regular file (ENOTDIR): the defaults, as without one. + write(self.home / ".config", "not a directory\n") + self.assertEqual((self.load().source, self.load().codex_home), ("not found; defaults", self.home / ".codex")) + xdg = write(self.base / "xdg file", "not a directory\n") + self.assertEqual(self.load(XDG_CONFIG_HOME=str(xdg)).source, "not found; defaults") + explicit = xdg / "config.toml" + self.assertEqual(self.fails(CODING_AGENT_KIT_CONFIG=str(explicit)), f"{explicit}: not found (CODING_AGENT_KIT_CONFIG).") + + def test_other_open_errors_fail_closed(self): + # A file may be there: never the defaults, which would ignore a configured location. + write(self.file, 'codex_home = "~/elsewhere"\n') + if os.geteuid() != 0: + for locked in (self.file, self.home / ".config"): + with self.subTest(locked=locked.name): + mode = stat.S_IMODE(locked.stat().st_mode) + locked.chmod(0) + try: + self.assertEqual(self.fails(), f"{self.file}: cannot be opened (Permission denied).") + finally: + locked.chmod(mode) + self.file.unlink() + self.file.symlink_to(self.file.name) + self.assertEqual(self.fails(), f"{self.file}: cannot be opened (Too many levels of symbolic links).") + + def test_a_fifo_is_refused_without_blocking(self): + self.file.parent.mkdir(parents=True) + os.mkfifo(self.file) + # A blocking open waits for a writer forever: load in a child with a deadline. + code = ("import importlib.util, sys\n" + "spec = importlib.util.spec_from_file_location('config', sys.argv[1])\n" + "config = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(config)\n" + "try:\n config.load()\nexcept config.ConfigError as error:\n print(error)\n") + result = subprocess.run([sys.executable, "-I", "-B", "-c", code, ROOT / "lib/coding-agent-kit/config.py"], + env=self.environ, capture_output=True, text=True, timeout=10) + self.assertEqual((result.returncode, result.stdout), (0, f"{self.file}: not a regular file.\n"), result.stderr) + + def test_directories_on_the_way_must_not_let_others_replace_the_file(self): + write(self.file, 'codex = "codex"\n') + directory, gid = self.file.parent, self.file.stat().st_gid + shared = f"{self.file}: directory {directory}: group-writable, and the group is not your private group." + for mode, expected in ((0o770, shared), (0o707, f"{self.file}: directory {directory}: writable by other users."), + (0o1777, None), (0o750, None)): + with self.subTest(mode=oct(mode)), shared_group(gid): + directory.chmod(mode) + if expected is None: # sticky like /tmp, or not writable by others + self.assertEqual(self.load().source, "found") + else: + self.assertEqual(self.fails(), expected) + directory.chmod(0o755) + # Up to and including HOME; its parent is not checked for a file below HOME. + for place, checked in ((self.home, True), (self.base, False)): + with self.subTest(place=place.name), shared_group(gid): + mode = stat.S_IMODE(place.stat().st_mode) + place.chmod(0o770) + try: + if checked: + self.assertEqual(self.fails(), shared.replace(f"directory {directory}:", f"directory {place}:")) + else: + self.assertEqual(self.load().source, "found") + finally: + place.chmod(mode) + # Ubuntu: umask 002 and the user private group make 0775 directories and a 0664 file. + for place in (self.home / ".config", directory): + place.chmod(0o775) + self.file.chmod(0o664) + with upg(gid): + self.assertEqual(self.load().source, "found") + with upg(gid, primary=(os.getuid() + 1,)): + self.assertEqual(self.fails(), f"{self.file}: group-writable, and the group is not your private group.") + self.file.chmod(0o644) + with upg(gid, primary=(os.getuid() + 1,)): + self.assertEqual(self.fails(), shared) + # A newgrp or sg shell has another real gid; the login group in the password database still + # decides, for the file and the directories alike. + for mode, refused in ((0o664, f"{self.file}: group-writable, and the group is not your private group."), + (0o644, shared)): + self.file.chmod(mode) + with self.subTest(file=oct(mode)): + with upg(gid), patch.object(config.os, "getgid", return_value=gid + 1): + self.assertEqual(self.load().source, "found") + with upg(gid, login=gid + 1), patch.object(config.os, "getgid", return_value=gid): + self.assertEqual(self.fails(), refused) + # Ownership alone from here: without group write, the host's own groups (macOS users share + # staff) cannot decide the outcome. + for place in (self.home / ".config", directory): + place.chmod(0o755) + with patch.object(config.os, "lstat", side_effect=owned_by(os.getuid() + 1, os.lstat, directory)): + self.assertEqual(self.fails(), f"{self.file}: directory {directory}: owned by another user.") + with patch.object(config.os, "lstat", side_effect=owned_by(0, os.lstat, directory)): + self.assertEqual(self.load().source, "found") + + def test_the_accounts_are_listed_once_per_load(self): + # Listing every account can be slow on a directory service: once per run, not per place. + write(self.file, 'codex = "codex"\n', 0o664) + for place in (self.home / ".config", self.file.parent): + place.chmod(0o775) # Ubuntu: three group-writable places in the user private group + with upg(self.file.stat().st_gid): + for run in (1, 2): + self.assertEqual(self.load().source, "found") + self.assertEqual(config.pwd.getpwall.call_count, run) # and never kept across loads + + def test_a_symlinked_home_stops_the_walk_at_its_resolved_path(self): + # HOME -> groups/lab/u, as with NFS or automount links: the shared directories above the + # real home are not checked, whichever spelling the location uses. + real = self.base / "groups/lab/u" + write(real / ".config/coding-agent-kit/config.toml", 'codex = "from-real-home"\n', 0o644) + real.chmod(0o700) + (self.base / "groups/lab").chmod(0o770) + home = self.base / "home link" + home.symlink_to(real, target_is_directory=True) + with shared_group(real.stat().st_gid): + for env in ({}, {"XDG_CONFIG_HOME": str(real / ".config")}): + with self.subTest(xdg=bool(env)): + self.assertEqual(self.load(HOME=str(home), **env).codex, "from-real-home") + # The HOME link itself is on the way as written, so it must be yours or root's. + with patch.object(config.os, "lstat", side_effect=owned_by(os.getuid() + 1, os.lstat, home)): + self.assertEqual(self.fails(HOME=str(home)), + f"{home}/.config/coding-agent-kit/config.toml: symlink {home}: owned by another user.") + + def test_symlinks_and_resolved_directories_on_the_way(self): + # A stowed file: config.toml -> a checkout you own; the checkout's directories count too. + checkout = self.home / "dotfiles/kit" + target = write(checkout / "config.toml", 'codex = "from-checkout"\n', 0o644) + self.file.parent.mkdir(parents=True) + self.file.symlink_to(os.path.relpath(target, self.file.parent)) + gid = target.stat().st_gid + self.assertEqual(self.load().codex, "from-checkout") + checkout.chmod(0o775) + with upg(gid): + self.assertEqual(self.load().codex, "from-checkout") + with shared_group(gid): + self.assertEqual(self.fails(), f"{self.file}: directory {checkout}: group-writable, and the group is not your private group.") + checkout.chmod(0o755) + # A symlink on the way must be yours or root's: whoever owns it chooses the file. + xdg = self.home / "xdg" + xdg.symlink_to(".config", target_is_directory=True) + for link, env in ((self.file, {}), (xdg, {"XDG_CONFIG_HOME": str(xdg)})): + path = xdg / "coding-agent-kit/config.toml" if env else self.file + with self.subTest(link=link.name): + with patch.object(config.os, "lstat", side_effect=owned_by(os.getuid() + 1, os.lstat, link)): + self.assertEqual(self.fails(**env), f"{path}: symlink {link}: owned by another user.") + with patch.object(config.os, "lstat", side_effect=owned_by(0, os.lstat, link)): + self.assertEqual(self.load(**env).codex, "from-checkout") + # Outside HOME every directory up to / counts, as written and as resolved. + explicit = write(self.base / "shared/sub/explicit.toml", 'codex = "from-explicit"\n') + alias = self.home / "alias" + alias.symlink_to(explicit.parent, target_is_directory=True) + for given in (explicit, alias / explicit.name): + with self.subTest(given=str(given)): + self.assertEqual(self.load(CODING_AGENT_KIT_CONFIG=str(given)).codex, "from-explicit") + (self.base / "shared").chmod(0o770) + try: + with shared_group(gid): + self.assertEqual(self.fails(CODING_AGENT_KIT_CONFIG=str(given)), f"{given}: directory " + f"{self.base / 'shared'}: group-writable, and the group is not your private group.") + finally: + (self.base / "shared").chmod(0o755) + + def test_size_limit_and_encoding(self): + write(self.file, "#" * (64 * 1024 - 1) + "\n") + self.assertEqual(self.load().source, "found") + write(self.file, "#" * (64 * 1024) + "\n") + self.assertEqual(self.fails(), f"{self.file}: larger than 64 KiB.") + write(self.file, b'codex = "\xff"\n') + self.assertEqual(self.fails(), f"{self.file}: not UTF-8 text.") + + def test_syntax_errors_name_the_position_never_the_contents(self): + for text in ('codex = "fabricated-secret-value\n', 'fabricated-secret-value\n', + '[fabricated-secret-value]\n[fabricated-secret-value]\n', 'codex = "\x01fabricated-secret-value"\n'): + with self.subTest(text=text): + write(self.file, text) + message = self.fails() + self.assertRegex(message, r": invalid TOML at (line \d+, column \d+|end of document)\.$") + self.assertNotIn("fabricated", message) + + def test_nesting_and_integers_past_the_parser_limits_are_invalid_toml(self): + # RecursionError and the int digit-limit ValueError, both well under 64 KiB. + for label, text in (("arrays", "x = " + "[" * 5000 + "]" * 5000), ("tables", "x = " + "{a=" * 3000 + "1" + "}" * 3000), + ("integer", "x = " + "7" * 5000)): + with self.subTest(label): + write(self.file, text + "\n") + self.assertRegex(self.fails(), "^" + re.escape(f"{self.file}: invalid TOML") + r"( at line \d+, column \d+)?\.$") + + def test_unknown_keys_warn_and_continue(self): + write(self.file, 'future_key = "x"\n"odd\\u001bkey" = 1\n[future_table]\nsecrets_dir = "~/elsewhere"\n' + 'claude = "claude-limited"\n') + loaded = self.load() + self.assertEqual(loaded.warnings, (f"{self.file}: unknown key future_key ignored", + f'{self.file}: unknown key "odd\\u001bkey" ignored', + f"{self.file}: unknown key future_table ignored")) + # The table swallows the following keys, which TOML assigns to it. + self.assertEqual((loaded.claude, loaded.secrets_dir), ("claude", self.home / ".secrets")) + + def test_value_errors(self): + cases = { + 'codex = 1': "codex: must be a string.", + 'codex_home = ["~/a"]': "codex_home: must be a string.", + '[secrets_dir]': "secrets_dir: must be a string.", + 'claude = ""': "claude: must not be empty.", + 'secrets_dir = ""': "secrets_dir: must not be empty.", + 'codex_home = ".codex"': "codex_home: must be an absolute or ~/ path.", + 'codex_home = "~"': "codex_home: must be an absolute or ~/ path.", + 'secrets_dir = "~other/secrets"': "secrets_dir: must be an absolute or ~/ path.", + 'codex_home = "~/../codex"': "codex_home: must not contain a .. component.", + 'secrets_dir = "/srv/../secrets"': "secrets_dir: must not contain a .. component.", + } + command = "codex: must be a command name ([A-Za-z0-9._+-]+) or an absolute or ~/ path." + for value in ("bin/codex", "./codex", "co dex", "codex;true", "~", "~other/codex", "$HOME/codex"): + cases[f'codex = "{value}"'] = command + cases['claude = "tools/claude"'] = command.replace("codex:", "claude:", 1) + for text, reason in cases.items(): + with self.subTest(text=text): + write(self.file, text + "\n") + self.assertEqual(self.fails(), f"{self.file}: {reason}") + + def test_control_characters_are_refused(self): + # They would reach the terminal raw in --show-config and in launch errors; NUL also breaks paths. + # C1 too: a terminal may act on CSI (U+009B) or NEL (U+0085) as on ESC [ or a line break. + # TOML takes them escaped and raw (the last case). + for key in config.DEFAULTS: + for escape in ("\\u001b]0;x\\u0007", "\\n", "\\t", "\\u0000", "\\u007f", "\\u0080", "\\u0085", + "\\u009b2J", "\\u009f", "\u009b31m"): + with self.subTest(key=key, escape=escape): + write(self.file, f'{key} = "/opt/x{escape}y"\n') + self.assertEqual(self.fails(), f"{self.file}: {key}: must not contain control characters.") + write(self.file, 'claude = "/opt/caf\\u00e9 \\u00a0/claude"\n') # other characters stay allowed + self.assertEqual(self.load().claude, "/opt/caf\u00e9 \u00a0/claude") + + def test_executables_are_names_or_absolute_paths(self): + for value, expected in (("codex-beta.1+x_y", "codex-beta.1+x_y"), ("/opt/codex/bin/codex/", "/opt/codex/bin/codex"), + ("~/bin/codex", str(self.home / "bin/codex")), ("/opt/../codex", "/opt/../codex"), + ("//opt/codex", "/opt/codex"), ("///opt//codex", "/opt/codex")): + with self.subTest(value=value): + write(self.file, f'codex = "{value}"\n') + self.assertEqual(self.load().codex, expected) + + def test_codex_home_and_secrets_dir_never_nest(self): + for codex_home, secrets_dir, reason in ( + ("~/a", "~/a/", "secrets_dir: must differ from codex_home."), + ("~/a", "~/a/secrets", "secrets_dir: must not be inside codex_home."), + ("~/", "~/.secrets", "secrets_dir: must not be inside codex_home."), + ("~/s/codex", "~/s", "codex_home: must not be inside secrets_dir."), + ("/", "~/.secrets", "secrets_dir: must not be inside codex_home.")): + with self.subTest(codex_home=codex_home, secrets_dir=secrets_dir): + write(self.file, f'codex_home = "{codex_home}"\nsecrets_dir = "{secrets_dir}"\n') + self.assertEqual(self.fails(), f"{self.file}: {reason}") + write(self.file, 'codex_home = "~/a"\nsecrets_dir = "~/ab"\n') # a prefix, not a parent + self.assertEqual(self.load().secrets_dir, self.home / "ab") + + def test_a_leading_double_slash_is_the_root(self): + # pathlib keeps exactly two leading slashes as their own root; Linux and macOS read them as /. + for codex_home, secrets_dir, reason in ( + (f"/{self.home}/.codex", "~/.codex/secrets", "secrets_dir: must not be inside codex_home."), + ("~/.codex", f"/{self.home}/.codex", "secrets_dir: must differ from codex_home."), + ("~/.codex", f"//{self.home}/.codex/accounts", "secrets_dir: must not be inside codex_home."), + (f"/{self.home}/s/codex", "~/s", "codex_home: must not be inside secrets_dir.")): + with self.subTest(codex_home=codex_home, secrets_dir=secrets_dir): + write(self.file, f'codex_home = "{codex_home}"\nsecrets_dir = "{secrets_dir}"\n') + self.assertEqual(self.fails(), f"{self.file}: {reason}") + write(self.file, 'codex_home = "//srv/x/"\nsecrets_dir = "///srv//y"\n') + loaded = self.load() + self.assertEqual((str(loaded.codex_home), str(loaded.secrets_dir)), ("/srv/x", "/srv/y")) + # A HOME spelled with // keeps its spelling, as before configuration, and is still compared as /. + write(self.file, f'secrets_dir = "{self.home}/.codex"\n') + self.assertEqual(self.fails(HOME="/" + str(self.home)), f"/{self.file}: secrets_dir: must differ from codex_home.") + self.file.unlink() + self.assertEqual(str(self.load(HOME="/" + str(self.home)).codex_home), f"/{self.home}/.codex") + + def test_home_must_be_absolute(self): + for value in (None, "", "relative/home"): + with self.subTest(home=value): + environ = {key: item for key, item in self.environ.items() if key != "HOME"} + with patch.dict(os.environ, environ | ({} if value is None else {"HOME": value}), clear=True), \ + self.assertRaises(config.ConfigError) as caught: + config.load() + self.assertEqual(str(caught.exception), "HOME must be set to an absolute path.") + + def test_expand_uses_the_loaded_home(self): + loaded = self.load() + self.assertEqual(loaded.expand("~"), self.home) + self.assertEqual(loaded.expand("~/project//x"), self.home / "project/x") + self.assertEqual(loaded.expand("/abs"), Path("/abs")) + self.assertEqual(loaded.expand("relative"), Path("relative")) + + def test_show_lists_each_tools_values(self): + fakes = self.base / "fake bin" + write(fakes / "codex", "#!/bin/sh\n", 0o700) + with patch.dict(os.environ, self.environ | {"PATH": str(fakes)}, clear=True): + loaded = config.load() + self.assertEqual(loaded.show("codex-as").splitlines(), [ + f"config = {self.file} (not found; defaults)", f"codex = codex ({fakes / 'codex'})", + f"codex_home = {self.home / '.codex'}", f"secrets_dir = {self.home / '.secrets'}"]) + self.assertEqual(loaded.show("claude-as").splitlines(), [ + f"config = {self.file} (not found; defaults)", "claude = claude (not found on PATH)", + f"secrets_dir = {self.home / '.secrets'}"]) + write(self.file, f'claude = "{self.base}/missing claude"\n') + self.assertIn(f"claude = {self.base}/missing claude (not an executable file)", config.load().show("claude-as")) + + +class Launchers(unittest.TestCase): + """The real launchers from kit copies, in a temporary HOME with recording fakes.""" + + def setUp(self): + # The loader checks the directories on the way to a configuration file. + self.addCleanup(os.umask, os.umask(0o022)) + self.tmp = tempfile.TemporaryDirectory(prefix="kit-launcher-test-") + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name).resolve() + self.kit = install(self.base / "kit with spaces") + self.home = self.base / "home with spaces" + self.make_home(self.home) + self.fakes = self.base / "fake bin" + for name, version in (("codex", "codex-cli 0.163.0"), ("claude", "2.1.296 (Claude Code)")): + write(self.fakes / name, f"#!{sys.executable}\nVERSION = {version!r}\n" + FAKE, 0o700) + self.record = self.base / "record.jsonl" + self.env = {key: value for key, value in os.environ.items() if not key.startswith(("ANTHROPIC_", "CLAUDE_"))} + for name in (*KIT_KNOBS, *ENV_CONFLICTS, "CODEX_HOME", "CODEX_SQLITE_HOME", "PYTHONPATH", "BASH_ENV", "ENV", "ZDOTDIR"): + self.env.pop(name, None) + self.env.update(HOME=str(self.home), FAKE_RECORD=str(self.record), + PATH=os.pathsep.join((str(self.fakes), self.env.get("PATH", "/usr/bin:/bin")))) + + def make_home(self, home): + home.mkdir(mode=0o700) + (home / ".codex").mkdir(mode=0o700) + write(home / ".codex/config.toml", 'model = "fixture-model"\n') + (home / ".secrets").mkdir(mode=0o700) + self.credentials(home / ".secrets", "A") + write(home / ".secrets/claude-work", CLAUDE_TOKEN + "\n") + + @staticmethod + def credentials(secrets, name): + payload = base64.urlsafe_b64encode(json.dumps({"sub": "fixture-" + name}).encode()).decode().rstrip("=") + write(secrets / f"codex-{name}.json", json.dumps({ + "auth_mode": "chatgpt", "OPENAI_API_KEY": None, "last_refresh": "2026-10-01T00:00:00Z", + "tokens": {"id_token": f"fake.{payload}.fake", "access_token": "fabricated-access-" + name, + "refresh_token": "fabricated-refresh-" + name, "account_id": name}})) + + def linked_home(self, home): + """A dotfiles host: HOME/X -> (Stow, relative) package X -> (relative) kit file; two hops.""" + self.make_home(home) + common = home / "dotfiles/common" + kit = install(common / "codex/coding-agent-kit", ("bin", "lib", "docs", "completions")) + for package, target in PACKAGES: + link = common / package + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(os.path.relpath(kit / target, link.parent)) + stowed = home / package.split("/", 1)[1] + stowed.parent.mkdir(parents=True, exist_ok=True) + stowed.symlink_to(os.path.relpath(link, stowed.parent)) + return kit + + def run_tool(self, command, env=None, cwd=None): + result = subprocess.run([str(part) for part in command], cwd=cwd or self.base, env=self.env | (env or {}), + stdin=subprocess.DEVNULL, text=True, capture_output=True, timeout=20, + start_new_session=True) + for secret in (CLAUDE_TOKEN, "fabricated-access-"): + self.assertNotIn(secret, result.stdout + result.stderr) + return result + + def records(self): + if not self.record.exists(): + return [] + return [json.loads(line) for line in self.record.read_text().splitlines()] + + def configure(self, text, home=None): + return write((home or self.home) / ".config/coding-agent-kit/config.toml", text) + + def assert_resolves(self, command, root, cwd=None, env=None): + tool = next(name for name in TOOLS if name in " ".join(map(str, command))) + result = self.run_tool([*command, "--help"], env, cwd) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines()[-2:], [CONFIGURATION, f"Details and manual repair: {root}/docs/account-launchers.md"]) + result = self.run_tool([*command, "--show-config"], env, cwd) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines()[0], + f"config = {self.home}/.config/coding-agent-kit/config.toml (not found; defaults)") + self.assertIn(f"{'codex' if tool == 'codex-as' else 'claude'} = ", result.stdout) + + def test_help_names_the_kit_documentation_or_the_url(self): + lone = self.base / "lone" + for tool in TOOLS: + with self.subTest(tool=tool): + result = self.run_tool([self.kit / "bin" / tool, "--help"]) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stderr, "") + self.assertTrue(result.stdout.startswith(f"Usage: {tool} NAME [")) + self.assertEqual(result.stdout.splitlines()[-1], + f"Details and manual repair: {self.kit}/docs/account-launchers.md") + self.assertIn(CONFIGURATION, result.stdout) + self.assertIn("/", result.stdout) + self.assertIn("(default ~/.secrets", result.stdout) + self.assertEqual(self.run_tool([self.kit / "bin" / tool]).stdout, result.stdout) + self.assertEqual(self.run_tool([self.kit / "bin" / tool]).returncode, 2) + # A lone copy, a broken kit or a kit without docs answers with the URL. + write(lone / "bin" / tool, (ROOT / "bin" / tool).read_text(), 0o700) + result = self.run_tool([lone / "bin" / tool, "--help"]) + self.assertEqual((result.returncode, result.stdout.splitlines()[-1]), (0, f"Details and manual repair: {URL}")) + loop = self.base / f"{tool} loop" + loop.symlink_to(loop) + shell = "bash" if tool == "codex-as" else "sh" + result = self.run_tool([shell, "-c", (ROOT / "bin" / tool).read_text(), loop, "--help"]) + self.assertEqual((result.returncode, result.stdout.splitlines()[-1], result.stderr), + (0, f"Details and manual repair: {URL}", "")) + self.assertIn("Concurrent accounts", self.run_tool([self.kit / "bin/codex-as", "--help"]).stdout) + + def test_without_python3_help_works_and_launches_fail_once(self): + tools = self.base / "no python" + tools.mkdir() + for name in ("dirname", "readlink"): + (tools / name).symlink_to(shutil.which(name)) + for tool in TOOLS: + with self.subTest(tool=tool): + result = self.run_tool([self.kit / "bin" / tool, "--help"], {"PATH": str(tools)}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines()[-1], + f"Details and manual repair: {self.kit}/docs/account-launchers.md") + # The launchers resolve their root with dirname and readlink first, then need python3. + before = snapshot(self.home) + required = "codex-as: Python 3.11+ is required for local JSON and file checks.\n" + for tool, args, code in (("codex-as", ["A"], 1), ("codex-as", ["--list-accounts"], 1), ("claude-as", ["work"], 127)): + with self.subTest(tool=tool, args=args): + result = self.run_tool([self.kit / "bin" / tool, *args], {"PATH": str(tools)}) + self.assertEqual((result.returncode, result.stdout), (code, ""), result.stderr) + if tool == "codex-as": + self.assertEqual(result.stderr, required) + else: # as in 0.1.0: the shell's own exec error, one line + self.assertRegex(result.stderr, r"\A[^\n]*python3: not found\n\Z") + self.assertEqual(snapshot(self.home), before, "a launch without python3 wrote to HOME") + self.assertEqual(self.records(), []) + + def test_resolution_cases(self): + linked = self.base / "linked home" + dotfiles_kit = self.linked_home(linked) + alias = self.base / "alias dir" + alias.symlink_to(self.base, target_is_directory=True) # like macOS /var -> /private/var + # A second hop through a symlinked ~/.local: its ../.. must be resolved physically (cd -P); + # a logical cd would take them lexically, into the decoy directory below the link. + hop = self.base / "hop" + hop_kit = install(hop / "store/kit") + (hop / "home/dot/bin").mkdir(parents=True) # the decoy + (hop / "home/.local").symlink_to("../store/local", target_is_directory=True) + for top, target in (("store/local/bin", "../../dot/bin"), ("store/dot/bin", "../../kit/bin")): + (hop / top).mkdir(parents=True) + for tool in TOOLS: + (hop / top / tool).symlink_to(f"{target}/{tool}") + elsewhere = self.base / "elsewhere" + elsewhere.mkdir() + interpreters = {"codex-as": [["bash"]], "claude-as": [["sh"], ["/bin/sh"]]} + if shutil.which("dash"): + interpreters["claude-as"].append(["dash"]) + for tool in TOOLS: + bin_dir = self.kit / "bin" + cases = [ + ("direct", [bin_dir / tool], None, None, self.kit), + ("PATH lookup", ["bash", "-c", f'{tool} "$@"', "path"], None, + {"PATH": f"{bin_dir}{os.pathsep}{self.env['PATH']}"}, self.kit), + ("relative", [f"./bin/{tool}"], self.kit, None, self.kit), + ("relative upwards", [f"../kit with spaces/bin/{tool}"], elsewhere, None, self.kit), + ("two-hop links", [linked / ".local/bin" / tool], None, None, dotfiles_kit), + ("symlinked directory", [alias / "kit with spaces/bin" / tool], None, None, self.kit), + ("second hop through a symlinked directory", [hop / "home/.local/bin" / tool], None, None, hop_kit), + ] + for interpreter in interpreters[tool]: + cases.append((f"{interpreter[0]} path", [*interpreter, bin_dir / tool], None, None, self.kit)) + cases.append((f"{interpreter[0]} name in cwd", [*interpreter, tool], bin_dir, None, self.kit)) + cases.append((f"{interpreter[0]} two-hop links", [*interpreter, linked / ".local/bin" / tool], + None, None, dotfiles_kit)) + cases.append((f"{interpreter[0]} second hop through a symlinked directory", + [*interpreter, hop / "home/.local/bin" / tool], None, None, hop_kit)) + for label, command, cwd, env, root in cases: + with self.subTest(tool=tool, case=label): + self.assert_resolves(command, root, cwd, env) + self.assertEqual(self.records(), [], "resolution ran a native CLI") + + def chain(self, name, length, target): + """LENGTH links, each to the next, the last to TARGET; returns the first.""" + links = [self.base / "chain" / f"{name}-{index}" for index in range(length)] + links[0].parent.mkdir(exist_ok=True) + for link, following in zip(links, [*links[1:], target]): + link.symlink_to(following.name if following.parent == link.parent else following) + return links[0] + + def test_symlink_loops_and_unresolvable_paths_fail_with_one_message(self): + # The kernel refuses to open more than 40 links (32 on macOS), so the shell + # runs the script text with $0 set to the chain, as it does for a link + # that changes while the launcher resolves it. + for tool, shell in (("codex-as", "bash"), ("claude-as", "sh")): + text = (self.kit / "bin" / tool).read_text() + loop = f"{tool}: launcher symlink loop (more than 40 links); reinstall the helper.\n" + with self.subTest(tool=tool, case="40 links"): + result = self.run_tool([shell, "-c", text, self.chain(tool + "-40", 40, self.kit / "bin" / tool), + "--show-config"]) + self.assertEqual(result.returncode, 0, result.stderr) + for label, start in (("41 links", self.chain(tool + "-41", 41, self.kit / "bin" / tool)), + ("cycle", self.chain(tool + "-cycle", 2, self.base / "chain" / f"{tool}-cycle-0"))): + with self.subTest(tool=tool, case=label): + result = self.run_tool([shell, "-c", text, start, "--show-config"]) + self.assertEqual((result.returncode, result.stdout, result.stderr), (1, "", loop)) + with self.subTest(tool=tool, case="not on PATH"): + result = self.run_tool([shell, "-c", text, tool + "-missing", "A"]) + self.assertEqual((result.returncode, result.stdout, result.stderr), + (1, "", f"{tool}: cannot find this launcher ({tool}-missing) on PATH.\n")) + if os.geteuid() != 0: + with self.subTest(tool=tool, case="unsearchable directory"): + locked = self.base / f"locked {tool}" + locked.mkdir() + (locked / tool).symlink_to(self.kit / "bin" / tool) + locked.chmod(0) + self.addCleanup(locked.chmod, 0o700) + result = self.run_tool([shell, "-c", text, locked / tool, "A"]) + self.assertEqual((result.returncode, result.stdout), (1, "")) + self.assertEqual(len(result.stderr.splitlines()), 1, result.stderr) + self.assertRegex(result.stderr, rf"^{tool}: cannot resolve the launcher (link|directory) ") + self.assertEqual(self.records(), []) + + def test_incomplete_installs_name_the_root_without_a_traceback(self): + lone = self.base / "lone copy" + for tool in TOOLS: + write(lone / "bin" / tool, (ROOT / "bin" / tool).read_text(), 0o700) + for args in (["--show-config"], ["A"]): + with self.subTest(tool=tool, args=args): + result = self.run_tool([lone / "bin" / tool, *args]) + self.assertEqual((result.returncode, result.stdout, result.stderr), + (1, "", f"{tool}: incomplete install at {lone} (missing lib/{tool}/launch.py)\n")) + self.assertEqual(self.run_tool([lone / "bin/codex-as", "-bad"]).returncode, 2, "the name check comes first") + partial = install(self.base / "partial kit") + shutil.rmtree(partial / "lib/coding-agent-kit") + (partial / "lib/codex-as/layout.py").unlink() + old_listing = ["python3", "-I", "-B", partial / "lib/codex-as/check.py", "accounts"] + for command in ([partial / "bin/codex-as", "--show-config"], [partial / "bin/codex-as", "A", "exec", "x"], + [partial / "bin/claude-as", "--list-accounts"], [partial / "bin/claude-as", "work"], old_listing): + with self.subTest(command=command[-2:]): + result = self.run_tool(command) + tool = "claude-as" if "claude-as" in str(command[0]) else "codex-as" + self.assertEqual((result.returncode, result.stdout, result.stderr), + (1, "", f"{tool}: incomplete install at {partial}\n")) + self.assertEqual(self.records(), []) + + def test_metadata_queries_start_nothing_and_ignore_launch_policy(self): + shutil.rmtree(self.home / ".codex") + before = snapshot(self.home) + # Conflicting variables stop a launch in preflight; the queries never run it. + env = {"OPENAI_API_KEY": "fabricated-access-conflict", "CLAUDE_CODE_OAUTH_TOKEN": "fabricated-access-conflict", + "CODEX_HOME": str(self.base / "elsewhere")} + for tool, names in (("codex-as", "A\n"), ("claude-as", "work\n")): + with self.subTest(tool=tool): + result = self.run_tool([self.kit / "bin" / tool, "--list-accounts"], env) + self.assertEqual((result.returncode, result.stdout, result.stderr), (0, names, "")) + result = self.run_tool([self.kit / "bin" / tool, "--show-config"], env) + self.assertEqual(result.returncode, 0, result.stderr) + key = "codex" if tool == "codex-as" else "claude" + expected = [f"config = {self.home}/.config/coding-agent-kit/config.toml (not found; defaults)", + f"{key} = {key} ({self.fakes / key})"] + if tool == "codex-as": + expected.append(f"codex_home = {self.home}/.codex") + self.assertEqual(result.stdout.splitlines(), [*expected, f"secrets_dir = {self.home}/.secrets"]) + self.assertEqual(snapshot(self.home), before, "a metadata query wrote to HOME") + self.assertEqual(self.records(), [], "a metadata query started a native CLI") + # Only alone: with more arguments the option is an invalid codex-as account name. + for args in (["--list-accounts", "x"], ["--show-config", "--show-config"]): + result = self.run_tool([self.kit / "bin/codex-as", *args]) + self.assertEqual(result.returncode, 2) + self.assertIn("invalid account name", result.stderr) + self.assertEqual(self.run_tool([self.kit / "bin/codex-as", "--list-account"]).returncode, 2) + + def completion(self, shell, tool, home, env=None): + env = (env or {}) | {"HOME": str(home), "PATH": os.pathsep.join((str(home / ".local/bin"), self.env["PATH"]))} + if shell == "bash": + function = "_" + tool.replace("-", "_") + script = (f'source "$HOME/.local/share/bash-completion/completions/{tool}"; COMP_WORDS=({tool} ""); ' + f'COMP_CWORD=1; {function}; [ "${{#COMPREPLY[@]}}" -eq 0 ] || printf "%s\\n" "${{COMPREPLY[@]}}"') + return self.run_tool(["bash", "--noprofile", "--norc", "-c", script], env) + script = ('_describe() { [[ $1 == -t ]] || return 0; shift 2; shift; print -rl -- "${(@P)1}"; }; ' + f'CURRENT=2; words=({tool} ""); source "$HOME/.oh-my-zsh/custom/completions/_{tool}"') + return self.run_tool(["zsh", "-f", "-c", script], env) + + def test_bad_configuration_gives_empty_listings_and_completions(self): + linked = self.base / "linked home" + self.linked_home(linked) + missing = self.base / "missing.toml" + old = {"codex-as": ["python3", "-I", "-B", linked / ".local/lib/codex-as/check.py", "accounts"], + "claude-as": ["python3", "-I", "-B", linked / ".local/lib/claude-as/launch.py", "--list-accounts"]} + shells = [shell for shell in ("bash", "zsh") if shutil.which(shell)] + # Each case names what its one error line must say, so none passes on another's error. + for label, env, prepare, named in ( + ("missing explicit file", {"CODING_AGENT_KIT_CONFIG": str(missing)}, None, str(missing)), + ("invalid value", {}, lambda: self.configure('secrets_dir = "relative"\n', linked), "secrets_dir"), + ("insecure secrets", {}, lambda: ((linked / ".config/coding-agent-kit/config.toml").unlink(), + (linked / ".secrets").chmod(0o755)), "0700")): + if prepare: + prepare() + for tool in TOOLS: + with self.subTest(case=label, tool=tool): + for command in ([linked / ".local/bin" / tool, "--list-accounts"], old[tool]): + result = self.run_tool(command, env | {"HOME": str(linked)}) + self.assertEqual((result.returncode, result.stdout), (1, "")) + self.assertEqual(len(result.stderr.splitlines()), 1, result.stderr) + self.assertTrue(result.stderr.startswith(tool + ": "), result.stderr) + self.assertIn(named, result.stderr) + if label != "insecure secrets": + result = self.run_tool([linked / ".local/bin" / tool, "--show-config"], env | {"HOME": str(linked)}) + self.assertEqual((result.returncode, result.stdout), (1, "")) + for shell in shells: + result = self.completion(shell, tool, linked, env) + # zsh completion functions return 1 for no matches. + self.assertEqual((result.returncode, result.stdout, result.stderr), + (0 if shell == "bash" else 1, "", ""), shell) + self.assertEqual(self.records(), []) + + def test_linked_install_completions_and_old_helpers_follow_the_configuration(self): + linked = self.base / "linked home" + self.linked_home(linked) + shells = [shell for shell in ("bash", "zsh") if shutil.which(shell)] + # Shells opened before an update keep the 0.1 completion functions, which + # call the library files directly through the retained links. + old = {"codex-as": ["python3", "-I", "-B", linked / ".local/lib/codex-as/check.py", "accounts"], + "claude-as": ["python3", "-I", "-B", linked / ".local/lib/claude-as/launch.py", "--list-accounts"]} + self.assertTrue(os.readlink(linked / ".local/lib/codex-as/check.py").startswith("../../../dotfiles/")) + for names, prepare in ((("A", "work"), None), (("Z", "vault"), self.alternate_secrets)): + if prepare: + prepare(linked) + for tool, name in zip(TOOLS, names): + with self.subTest(tool=tool, names=names): + env = {"HOME": str(linked)} + for command in ([linked / ".local/bin" / tool, "--list-accounts"], old[tool]): + result = self.run_tool(command, env) + self.assertEqual((result.returncode, result.stdout, result.stderr), (0, name + "\n", "")) + for shell in shells: + result = self.completion(shell, tool, linked) + self.assertEqual((result.returncode, result.stdout, result.stderr), (0, name + "\n", ""), shell) + self.assertEqual(self.records(), []) + + def alternate_secrets(self, home): + secrets = home / "vault" + secrets.mkdir(mode=0o700) + self.credentials(secrets, "Z") + write(secrets / "claude-vault", CLAUDE_TOKEN + "\n") + self.configure('secrets_dir = "~/vault"\n', home) + + def test_codex_as_uses_the_configured_paths_and_executable(self): + state, vault, tools = self.base / "state", self.base / "vault", self.base / "tools dir" + root, secrets = state / "codex root", vault / "secrets" + for directory in (state, root, vault, secrets): + directory.mkdir(mode=0o700) + self.credentials(secrets, "A") + write(root / "config.toml", 'model = "configured-model"\n') + tools.mkdir() + shutil.copy2(self.fakes / "codex", tools / "my codex") + self.configure(f'codex = "{tools}/my codex"\ncodex_home = "{root}/"\nsecrets_dir = "{secrets}"\n') + native = snapshot(self.home / ".codex") + result = self.run_tool([self.kit / "bin/codex-as", "A", "exec", "prompt"]) + self.assertEqual(result.returncode, 0, result.stderr) + record = self.records()[-1] + self.assertEqual(record["argv"][0], f"{tools}/my codex") + self.assertEqual((record["env"]["CODEX_HOME"], record["env"]["CODEX_SQLITE_HOME"]), + (f"{root}/accounts/A", str(root))) + self.assertEqual(os.readlink(root / "accounts/A/auth.json"), f"{secrets}/codex-A.json") + self.assertEqual(os.readlink(root / "accounts/A/config.toml"), f"{root}/config.toml") + self.assertEqual(snapshot(self.home / ".codex"), native, "the default root was used") + # Rootless commands, an inherited CODEX_HOME and -p follow codex_home. + self.assertEqual(self.run_tool([self.kit / "bin/codex-as", "A", "--help"]).returncode, 0) + self.assertEqual(self.records()[-1]["env"]["CODEX_HOME"], str(root)) + self.assertEqual(self.run_tool([self.kit / "bin/codex-as", "A", "exec", "x"], {"CODEX_HOME": str(root)}).returncode, 0) + count = len(self.records()) + result = self.run_tool([self.kit / "bin/codex-as", "A", "exec", "x"], {"CODEX_HOME": str(self.home / ".codex")}) + self.assertIn("CODEX_HOME conflicts", result.stderr) + write(root / "work.config.toml", 'model = "work"\n') + self.assertEqual(self.run_tool([self.kit / "bin/codex-as", "A", "-p", "work", "exec", "x"]).returncode, 0) + self.assertEqual(os.readlink(root / "accounts/A/work.config.toml"), f"{root}/work.config.toml") + # Preflight reads the configured root's config, not ~/.codex/config.toml. + write(self.home / ".codex/config.toml", 'model_provider = "elsewhere"\n') + self.assertEqual(self.run_tool([self.kit / "bin/codex-as", "A", "exec", "x"]).returncode, 0) + write(root / "config.toml", 'model_provider = "elsewhere"\n') + self.assertIn("another model provider", self.run_tool([self.kit / "bin/codex-as", "A", "exec", "x"]).stderr) + write(root / "config.toml", 'model = "configured-model"\n') + # Both parents must be private to the user. + for parent in (state, vault): + with self.subTest(parent=parent.name): + parent.chmod(0o770) + result = self.run_tool([self.kit / "bin/codex-as", "A", "exec", "x"]) + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stderr, f"codex-as: {parent}: directory is group/other writable; " + "review and restrict it first.\n") + parent.chmod(0o700) + self.assertEqual(len(self.records()), count + 2) + self.configure(f'codex = "{tools}/missing codex"\n') + result = self.run_tool([self.kit / "bin/codex-as", "A", "exec", "x"]) + self.assertEqual(result.stderr, f"codex-as: the configured codex executable {tools}/missing codex was not found.\n") + + def test_claude_as_uses_the_configured_secrets_and_executable(self): + vault, tools = self.base / "vault", self.base / "tools dir" + secrets = vault / "secrets" + for directory in (vault, secrets, tools): + directory.mkdir(mode=0o700) + write(secrets / "claude-work", CLAUDE_TOKEN + "\n") + shutil.copy2(self.fakes / "claude", tools / "claude-limited") + self.configure(f'claude = "{tools}/claude-limited"\nsecrets_dir = "{secrets}"\n') + (self.home / ".secrets/claude-work").unlink() + result = self.run_tool([self.kit / "bin/claude-as", "work", "-p", "hello"]) + self.assertEqual(result.returncode, 0, result.stderr) + record = self.records()[-1] + self.assertEqual(record["argv"], [f"{tools}/claude-limited", "-p", "hello"]) + self.assertEqual(record["token"], hashlib.sha256(CLAUDE_TOKEN.encode()).hexdigest()) + self.assertEqual(self.run_tool([self.kit / "bin/claude-as", "--list-accounts"]).stdout, "work\n") + vault.chmod(0o750) + self.assertEqual(self.run_tool([self.kit / "bin/claude-as", "work"]).stderr, "") + vault.chmod(0o770) + result = self.run_tool([self.kit / "bin/claude-as", "work"]) + self.assertEqual(result.stderr, f"claude-as: {vault} must be an owned, real directory without group/other write access.\n") + vault.chmod(0o700) + secrets.chmod(0o750) + result = self.run_tool([self.kit / "bin/claude-as", "work"]) + self.assertEqual(result.stderr, f"claude-as: {secrets} must be an owned, real directory with permissions 0700.\n") + secrets.chmod(0o700) + self.configure(f'claude = "{tools}/missing claude"\nsecrets_dir = "{secrets}"\n') + result = self.run_tool([self.kit / "bin/claude-as", "work"]) + self.assertEqual(result.stderr, f"claude-as: the configured claude executable {tools}/missing claude was not found.\n") + self.assertEqual(len(self.records()), 2) + + def test_unknown_keys_warn_once_and_the_launch_continues(self): + self.configure('future_key = "x"\n') + warning = f"{{tool}}: {self.home}/.config/coding-agent-kit/config.toml: unknown key future_key ignored\n" + for tool, args in (("codex-as", ["A", "exec", "x"]), ("claude-as", ["work"])): + with self.subTest(tool=tool): + result = self.run_tool([self.kit / "bin" / tool, *args]) + self.assertEqual((result.returncode, result.stdout, result.stderr), + (0, "stdout sentinel\n", warning.format(tool=tool))) + + def test_configuration_problems_print_one_line_without_a_traceback(self): + file = self.configure("") + old = ["python3", "-I", "-B", self.kit / "lib/codex-as/check.py", "accounts"] + commands = (("codex-as", [self.kit / "bin/codex-as", "--show-config"]), + ("codex-as", [self.kit / "bin/codex-as", "--list-accounts"]), + ("codex-as", [self.kit / "bin/codex-as", "A", "exec", "x"]), ("codex-as", old), + ("claude-as", [self.kit / "bin/claude-as", "--show-config"]), + ("claude-as", [self.kit / "bin/claude-as", "work", "-p", "x"])) + for label, text in (("nesting", "x = " + "[" * 5000 + "]" * 5000), ("integer", "x = " + "7" * 5000), + ("control character", 'claude = "/opt/claude\\u001b[2J"')): + self.configure(text + "\n") + reason = "claude: must not contain control characters" if label == "control character" else "invalid TOML" + for tool, command in commands: + with self.subTest(label, command=command[-2:]): + result = self.run_tool(command) + self.assertEqual((result.returncode, result.stdout), (1, ""), result.stderr) + self.assertRegex(result.stderr, "^" + re.escape(f"{tool}: {file}: {reason}") + + r"( at line \d+, column \d+)?\.\n$") + # Errors from elsewhere, here a --settings path of an unknown user, get the generic line too. + file.unlink() + result = self.run_tool([self.kit / "bin/claude-as", "work", "--settings", "~no-such-kit-test-user/x.json"]) + self.assertEqual((result.returncode, result.stdout, result.stderr), + (1, "", "claude-as: missing, unreadable or malformed token/settings; no unsafe repair performed.\n")) + self.assertEqual(self.records(), []) + + def test_no_configuration_below_a_file_and_a_missing_parent_is_named(self): + (self.home / ".config").write_text("not a directory\n") # no configuration file can be there + for tool, args in (("codex-as", ["A", "exec", "x"]), ("claude-as", ["work"])): + with self.subTest(tool=tool): + result = self.run_tool([self.kit / "bin" / tool, *args]) + self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "stdout sentinel\n", "")) + (self.home / ".config").unlink() + count = len(self.records()) + message = "{parent} does not exist; create it, owned by you and not group or other writable.\n" + afile = write(self.home / "afile", "a regular file\n") # below it (ENOTDIR) nothing exists either + for text, cases in (('codex_home = "~/missing root/codex"\n', (("codex-as", ["A", "exec", "x"], "missing root"),)), + ('secrets_dir = "~/missing vault/secrets"\n', (("codex-as", ["A", "exec", "x"], "missing vault"), + ("claude-as", ["work"], "missing vault"))), + ('codex_home = "~/afile/x/.codex"\n', (("codex-as", ["A", "exec", "x"], "afile/x"),)), + ('secrets_dir = "~/afile/x/sec"\n', (("codex-as", ["A", "exec", "x"], "afile/x"), + ("claude-as", ["work"], "afile/x")))): + self.configure(text) + for tool, args, parent in cases: + with self.subTest(tool=tool, parent=parent): + result = self.run_tool([self.kit / "bin" / tool, *args]) + self.assertEqual((result.returncode, result.stdout, result.stderr), + (1, "", f"{tool}: " + message.format(parent=self.home / parent))) + self.assertFalse((self.home / parent).exists()) + self.assertEqual(afile.read_text(), "a regular file\n") + self.assertEqual(len(self.records()), count) + + def golden(self, variant): + """Every launch scenario from a fresh HOME at one fixed path: outputs, records and HOME.""" + top = self.base / "golden" + shutil.rmtree(top, ignore_errors=True) + top.mkdir() + home = top / "home with spaces" + self.make_home(home) + write(home / ".codex/work.config.toml", 'model = "work"\n') + if variant is not None: + self.configure(variant, home) + record = top / "record.jsonl" + env = {"HOME": str(home), "FAKE_RECORD": str(record)} + results = [] + for tool, args in (("codex-as", ["A", "exec", "prompt"]), ("codex-as", ["A", "-p", "work", "resume", "--last"]), + ("codex-as", ["missing", "--help"]), ("codex-as", ["--list-accounts"]), + ("claude-as", ["work", "-p", "hello"]), ("claude-as", ["work", "--version"]), + ("claude-as", ["--list-accounts"])): + result = self.run_tool([self.kit / "bin" / tool, *args], env) + results.append((tool, args, result.returncode, result.stdout, result.stderr)) + records = [json.loads(line) for line in record.read_text().splitlines()] + return results, records, snapshot(home, skip={".config"}) + + def test_explicit_defaults_reproduce_the_defaults_exactly(self): + expected = self.golden(None) + results, records, files = expected + self.assertEqual([result[2] for result in results], [0] * len(results), results) + self.assertEqual(len(records), 4) # claude --version prints; the fakes record no query + home = self.base / "golden/home with spaces" + self.assertEqual(files[".codex/accounts/A/auth.json"][2], f"{home}/.secrets/codex-A.json") + self.assertEqual(records[2]["env"]["CODEX_HOME"], f"{home}/.codex") + for variant in (DEFAULTS_FILE, f'codex_home = "{home}//.codex/"\nsecrets_dir = "{home}/./.secrets/"\n'): + with self.subTest(variant=variant): + self.assertEqual(self.golden(variant), expected) + + def test_kit_code_reads_home_only_through_the_loader(self): + reads = re.compile(r'os\.environ\[["\']HOME["\']\]|environ\.get\(["\']HOME["\']|getenv\(["\']HOME["\']' + r'|Path\.home\(\)|expanduser') + python = [path for path in ROOT.glob("lib/**/*.py") if path != ROOT / "lib/coding-agent-kit/config.py"] + self.assertGreaterEqual(len(python), 5) + for path in python: + with self.subTest(path=path.relative_to(ROOT)): + self.assertEqual(reads.findall(path.read_text()), []) + for path in (*ROOT.glob("bin/*"), *ROOT.glob("completions/*/*")): + with self.subTest(path=path.relative_to(ROOT)): + self.assertEqual(re.findall(r"\$\{?HOME\b", path.read_text()), []) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_config_sync.py b/tests/test_config_sync.py deleted file mode 100644 index 7ac7d26..0000000 --- a/tests/test_config_sync.py +++ /dev/null @@ -1,1177 +0,0 @@ -"""Native, isolated config-backend tests; run with the provisioned interpreter.""" - -import copy -import importlib.util -import io -import json -import os -from pathlib import Path -import shutil -import stat -import subprocess -import sys -import tempfile -import unittest -from unittest import mock -import venv - -ROOT = Path(__file__).resolve().parents[1] -SPEC = importlib.util.spec_from_file_location("config_sync", ROOT / "lib/config_sync.py") -sync = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(sync) -CODEX = (ROOT / "tests/fixtures/codex-config.toml").read_bytes() -CLAUDE = b'{"permissions":{"allow":[],"ask":[]},"flag":false,"nil":null,"hooks":{"Stop":[]}}' -CODEX_SCOPED = {"PreToolUse": [{"matcher": "^Bash$", "hooks": [ - {"type": "command", "command": "fixture-hook --client codex", "timeout": 5, "async": False}]}]} -MARKER = (b"") -PORTABLE_AGENTS = b"# Global Working Preferences\n\n- portable preference\n" -HOST_RULES = b"## Sherlock toolkit\n\n- host rule\n" -GENERATED = (MARKER + b"\n# Global Working Preferences\n\n- portable preference" - + b"\n\n## Sherlock toolkit\n\n- host rule\n") - - -class TransformTests(unittest.TestCase): - def test_codex_opt_in_hook_preserves_existing_arrays_and_runtime(self): - import tomlkit - - live = b'''model="local" -host_runtime="keep" -[[hooks.PreToolUse]] -matcher="^Write$" -[[hooks.PreToolUse.hooks]] -type="command" -command="host-local-check" -timeout=17 -[[hooks.Stop]] -[[hooks.Stop.hooks]] -type="command" -command="host-stop" -''' - merged, _ = sync.merge_codex(CODEX, live, scoped_hooks=CODEX_SCOPED) - value = sync.parse_toml(merged).unwrap() - self.assertEqual(value["host_runtime"], "keep") - self.assertEqual(value["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "host-local-check") - self.assertEqual(value["hooks"]["PreToolUse"][1], CODEX_SCOPED["PreToolUse"][0]) - self.assertEqual(value["hooks"]["Stop"][0]["hooks"][0]["command"], "host-stop") - again, _ = sync.merge_codex(CODEX, merged, scoped_hooks=CODEX_SCOPED) - self.assertEqual(again, merged) - ordinary, _ = sync.merge_codex(CODEX, merged) - self.assertEqual(ordinary, merged) - portable = sync.parse_toml(CODEX) - portable["hooks"] = tomlkit.item(CODEX_SCOPED) - baseline, cleaned = sync.merge_codex(tomlkit.dumps(portable).encode(), live) - self.assertEqual(sync.parse_toml(baseline).unwrap()["hooks"], value["hooks"]) - self.assertEqual(sync.parse_toml(cleaned).unwrap()["hooks"], CODEX_SCOPED) - - def test_codex_hook_schema_rejects_normalized_timeout_and_invalid_values(self): - import copy - - for change in ({"timeoutSec": 5}, {"timeout_sec": 5}, {"timeout": False}, - {"timeout": 1.5}, {"command": ""}, {"async": "false"}, - {"additionalContextLimit": -1}, {"commandWindows": 42}): - scoped = copy.deepcopy(CODEX_SCOPED) - scoped["PreToolUse"][0]["hooks"][0].update(change) - with self.subTest(change=change), self.assertRaises(sync.SyncError): - sync.merge_codex(CODEX, b"", scoped_hooks=scoped) - with self.assertRaisesRegex(sync.SyncError, "unsupported Codex hook event"): - sync.merge_codex(CODEX, b"", scoped_hooks={"NotAnEvent": CODEX_SCOPED["PreToolUse"]}) - with self.assertRaisesRegex(sync.SyncError, "3 seconds"): - sync.merge_codex(CODEX, b"", scoped_hooks={"SessionEnd": CODEX_SCOPED["PreToolUse"]}) - with self.assertRaisesRegex(sync.SyncError, "live Codex hooks"): - sync.merge_codex(CODEX, b'hooks="invalid"', scoped_hooks=CODEX_SCOPED) - - def test_scoped_hook_inputs_use_each_client_raw_format(self): - for client in ("claude", "codex"): - command = {"type": "command", "command": f"fixture-hook --client {client}", "timeout": 5} - if client == "codex": - command.update({"commandWindows": "fixture-hook.cmd --client codex", "async": False}) - hooks = sync.parse_json(json.dumps( - {"PreToolUse": [{"matcher": "Bash", "hooks": [command]}]}).encode()) - sync.validate_hook_events(hooks, client=client) - if client == "codex": - invalid = copy.deepcopy(hooks) - invalid["PreToolUse"][0]["hooks"][0]["command_windows"] = "duplicate" - with self.assertRaisesRegex(sync.SyncError, "only one"): - sync.validate_hook_events(invalid, client=client) - else: - invalid = copy.deepcopy(hooks) - invalid["PreToolUse"][0]["hooks"][0]["commandWindows"] = "codex-only" - with self.assertRaisesRegex(sync.SyncError, "unsupported scoped claude"): - sync.validate_hook_events(invalid, client=client) - - def test_opt_in_hooks_preserve_local_registrations_permissions_and_runtime(self): - local = {"matcher": "Bash", "hooks": [{"type": "command", "command": "local-check"}]} - owned = {"matcher": "Bash", "hooks": [{"type": "command", "command": "fixture-hook --client claude"}]} - live = {"model": "host-choice", "runtime": True, - "permissions": {"allow": ["old"], "additionalDirectories": ["/synthetic/local"]}, - "hooks": {"PreToolUse": [local], "HostEvent": [local]}} - portable = sync.parse_json(CLAUDE) - portable["hooks"]["PreToolUse"] = [] - merged = sync.merge_claude(json.dumps(portable).encode(), json.dumps(live).encode(), - scoped_hooks={"PreToolUse": [owned]}) - result = sync.parse_json(merged) - self.assertEqual(result["hooks"]["PreToolUse"], [local, owned]) - self.assertEqual(result["hooks"]["HostEvent"], [local]) - self.assertEqual(result["model"], "host-choice") - self.assertTrue(result["runtime"]) - self.assertEqual(result["permissions"]["allow"], []) - self.assertEqual(result["permissions"]["additionalDirectories"], ["/synthetic/local"]) - self.assertEqual(sync.merge_claude(json.dumps(portable).encode(), merged, - scoped_hooks={"PreToolUse": [owned]}), merged) - - def test_opt_in_hook_structure_rejected_before_mutation(self): - for scoped in ({}, [], {"PreToolUse": ["bad"]}, {"PreToolUse": [{"hooks": []}]}, - {"PreToolUse": [{"hooks": [{"type": "prompt", "prompt": "unsafe"}]}]}, - {"PreToolUse": [{"hooks": [{"type": "command", "command": "check", "timeout": False}]}]}): - with self.subTest(scoped=scoped), self.assertRaises(sync.SyncError): - sync.merge_claude(CLAUDE, b"{}", scoped_hooks=scoped) - - def codex(self, live, portable=CODEX): - result, _ = sync.merge_codex(portable, live) - return sync.parse_toml(result).unwrap(), result - - def test_pinned_runtime(self): - sync.require_runtime() - - def test_codex_multiline_owned_array_regression(self): - live = b'project_doc_fallback_filenames = [\n "MACHINE.md",\n]\n' - result, _ = self.codex(live) - self.assertEqual(result["project_doc_fallback_filenames"], ["CLAUDE.md"]) - - def test_codex_multiline_strings_preserve_content_and_comments(self): - live = b'# keep heading\nnotes = """\n first\n\n[not_a_table]\n last\n""" # keep suffix\n' - result, raw = self.codex(live) - self.assertEqual(result["notes"], sync.parse_toml(live)["notes"]) - self.assertIn(live.rstrip(), raw) - - def test_codex_quoted_dotted_tables_inline_comments_and_aot(self): - live = b'''"features".network_proxy = false # comment on owned field -"features".unknown = "keep" -[permissions."workspace-net".network.domains] # comment on table -"*" = "deny" -"private.example.org" = "deny" -[[mcp_servers.demo.tools]] -name = "first" -model = "local" -[[mcp_servers.demo.tools]] -name = "second" -''' - result, raw = self.codex(live) - self.assertTrue(result["features"]["network_proxy"]) - self.assertEqual(result["features"]["unknown"], "keep") - self.assertEqual(result["permissions"]["workspace-net"]["network"]["domains"]["private.example.org"], "deny") - self.assertEqual(result["mcp_servers"]["demo"]["tools"], [{"name": "first", "model": "local"}, {"name": "second"}]) - self.assertIn(b"# comment on table", raw) - - def test_codex_inline_live_tables(self): - result, _ = self.codex(b'features = { network_proxy = false, unknown = "keep" }\n') - self.assertTrue(result["features"]["network_proxy"]) - self.assertEqual(result["features"]["unknown"], "keep") - - def test_literal_dotted_table_name_is_not_an_owned_path(self): - live = b'["permissions.workspace-net"]\nextends="literal-local"\n' - result, _ = self.codex(live) - self.assertEqual(result["permissions.workspace-net"]["extends"], "literal-local") - self.assertEqual(result["permissions"]["workspace-net"]["extends"], ":workspace") - - def test_codex_quoted_and_multiline_portable(self): - import tomlkit - - portable = sync.parse_toml(CODEX) - portable["project_doc_fallback_filenames"] = tomlkit.array().multiline(True) - portable["project_doc_fallback_filenames"].append("文档.md") - encoded = tomlkit.dumps(portable).replace("[features]", '["features"] # legal header').encode("utf-8") - result, _ = self.codex(b"", encoded) - self.assertEqual(result["project_doc_fallback_filenames"], ["文档.md"]) - - def test_codex_all_required_fields(self): - import tomlkit - - for path, _ in sync.CODEX_POLICY: - with self.subTest(path=path): - portable = sync.parse_toml(CODEX) - parent = portable - for key in path[:-1]: - parent = parent[key] - del parent[path[-1]] - with self.assertRaisesRegex(sync.SyncError, "missing portable key"): - self.codex(b"", tomlkit.dumps(portable).encode()) - - def test_codex_type_validation(self): - for old, new in ((b'network_proxy = true', b'network_proxy = "true"'), - (b'project_doc_fallback_filenames = ["CLAUDE.md"]', b'project_doc_fallback_filenames = [42]')): - with self.subTest(old=old): - self.assertIn(old, CODEX) - with self.assertRaisesRegex(sync.SyncError, "invalid portable value type"): - self.codex(b"", CODEX.replace(old, new)) - - def test_codex_bad_toml_rejected(self): - for bad in (b'a = [', b'a = 1\na = 2\n', b'\xff', b'[[missing]\n'): - for portable, live in ((bad, b""), (CODEX, bad)): - with self.subTest(portable=portable[:20], live=live): - with self.assertRaises(Exception): - self.codex(live, portable) - - def test_codex_rejects_ancestor_type_conflict(self): - with self.assertRaisesRegex(sync.SyncError, "live table conflicts"): - self.codex(b'features = "not a table"\n') - - def test_codex_retired_policy_and_unknown_state(self): - live = b'''sandbox_mode = "read-only" -[sandbox_workspace_write] -network_access = false -[features] -js_repl = true -unknown = true -[projects."/machine-only/project"] -trust_level = "trusted" -''' - result, _ = self.codex(live) - self.assertNotIn("sandbox_mode", result) - self.assertNotIn("sandbox_workspace_write", result) - self.assertNotIn("js_repl", result["features"]) - self.assertTrue(result["features"]["unknown"]) - self.assertEqual(result["projects"]["/machine-only/project"]["trust_level"], "trusted") - - def test_codex_noop_bytes(self): - _, once = self.codex(b'# runtime comment\nnotes=" keep "\n') - _, twice = self.codex(once) - self.assertEqual(once, twice) - - def test_claude_authoritative_arrays_false_null_unicode_and_live_model(self): - live = {"permissions": {"allow": ["old"], "ask": ["old"], "additionalDirectories": ["C:/文档"]}, - "hooks": {"Stop": [{"command": "old"}], "Unknown": [1]}, - "model": "local", "flag": True, "nil": 42, "hostOnly": {"nested": "保持"}} - merged = sync.parse_json(sync.merge_claude(CLAUDE, json.dumps(live).encode())) - self.assertEqual(merged["permissions"], {"allow": [], "ask": [], "additionalDirectories": ["C:/文档"]}) - self.assertEqual(merged["hooks"], {"Stop": [], "Unknown": [1]}) - self.assertFalse(merged["flag"]) - self.assertIsNone(merged["nil"]) - self.assertEqual(merged["model"], "local") - self.assertEqual(merged["hostOnly"], {"nested": "保持"}) - - def test_claude_type_conflicts_use_portable_values(self): - live = b'{"permissions":false,"hooks":[],"flag":{"old":true},"nil":[1]}' - self.assertEqual(sync.parse_json(sync.merge_claude(CLAUDE, live)), sync.parse_json(CLAUDE)) - - def test_claude_invalid_inputs(self): - for bad in (b'{', b'[]', b'null', b'{"x":1,"x":2}', b'{"x":NaN}', b'{"x":Infinity}', b'\xff'): - for portable, live in ((bad, b"{}"), (CLAUDE, bad)): - with self.subTest(portable=portable, live=live): - with self.assertRaises((ValueError, UnicodeError)): - sync.merge_claude(portable, live) - - def test_claude_invalid_permission_policy(self): - for policy in ({}, {"allow": []}, {"allow": [42], "ask": []}, {"allow": [], "ask": "bad"}): - with self.subTest(policy=policy): - with self.assertRaisesRegex(sync.SyncError, "string arrays"): - sync.merge_claude(json.dumps({"permissions": policy}).encode(), b"{}") - - def test_claude_model_is_not_portable(self): - with self.assertRaisesRegex(sync.SyncError, "machine-local"): - sync.merge_claude(b'{"model":"oops","permissions":{"allow":[],"ask":[]}}', b"{}") - - def test_claude_noop_preserves_original_format(self): - live = CLAUDE + b"\n\n" - self.assertEqual(sync.merge_claude(CLAUDE, live), live) - - def test_claude_booleans_are_not_numeric_noops(self): - portable = b'{"permissions":{"allow":[],"ask":[]},"nested":{"flag":false,"values":[true,false]}}' - live = b'{"permissions":{"allow":[],"ask":[]},"nested":{"flag":0,"values":[1,0]}}' - merged = sync.parse_json(sync.merge_claude(portable, live)) - self.assertIs(merged["nested"]["flag"], False) - self.assertIs(merged["nested"]["values"][0], True) - self.assertIs(merged["nested"]["values"][1], False) - - def test_claude_empty_live_is_fresh(self): - self.assertEqual(sync.parse_json(sync.merge_claude(CLAUDE, b"")), sync.parse_json(CLAUDE)) - - -class FilesystemTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory(prefix="dotfiles sync tests ") - self.addCleanup(self.temporary.cleanup) - self.directory = Path(self.temporary.name) - self.portable = self.directory / "portable.toml" - self.live = self.directory / "home" / "config.toml" - self.portable.write_bytes(CODEX) - - def run_sync(self, *, kind="codex-config-sync", **kwargs): - sync.synchronize(kind, self.portable, self.live, **kwargs) - - def create_live(self, content): - self.live.parent.mkdir(parents=True, exist_ok=True) - self.live.write_bytes(content) - - def wrapper_command(self, wrapper, *arguments): - if os.name != "nt": - return [str(wrapper), *map(str, arguments)] - # Never accidentally select Windows' WSL bash.exe; wrappers need the - # same Git Bash/MSYS path conversion used by stow-all.ps1. - git = shutil.which("git") - candidates = [] - if git: - candidates.append(Path(git).parent.parent / "bin/bash.exe") - candidates.append(Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe") - bash = next((path for path in candidates if path.is_file()), None) - self.assertIsNotNone(bash, "Git Bash is required for Windows wrapper tests") - return [str(bash), "--noprofile", "--norc", Path(wrapper).as_posix(), - *(str(value).replace("\\", "/") for value in arguments)] - - def test_fresh_parent_and_source_immutable(self): - before = self.portable.stat() - self.run_sync() - self.assertEqual(self.portable.read_bytes(), CODEX) - self.assertEqual(self.portable.stat().st_mtime_ns, before.st_mtime_ns) - self.assertFalse(self.live.is_symlink()) - self.assertEqual(sync.parse_toml(self.live.read_bytes()).unwrap(), sync.parse_toml(CODEX).unwrap()) - - def test_check_creates_no_directories_or_files(self): - self.run_sync(check=True) - self.assertFalse(self.live.parent.exists()) - self.assertEqual(list(self.directory.iterdir()), [self.portable]) - - def test_check_validates_live(self): - self.create_live(b'bad = [') - with self.assertRaises(Exception): - self.run_sync(check=True) - self.assertEqual(self.live.read_bytes(), b'bad = [') - - def test_noop_keeps_inode_and_mtime(self): - self.run_sync() - before = self.live.stat() - self.run_sync() - after = self.live.stat() - self.assertEqual((before.st_ino, before.st_mtime_ns), (after.st_ino, after.st_mtime_ns)) - - def test_legacy_relative_symlink_is_replaced_not_followed(self): - self.live.parent.mkdir() - self.live.symlink_to(Path("..") / self.portable.name) - self.run_sync(check=True) - self.assertTrue(self.live.is_symlink()) - self.run_sync() - self.assertFalse(self.live.is_symlink()) - self.assertEqual(self.portable.read_bytes(), CODEX) - - def test_explicit_migration_preserves_polluted_live_state(self): - polluted = '# legacy state\nruntime="保持"\n'.encode() + CODEX + b'\n[host]\nx=42\n' - self.portable.write_bytes(polluted) - self.live.parent.mkdir() - self.live.symlink_to(self.portable) - self.run_sync() - self.assertEqual(self.portable.read_bytes(), polluted) - self.run_sync(migrate=True) - self.assertNotIn("runtime", sync.parse_toml(self.portable.read_bytes())) - self.assertNotIn("host", sync.parse_toml(self.portable.read_bytes())) - self.assertEqual(sync.parse_toml(self.live.read_bytes())["runtime"], "保持") - self.assertEqual(sync.parse_toml(self.live.read_bytes())["host"]["x"], 42) - - def test_migration_retains_source_only_runtime_on_materialized_live(self): - self.portable.write_bytes(CODEX + b'\n[projects.legacy]\ntrust_level="trusted"\n[projects.shared]\nx="source"\n') - self.create_live(b'[projects.current]\ntrust_level="untrusted"\n[projects.shared]\nx="live"\n') - self.run_sync(migrate=True) - projects = sync.parse_toml(self.live.read_bytes())["projects"] - self.assertEqual(projects["legacy"]["trust_level"], "trusted") - self.assertEqual(projects["current"]["trust_level"], "untrusted") - self.assertEqual(projects["shared"]["x"], "live") - self.assertNotIn("projects", sync.parse_toml(self.portable.read_bytes())) - - def test_migration_retains_source_only_runtime_on_fresh_live(self): - self.portable.write_bytes(CODEX + b'\n[projects.legacy]\ntrust_level="trusted"\n') - self.run_sync(migrate=True) - self.assertEqual(sync.parse_toml(self.live.read_bytes())["projects"]["legacy"]["trust_level"], "trusted") - self.assertNotIn("projects", sync.parse_toml(self.portable.read_bytes())) - - def test_migration_source_write_failure_keeps_saved_live_and_old_source(self): - polluted = CODEX + b'\n[projects.legacy]\ntrust_level="trusted"\n' - self.portable.write_bytes(polluted) - original = sync.os.replace - - def fail_only_source(source, target): - if target == self.portable: - raise PermissionError("injected source replacement failure") - original(source, target) - - with mock.patch.object(sync.os, "replace", side_effect=fail_only_source): - with self.assertRaises(PermissionError): - self.run_sync(migrate=True) - self.assertEqual(self.portable.read_bytes(), polluted) - self.assertEqual(sync.parse_toml(self.live.read_bytes())["projects"]["legacy"]["trust_level"], "trusted") - self.assertEqual(set(self.directory.iterdir()), {self.portable, self.live.parent}) - - def test_missing_source_fail_closed(self): - self.portable.unlink() - with self.assertRaisesRegex(sync.SyncError, "missing portable"): - self.run_sync() - self.assertFalse(self.live.parent.exists()) - - def test_directory_inputs_rejected(self): - self.live.mkdir(parents=True) - with self.assertRaisesRegex(sync.SyncError, "not a regular"): - self.run_sync() - self.portable.unlink() - self.portable.mkdir() - with self.assertRaisesRegex(sync.SyncError, "not a regular"): - self.run_sync() - - def test_dangling_symlink_not_treated_as_fresh(self): - self.live.parent.mkdir() - self.live.symlink_to("missing.toml") - with self.assertRaisesRegex(sync.SyncError, "dangling symlink"): - self.run_sync() - self.assertTrue(self.live.is_symlink()) - - @unittest.skipIf(os.name == "nt", "POSIX permission bits") - def test_unreadable_live_fails_closed(self): - self.create_live(b'local = "keep"\n') - self.live.chmod(0) - self.addCleanup(self.live.chmod, 0o600) - with self.assertRaisesRegex(sync.SyncError, "unreadable"): - self.run_sync() - - @unittest.skipIf(os.name == "nt", "POSIX permission bits") - def test_unreadable_portable_fails_closed(self): - self.portable.chmod(0) - self.addCleanup(self.portable.chmod, 0o600) - with self.assertRaisesRegex(sync.SyncError, "unreadable"): - self.run_sync() - self.assertFalse(self.live.parent.exists()) - - @unittest.skipIf(os.name == "nt", "POSIX permission bits") - def test_unix_permissions(self): - for kind, portable, mode in (("codex-config-sync", CODEX, 0o600), - ("claude-settings-sync", CLAUDE, 0o644), - ("codex-rules-sync", b"# portable rules\n", 0o644)): - with self.subTest(kind=kind): - self.portable.write_bytes(portable) - self.run_sync(kind=kind) - self.assertEqual(stat.S_IMODE(self.live.stat().st_mode), mode) - self.live.unlink() - - @unittest.skipIf(os.name == "nt", "POSIX permission bits") - def test_existing_permissions_never_widen_on_noop_or_replacement(self): - for kind, portable, mode in (("codex-config-sync", CODEX, 0o600), - ("claude-settings-sync", CLAUDE, 0o644), - ("codex-rules-sync", b"# portable rules\n", 0o644)): - for initial in (0o400, 0o600, 0o640, 0o644, 0o660, 0o755): - for changed in (False, True): - with self.subTest(kind=kind, initial=oct(initial), changed=changed): - self.portable.write_bytes(portable) - self.live.unlink(missing_ok=True) - self.run_sync(kind=kind) - if changed: - self.live.write_bytes(b"{}" if kind == "claude-settings-sync" else b"# old content\n") - self.live.chmod(initial) - before = self.live.stat() - self.run_sync(kind=kind) - after = self.live.stat() - self.assertEqual(stat.S_IMODE(after.st_mode), initial & mode) - if not changed: - self.assertEqual((before.st_ino, before.st_mtime_ns), - (after.st_ino, after.st_mtime_ns)) - else: - self.assertNotEqual(self.live.read_bytes(), - b"{}" if kind == "claude-settings-sync" else b"# old content\n") - - @unittest.skipIf(os.name == "nt", "POSIX permission bits") - def test_legacy_symlink_does_not_copy_or_change_source_permissions(self): - for kind, portable, mode in (("codex-config-sync", CODEX, 0o600), - ("claude-settings-sync", CLAUDE, 0o644), - ("codex-rules-sync", b"# portable rules\n", 0o644)): - with self.subTest(kind=kind): - self.portable.write_bytes(portable) - self.portable.chmod(0o751) - before = self.portable.stat() - self.live.parent.mkdir(exist_ok=True) - self.live.symlink_to(self.portable) - self.run_sync(kind=kind) - self.assertFalse(self.live.is_symlink()) - self.assertEqual(stat.S_IMODE(self.live.stat().st_mode), mode) - self.assertEqual(self.portable.read_bytes(), portable) - after = self.portable.stat() - self.assertEqual((before.st_mode, before.st_ino, before.st_mtime_ns), - (after.st_mode, after.st_ino, after.st_mtime_ns)) - self.live.unlink() - - @unittest.skipIf(os.name == "nt", "POSIX permission bits") - def test_check_does_not_restrict_existing_permissions(self): - self.run_sync() - self.live.chmod(0o644) - before = self.live.stat() - self.run_sync(check=True) - after = self.live.stat() - self.assertEqual((before.st_mode, before.st_ino, before.st_mtime_ns), - (after.st_mode, after.st_ino, after.st_mtime_ns)) - - @unittest.skipIf(os.name == "nt", "POSIX FIFO") - def test_fifo_rejected_without_blocking(self): - self.live.parent.mkdir() - os.mkfifo(self.live) - with self.assertRaisesRegex(sync.SyncError, "not a regular"): - self.run_sync() - - def test_same_source_and_live_rejected(self): - self.live = self.portable - with self.assertRaisesRegex(sync.SyncError, "same regular file"): - self.run_sync() - self.assertEqual(self.portable.read_bytes(), CODEX) - - def test_hardlinked_source_and_live_rejected(self): - self.live.parent.mkdir() - self.live.hardlink_to(self.portable) - with self.assertRaisesRegex(sync.SyncError, "same regular file"): - self.run_sync() - - def test_replace_failure_preserves_legacy_link_and_cleans_temp(self): - self.live.parent.mkdir() - self.live.symlink_to(self.portable) - with mock.patch.object(sync.os, "replace", side_effect=PermissionError("injected replace failure")): - with self.assertRaises(PermissionError): - self.run_sync() - self.assertTrue(self.live.is_symlink()) - self.assertEqual(self.portable.read_bytes(), CODEX) - self.assertEqual(list(self.live.parent.iterdir()), [self.live]) - - def test_fsync_failure_preserves_regular_file_and_cleans_temp(self): - self.create_live(b'local="keep"\n') - self.live.chmod(0o600) - before = self.live.stat() - with mock.patch.object(sync.os, "fsync", side_effect=OSError("injected write failure")): - with self.assertRaises(OSError): - self.run_sync() - self.assertEqual(self.live.read_bytes(), b'local="keep"\n') - after = self.live.stat() - self.assertEqual((before.st_mode, before.st_ino, before.st_mtime_ns), - (after.st_mode, after.st_ino, after.st_mtime_ns)) - self.assertEqual(list(self.live.parent.iterdir()), [self.live]) - - def test_temp_creation_failure_keeps_legacy_symlink(self): - self.live.parent.mkdir() - self.live.symlink_to(self.portable) - with mock.patch.object(sync.tempfile, "mkstemp", side_effect=PermissionError("injected temporary failure")): - with self.assertRaises(PermissionError): - self.run_sync() - self.assertTrue(self.live.is_symlink()) - self.assertEqual(self.portable.read_bytes(), CODEX) - - def test_observed_concurrent_write_is_not_overwritten(self): - self.create_live(b'local="old"\n') - with mock.patch.object(sync, "read_regular", wraps=sync.read_regular) as reader: - original = sync.os.fsync - - def changed_after_write(fd): - original(fd) - self.live.write_bytes(b'local="new"\n') - - with mock.patch.object(sync.os, "fsync", side_effect=changed_after_write): - with self.assertRaisesRegex(sync.SyncError, "changed during synchronization"): - self.run_sync() - self.assertGreaterEqual(reader.call_count, 3) - self.assertEqual(self.live.read_bytes(), b'local="new"\n') - self.assertEqual(list(self.live.parent.iterdir()), [self.live]) - - def test_rules_copy_bytes_only_and_preserve_siblings(self): - self.portable.write_bytes(b'# rules\nopaque_non_parser_payload()\n') - self.live.parent.mkdir() - sibling = self.live.parent / "default.rules" - sibling.write_bytes(b"machine-local") - self.run_sync(kind="codex-rules-sync", check=True) - self.assertFalse(self.live.exists()) - self.run_sync(kind="codex-rules-sync") - self.assertEqual(self.live.read_bytes(), self.portable.read_bytes()) - self.assertEqual(sibling.read_bytes(), b"machine-local") - - def test_empty_rules_fail_closed(self): - self.portable.write_bytes(b"") - with self.assertRaisesRegex(sync.SyncError, "empty portable rules"): - self.run_sync(kind="codex-rules-sync", check=True) - self.assertFalse(self.live.parent.exists()) - - def test_claude_check_no_mutation(self): - self.portable.write_bytes(CLAUDE) - self.run_sync(kind="claude-settings-sync", check=True) - self.assertFalse(self.live.parent.exists()) - - def test_scoped_hook_check_preserves_bytes_modes_and_source_symlink(self): - self.portable.write_bytes(CLAUDE) - self.live.parent.mkdir() - source = self.directory / "local-settings.json" - source.write_text('{"model":"host","hooks":{"PreToolUse":[]}}') - source.chmod(0o600) - self.live.symlink_to(source) - hooks = self.directory / "opt-in-hooks.json" - hooks.write_text('{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"fixture-hook --client claude"}]}]}') - before = source.stat() - sync.synchronize("claude-settings-sync", self.portable, self.live, check=True, hooks=hooks) - self.assertTrue(self.live.is_symlink()) - self.assertEqual(before.st_mtime_ns, source.stat().st_mtime_ns) - sync.synchronize("claude-settings-sync", self.portable, self.live, hooks=hooks) - self.assertFalse(self.live.is_symlink()) - self.assertEqual(source.read_text(), '{"model":"host","hooks":{"PreToolUse":[]}}') - - def test_codex_scoped_hook_preflight_modes_and_runtime_keys(self): - self.create_live(b'host_runtime="keep"\n') - self.live.chmod(0o600) - before = self.live.stat() - hooks = self.directory / "scoped-codex-hooks.json" - hooks.write_text(json.dumps(CODEX_SCOPED)) - sync.synchronize("codex-config-sync", self.portable, self.live, check=True, hooks=hooks) - self.assertEqual(self.live.read_bytes(), b'host_runtime="keep"\n') - self.assertEqual(before.st_mtime_ns, self.live.stat().st_mtime_ns) - self.assertEqual(before.st_mode, self.live.stat().st_mode) - sync.synchronize("codex-config-sync", self.portable, self.live, hooks=hooks) - self.assertEqual(sync.parse_toml(self.live.read_bytes())["host_runtime"], "keep") - if os.name != "nt": - self.assertEqual(self.live.stat().st_mode & 0o777, 0o600) - before = self.live.read_bytes() - hooks.write_text('{"PreToolUse":[{"hooks":[{"type":"command","command":"fixture-hook --client codex","timeoutSec":5}]}]}') - with self.assertRaises(sync.SyncError): - sync.synchronize("codex-config-sync", self.portable, self.live, check=True, hooks=hooks) - self.assertEqual(self.live.read_bytes(), before) - - def test_cli_native_python_paths_with_spaces(self): - self.live = self.directory / "文档 home" / "配置.toml" - result = subprocess.run([sys.executable, "-I", "-B", "-X", "utf8", str(ROOT / "lib/config_sync.py"), - "codex-config-sync", "--check", str(self.portable), str(self.live)], - capture_output=True, encoding="utf-8", check=False) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("配置.toml", result.stdout) - self.assertFalse(self.live.parent.exists()) - - def test_cli_quiet_suppresses_success_and_check_output(self): - for kind, portable, label in (("codex-config-sync", CODEX, "portable Codex settings"), - ("claude-settings-sync", CLAUDE, "portable Claude settings"), - ("codex-rules-sync", b"# rules\n", "portable Codex rules")): - self.portable.write_bytes(portable) - for check in (True, False): - for quiet in (True, False): - with self.subTest(kind=kind, check=check, quiet=quiet): - self.live.unlink(missing_ok=True) - arguments = (["--check"] if check else []) + (["--quiet"] if quiet else []) - result = subprocess.run([sys.executable, "-I", "-B", "-X", "utf8", - str(ROOT / "lib/config_sync.py"), kind, *arguments, - str(self.portable), str(self.live)], - capture_output=True, encoding="utf-8", check=False) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stderr, "") - if quiet: - self.assertEqual(result.stdout, "") - else: - self.assertIn(f"{'Validated' if check else 'Synchronized'} {label} into", result.stdout) - self.assertEqual(self.live.exists(), not check) - - def test_cli_quiet_never_suppresses_errors(self): - for kind, portable in (("codex-config-sync", b"bad = ["), - ("claude-settings-sync", b"{"), - ("codex-rules-sync", b"")): - self.portable.write_bytes(portable) - for check in (True, False): - with self.subTest(kind=kind, check=check): - result = subprocess.run([sys.executable, "-I", "-B", "-X", "utf8", - str(ROOT / "lib/config_sync.py"), kind, "--quiet", - *(["--check"] if check else []), - str(self.portable), str(self.live)], - capture_output=True, encoding="utf-8", check=False) - self.assertEqual(result.returncode, 1) - self.assertEqual(result.stdout, "") - self.assertIn("config-sync:", result.stderr) - self.assertNotIn("Traceback", result.stderr) - self.assertFalse(self.live.parent.exists()) - - def test_stowed_wrapper_resolves_relative_symlinks_and_spaced_paths(self): - bin_dir = self.directory / "fake home" / ".local" / "bin" - bin_dir.mkdir(parents=True) - helper = ROOT / "common/codex/.local/bin/codex-config-sync" - intermediary = self.directory / "relative wrapper" - intermediary.symlink_to(helper) - wrapper = bin_dir / "codex-config-sync" - wrapper.symlink_to(os.path.relpath(intermediary, bin_dir)) - self.live = self.directory / "文档 home" / "配置.toml" - env = dict(os.environ, DOTFILES_SYNC_PYTHON=sys.executable) - result = subprocess.run(self.wrapper_command(wrapper, "--check", self.portable, self.live), env=env, - capture_output=True, encoding="utf-8", check=False) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("配置.toml", result.stdout) - self.assertFalse(self.live.parent.exists()) - - def test_invalid_runtime_override_fails_closed_without_fallback(self): - helper = ROOT / "common/codex/.local/bin/codex-config-sync" - env = dict(os.environ, DOTFILES_SYNC_PYTHON=str(self.directory / "missing interpreter")) - result = subprocess.run(self.wrapper_command(helper, self.portable, self.live), env=env, - capture_output=True, text=True, check=False) - self.assertEqual(result.returncode, 1) - self.assertIn("runtime unavailable", result.stderr) - self.assertFalse(self.live.parent.exists()) - - def test_setup_rejects_linked_environment_without_touching_target(self): - external = self.directory / "external environment" - external.mkdir() - marker = external / "pyvenv.cfg" - marker.write_bytes(b"sentinel: not ours") - linked = self.directory / ".venv-sync" - linked.symlink_to(external, target_is_directory=True) - if os.name == "nt": - setup = self.directory / "setup-sync.ps1" - shutil.copyfile(ROOT / setup.name, setup) - command = ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(setup), "-Python", "must-not-run"] - else: - setup = self.directory / "setup-sync.sh" - shutil.copyfile(ROOT / setup.name, setup) - command = ["sh", str(setup), "--python", "must-not-run"] - result = subprocess.run(command, capture_output=True, text=True, check=False) - self.assertNotEqual(result.returncode, 0) - self.assertIn("Refusing" if os.name == "nt" else "refusing", result.stderr) - self.assertEqual(marker.read_bytes(), b"sentinel: not ours") - self.assertEqual(list(external.iterdir()), [marker]) - - def test_cli_invalid_inputs_fail_without_traceback_or_writes(self): - self.create_live(b'bad = [') - result = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "lib/config_sync.py"), - "codex-config-sync", "--check", str(self.portable), str(self.live)], - capture_output=True, text=True, check=False) - self.assertEqual(result.returncode, 1) - self.assertNotIn("Traceback", result.stderr) - self.assertEqual(self.live.read_bytes(), b'bad = [') - - def test_missing_or_mismatched_tomlkit_fails_closed(self): - for installed in ("0.0.0", None): - effect = sync.importlib.metadata.PackageNotFoundError("tomlkit") if installed is None else None - with self.subTest(installed=installed): - with mock.patch.object(sync.importlib.metadata, "version", return_value=installed, side_effect=effect): - with self.assertRaisesRegex(sync.SyncError, "setup-sync"): - sync.require_runtime() - - def test_cli_tomlkit_requirement_applies_only_to_codex_and_full_runtime_check(self): - for installed in ("0.0.0", None): - effect = sync.importlib.metadata.PackageNotFoundError("tomlkit") if installed is None else None - for kind, portable in (("codex-config-sync", CODEX), - ("claude-settings-sync", CLAUDE), - ("codex-rules-sync", b"# rules\n")): - self.portable.write_bytes(portable) - for check in (False, True): - with self.subTest(installed=installed, kind=kind, check=check): - self.live.unlink(missing_ok=True) - with mock.patch.object(sync.importlib.metadata, "version", return_value=installed, - side_effect=effect) as version: - with mock.patch.object(sync.sys, "stdout", new_callable=io.StringIO) as stdout: - with mock.patch.object(sync.sys, "stderr", new_callable=io.StringIO) as stderr: - result = sync.main([kind, "--quiet", *(["--check"] if check else []), - str(self.portable), str(self.live)]) - self.assertEqual(stdout.getvalue(), "") - if kind == "codex-config-sync": - self.assertEqual(result, 1) - self.assertIn("tomlkit", stderr.getvalue()) - self.assertFalse(self.live.exists()) - version.assert_called_once_with("tomlkit") - else: - self.assertEqual(result, 0, stderr.getvalue()) - self.assertEqual(stderr.getvalue(), "") - self.assertEqual(self.live.exists(), not check) - version.assert_not_called() - with self.subTest(installed=installed, kind="--runtime-check"): - with mock.patch.object(sync.importlib.metadata, "version", return_value=installed, - side_effect=effect): - with mock.patch.object(sync.sys, "stderr", new_callable=io.StringIO) as stderr: - self.assertEqual(sync.main(["--runtime-check"]), 1) - self.assertIn("tomlkit", stderr.getvalue()) - - def test_all_helpers_keep_python_version_floor(self): - for kind in ("codex-config-sync", "claude-settings-sync", "codex-rules-sync", - "codex-instructions-sync", "--runtime-check"): - with self.subTest(kind=kind): - arguments = [kind] if kind == "--runtime-check" else [kind, "--quiet", str(self.portable), str(self.live)] - with mock.patch.object(sync.sys, "version_info", (3, 10)): - with mock.patch.object(sync.sys, "stderr", new_callable=io.StringIO) as stderr: - self.assertEqual(sync.main(arguments), 1) - self.assertIn("Python 3.11", stderr.getvalue()) - self.assertFalse(self.live.parent.exists()) - - def test_wrappers_accept_explicit_stdlib_only_runtime_except_codex(self): - # A fresh venv without pip or site packages is a real no-tomlkit - # interpreter on every CI platform, without downloading anything. - runtime = self.directory / "stdlib runtime" - venv.EnvBuilder(with_pip=False).create(runtime) - interpreter = runtime / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - env = dict(os.environ, DOTFILES_SYNC_PYTHON=str(interpreter)) - for kind, package, portable in (("claude-settings-sync", "claude", CLAUDE), - ("codex-rules-sync", "codex", b"# rules\n"), - ("codex-config-sync", "codex", CODEX)): - helper = ROOT / "common" / package / ".local/bin" / kind - self.portable.write_bytes(portable) - for check in (True, False): - with self.subTest(kind=kind, check=check): - self.live.unlink(missing_ok=True) - result = subprocess.run(self.wrapper_command(helper, "--quiet", - *(["--check"] if check else []), - self.portable, self.live), - env=env, capture_output=True, encoding="utf-8", check=False) - self.assertEqual(result.stdout, "") - if kind == "codex-config-sync": - self.assertEqual(result.returncode, 1) - self.assertIn("missing tomlkit", result.stderr) - self.assertFalse(self.live.exists()) - else: - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stderr, "") - self.assertEqual(self.live.exists(), not check) - - self.portable.write_bytes(b"{") - self.live.unlink(missing_ok=True) - helper = ROOT / "common/claude/.local/bin/claude-settings-sync" - result = subprocess.run(self.wrapper_command(helper, "--quiet", self.portable, self.live), - env=env, capture_output=True, encoding="utf-8", check=False) - self.assertEqual(result.returncode, 1) - self.assertEqual(result.stdout, "") - self.assertIn("config-sync:", result.stderr) - self.assertNotIn("Traceback", result.stderr) - self.assertFalse(self.live.exists()) - - -class InstructionsTests(unittest.TestCase): - """codex-instructions-sync: one generated ~/.codex/AGENTS.override.md per host.""" - - wrapper_command = FilesystemTests.wrapper_command - - def setUp(self): - self.temporary = tempfile.TemporaryDirectory(prefix="dotfiles instructions tests ") - self.addCleanup(self.temporary.cleanup) - self.directory = Path(self.temporary.name) - self.portable = self.directory / "common" / "AGENTS.md" - self.target = self.directory / "submodule" / "rules" / "workstation.md" - for path, content in ((self.portable, PORTABLE_AGENTS), (self.target, HOST_RULES)): - path.parent.mkdir(parents=True) - path.write_bytes(content) - # Two hops, as in a stowed home: home link -> host package link -> submodule file. - package_link = self.directory / "host" / "sherlock-kit" / "sherlock-kit.md" - package_link.parent.mkdir(parents=True) - package_link.symlink_to(os.path.relpath(self.target, package_link.parent)) - self.rules = self.directory / "home rules" / "sherlock-kit.md" - self.rules.parent.mkdir() - self.rules.symlink_to(package_link) - self.live = self.directory / "home" / ".codex" / "AGENTS.override.md" - - def run_sync(self, *, rules=True, check=False): - return sync.synchronize_instructions(self.portable, self.live, rules=self.rules if rules else None, - check=check) - - def create_live(self, content): - self.live.parent.mkdir(parents=True, exist_ok=True) - self.live.write_bytes(content) - - def cli(self, *arguments, env=None): - return subprocess.run([sys.executable, "-I", "-B", "-X", "utf8", str(ROOT / "lib/config_sync.py"), - "codex-instructions-sync", *map(str, arguments)], - capture_output=True, encoding="utf-8", check=False, env=env) - - def test_marker_is_the_frozen_contract_text(self): - self.assertEqual(sync.INSTRUCTIONS_MARKER, MARKER) - - def test_append_writes_marker_portable_then_rules(self): - self.assertEqual(self.run_sync(), "write") - self.assertEqual(self.live.read_bytes(), GENERATED) - self.assertFalse(self.live.is_symlink()) - self.assertEqual(self.portable.read_bytes(), PORTABLE_AGENTS) - self.assertEqual(self.target.read_bytes(), HOST_RULES) - self.assertTrue(self.rules.is_symlink()) - - def test_trailing_newlines_are_normalized(self): - self.portable.write_bytes(b"# Portable\n\n\n") - self.target.write_bytes(b"## Rules") - self.run_sync() - self.assertEqual(self.live.read_bytes(), MARKER + b"\n# Portable\n\n## Rules\n") - - @unittest.skipIf(os.name == "nt", "POSIX permission bits") - def test_output_mode_is_0644_and_never_widens(self): - self.run_sync() - self.assertEqual(stat.S_IMODE(self.live.stat().st_mode), 0o644) - self.live.chmod(0o600) - self.target.write_bytes(b"## Changed rules\n") - self.run_sync() - self.assertEqual(stat.S_IMODE(self.live.stat().st_mode), 0o600) - self.assertTrue(self.live.read_bytes().endswith(b"\n\n## Changed rules\n")) - - def test_byte_identical_output_is_a_noop(self): - self.run_sync() - before = self.live.stat() - self.assertEqual(self.run_sync(check=True), "current") - self.assertEqual(self.run_sync(), "current") - after = self.live.stat() - self.assertEqual((before.st_ino, before.st_mtime_ns), (after.st_ino, after.st_mtime_ns)) - self.assertEqual(list(self.live.parent.iterdir()), [self.live]) - - def test_stale_generated_file_is_regenerated(self): - self.create_live(MARKER + b"\n# old portable text\n") - self.assertEqual(self.run_sync(check=True), "write") - self.assertEqual(self.live.read_bytes(), MARKER + b"\n# old portable text\n") - self.assertEqual(self.run_sync(), "write") - self.assertEqual(self.live.read_bytes(), GENERATED) - - def test_foreign_live_is_refused_and_preserved(self): - for foreign in (b"# my own override\n", b"", b"\n" + MARKER + b"\n", b" " + MARKER + b"\n", - MARKER + b" trailing\n"): - for check in (True, False): - with self.subTest(foreign=foreign, check=check): - self.create_live(foreign) - with self.assertRaisesRegex(sync.SyncError, "not generated by codex-instructions-sync"): - self.run_sync(check=check) - self.assertEqual(self.live.read_bytes(), foreign) - - def test_symlinked_live_is_refused_and_preserved(self): - generated = self.directory / "generated elsewhere.md" - generated.write_bytes(GENERATED) - self.live.parent.mkdir(parents=True) - for link in (generated, Path("missing.md")): - self.live.unlink(missing_ok=True) - self.live.symlink_to(link) - for check in (True, False): - with self.subTest(link=link, check=check): - with self.assertRaisesRegex(sync.SyncError, "symlink"): - self.run_sync(check=check) - self.assertTrue(self.live.is_symlink()) - self.assertEqual(generated.read_bytes(), GENERATED) - - def test_nonregular_live_is_refused(self): - self.live.mkdir(parents=True) - with self.assertRaisesRegex(sync.SyncError, "not a regular file"): - self.run_sync() - self.assertTrue(self.live.is_dir()) - - def test_check_creates_no_directories_or_files(self): - self.assertEqual(self.run_sync(check=True), "write") - self.assertFalse(self.live.parent.exists()) - self.live.parent.mkdir(parents=True) - self.run_sync(check=True) - self.assertEqual(list(self.live.parent.iterdir()), []) - self.create_live(GENERATED) - before = self.live.stat() - self.assertEqual(self.run_sync(rules=False, check=True), "remove") - after = self.live.stat() - self.assertEqual(self.live.read_bytes(), GENERATED) - self.assertEqual((before.st_ino, before.st_mtime_ns), (after.st_ino, after.st_mtime_ns)) - - def test_without_append_removes_only_a_generated_file(self): - self.run_sync() - sibling = self.live.parent / "AGENTS.md" - sibling.write_bytes(b"stowed portable stand-in\n") - self.assertEqual(self.run_sync(rules=False), "remove") - self.assertFalse(self.live.exists()) - self.assertEqual(self.run_sync(rules=False), "none") - self.assertEqual(list(self.live.parent.iterdir()), [sibling]) - self.assertEqual(sibling.read_bytes(), b"stowed portable stand-in\n") - - def test_without_append_absent_live_creates_nothing(self): - for check in (True, False): - with self.subTest(check=check): - self.assertEqual(self.run_sync(rules=False, check=check), "none") - self.assertFalse(self.live.parent.exists()) - - def test_without_append_keeps_every_foreign_entry(self): - generated = self.directory / "generated elsewhere.md" - generated.write_bytes(GENERATED) - - def regular(): - self.create_live(b"# my own override\n") - - def link(): - self.live.parent.mkdir(parents=True, exist_ok=True) - self.live.symlink_to(generated) - - def dangling(): - self.live.parent.mkdir(parents=True, exist_ok=True) - self.live.symlink_to("missing.md") - - def directory(): - self.live.mkdir(parents=True) - - for make in (regular, link, dangling, directory): - for check in (True, False): - with self.subTest(entry=make.__name__, check=check): - make() - before = self.live.lstat() - self.assertEqual(self.run_sync(rules=False, check=check), "foreign") - after = self.live.lstat() - self.assertEqual((before.st_mode, before.st_ino, before.st_mtime_ns), - (after.st_mode, after.st_ino, after.st_mtime_ns)) - if self.live.is_dir() and not self.live.is_symlink(): - self.live.rmdir() - else: - self.live.unlink() - self.assertEqual(generated.read_bytes(), GENERATED) - - def test_dangling_rules_link_gives_submodule_hint(self): - self.target.unlink() - for check in (True, False): - with self.subTest(check=check): - with self.assertRaisesRegex(sync.SyncError, r"git submodule update --init --recursive"): - self.run_sync(check=check) - self.assertFalse(self.live.parent.exists()) - self.create_live(MARKER + b"\n# previous generation\n") - with self.assertRaisesRegex(sync.SyncError, "dangles"): - self.run_sync() - self.assertEqual(self.live.read_bytes(), MARKER + b"\n# previous generation\n") - - def test_missing_inputs_fail_closed(self): - self.rules.unlink() - with self.assertRaisesRegex(sync.SyncError, "missing"): - self.run_sync() - self.portable.unlink() - for rules in (True, False): - with self.subTest(rules=rules), self.assertRaisesRegex(sync.SyncError, "missing"): - self.run_sync(rules=rules) - self.assertFalse(self.live.parent.exists()) - - def test_empty_inputs_fail_closed(self): - for name, modes in (("portable", (True, False)), ("target", (True,))): - for empty in (b"", b"\n\n", b" \n"): - for rules in modes: - with self.subTest(name=name, empty=empty, rules=rules): - self.portable.write_bytes(PORTABLE_AGENTS) - self.target.write_bytes(HOST_RULES) - getattr(self, name).write_bytes(empty) - with self.assertRaisesRegex(sync.SyncError, "empty"): - self.run_sync(rules=rules) - self.assertFalse(self.live.parent.exists()) - - def test_invalid_utf8_inputs_fail_closed(self): - for name in ("portable", "target"): - with self.subTest(name=name): - self.portable.write_bytes(PORTABLE_AGENTS) - self.target.write_bytes(HOST_RULES) - getattr(self, name).write_bytes(b"## \xff\n") - with self.assertRaises(ValueError): - self.run_sync() - self.assertFalse(self.live.parent.exists()) - - def test_live_must_differ_from_both_inputs(self): - for live in (self.portable, self.target): - with self.subTest(live=live): - with self.assertRaisesRegex(sync.SyncError, "same regular file"): - sync.synchronize_instructions(self.portable, live, rules=self.rules) - self.assertEqual(self.portable.read_bytes(), PORTABLE_AGENTS) - self.assertEqual(self.target.read_bytes(), HOST_RULES) - - def test_observed_change_before_removal_is_not_removed(self): - self.create_live(GENERATED) - original = sync.read_regular - reads = [] - - def edited_between_reads(path, **kwargs): - if path == self.live: - reads.append(path) - if len(reads) == 2: - self.live.write_bytes(MARKER + b"\n# edited meanwhile\n") - return original(path, **kwargs) - - with mock.patch.object(sync, "read_regular", side_effect=edited_between_reads): - with self.assertRaisesRegex(sync.SyncError, "changed during synchronization"): - self.run_sync(rules=False) - self.assertEqual(self.live.read_bytes(), MARKER + b"\n# edited meanwhile\n") - - def test_cli_reports_state_and_quiet_hides_only_success(self): - appended = ("--append", self.rules, self.portable, self.live) - result = self.cli("--check", *appended) - self.assertEqual((result.returncode, result.stderr), (0, "")) - self.assertEqual(result.stdout.strip(), f"Validated host Codex instructions into {self.live} (stale)") - self.assertFalse(self.live.parent.exists()) - result = self.cli(*appended) - self.assertEqual((result.returncode, result.stderr), (0, "")) - self.assertEqual(result.stdout.strip(), f"Synchronized host Codex instructions into {self.live}") - result = self.cli("--check", *appended) - self.assertEqual(result.stdout.strip(), f"Validated host Codex instructions into {self.live} (current)") - for arguments in (("--quiet", "--check", *appended), ("--quiet", *appended)): - with self.subTest(arguments=arguments): - result = self.cli(*arguments) - self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "", "")) - result = self.cli("--check", self.portable, self.live) - self.assertEqual(result.stdout.strip(), f"Validated host Codex instructions into {self.live} (stale)") - result = self.cli(self.portable, self.live) - self.assertEqual((result.returncode, result.stderr), (0, "")) - self.assertEqual(result.stdout.strip(), f"Removed generated host Codex instructions from {self.live}") - self.assertFalse(self.live.exists()) - result = self.cli("--check", self.portable, self.live) - self.assertEqual(result.stdout.strip(), f"Validated host Codex instructions into {self.live} (current)") - - def test_cli_foreign_live_warns_without_append_and_fails_with_it(self): - self.create_live(b"# my own override\n") - result = self.cli("--quiet", self.portable, self.live) - self.assertEqual((result.returncode, result.stdout), (0, "")) - self.assertIn("warning", result.stderr) - self.assertIn(str(self.live), result.stderr) - result = self.cli("--check", self.portable, self.live) - self.assertEqual((result.returncode, result.stderr), (0, "")) - self.assertIn("(foreign file left unchanged)", result.stdout) - result = self.cli("--quiet", "--check", self.portable, self.live) - self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "", "")) - for check in (True, False): - with self.subTest(check=check): - result = self.cli("--quiet", *(["--check"] if check else []), - "--append", self.rules, self.portable, self.live) - self.assertEqual((result.returncode, result.stdout), (1, "")) - self.assertIn("config-sync:", result.stderr) - self.assertIn("not generated by codex-instructions-sync", result.stderr) - self.assertNotIn("Traceback", result.stderr) - self.assertEqual(self.live.read_bytes(), b"# my own override\n") - - def test_cli_dangling_rules_hint_without_traceback(self): - self.target.unlink() - result = self.cli("--quiet", "--check", "--append", self.rules, self.portable, self.live) - self.assertEqual((result.returncode, result.stdout), (1, "")) - self.assertIn("git submodule update --init --recursive", result.stderr) - self.assertNotIn("Traceback", result.stderr) - - def test_cli_rejects_options_owned_by_other_kinds(self): - hooks = self.directory / "hooks.json" - hooks.write_text(json.dumps(CODEX_SCOPED)) - for kind, arguments, option in ( - ("codex-instructions-sync", ("--hooks", hooks), "--hooks"), - ("codex-instructions-sync", ("--migrate-portable",), "--migrate-portable"), - ("codex-rules-sync", ("--append", self.rules), "--append"), - ("codex-config-sync", ("--append", self.rules), "--append"), - ("claude-settings-sync", ("--append", self.rules), "--append")): - with self.subTest(kind=kind, option=option): - result = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "lib/config_sync.py"), kind, - *map(str, arguments), str(self.portable), str(self.live)], - capture_output=True, encoding="utf-8", check=False) - self.assertEqual(result.returncode, 2) - self.assertIn(option, result.stderr) - self.assertFalse(self.live.parent.exists()) - - def test_cli_needs_no_tomlkit(self): - effect = sync.importlib.metadata.PackageNotFoundError("tomlkit") - with mock.patch.object(sync.importlib.metadata, "version", side_effect=effect) as version: - with mock.patch.object(sync.sys, "stdout", new_callable=io.StringIO) as stdout: - with mock.patch.object(sync.sys, "stderr", new_callable=io.StringIO) as stderr: - result = sync.main(["codex-instructions-sync", "--quiet", "--append", str(self.rules), - str(self.portable), str(self.live)]) - self.assertEqual(result, 0, stderr.getvalue()) - self.assertEqual((stdout.getvalue(), stderr.getvalue()), ("", "")) - version.assert_not_called() - self.assertEqual(self.live.read_bytes(), GENERATED) - - def test_stowed_wrapper_runs_on_a_stdlib_only_runtime(self): - runtime = self.directory / "stdlib runtime" - venv.EnvBuilder(with_pip=False).create(runtime) - interpreter = runtime / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - bin_dir = self.directory / "fake home" / ".local" / "bin" - bin_dir.mkdir(parents=True) - # The checkout and the temporary directory may sit on different - # Windows drives, so only the second hop is relative. - intermediary = self.directory / "relative wrapper" - intermediary.symlink_to(ROOT / "common/codex/.local/bin/codex-instructions-sync") - wrapper = bin_dir / "codex-instructions-sync" - wrapper.symlink_to(os.path.relpath(intermediary, bin_dir)) - env = dict(os.environ, DOTFILES_SYNC_PYTHON=str(interpreter)) - result = subprocess.run(self.wrapper_command(wrapper, "--quiet", "--append", self.rules, - self.portable, self.live), - env=env, capture_output=True, encoding="utf-8", check=False) - self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "", "")) - self.assertEqual(self.live.read_bytes(), GENERATED) - result = subprocess.run(self.wrapper_command(wrapper, "--quiet", self.portable, self.live), - env=env, capture_output=True, encoding="utf-8", check=False) - self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "", "")) - self.assertFalse(self.live.exists()) - - -if __name__ == "__main__": - unittest.main()