From 2f8f3032a59bd3fb851dfd86bdd7b6bffde7ea6d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 14:04:04 +0200 Subject: [PATCH 01/10] t3-sheet.mjs: check a sheet's grammar without a catalog checkSheet with no catalog skips the per-target dispatch check and keeps every grammar rule, the role vocabulary, and the panel's two-family rule. The installer's doctor uses it, since a shell installer cannot call orchestrator_capabilities. With a catalog nothing changes. --- .../pstack/skills/setup-pstack/scripts/t3-sheet.mjs | 2 ++ tests/t3-sheet.test.mjs | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs b/plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs index 29ae575b..43587962 100644 --- a/plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs +++ b/plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs @@ -67,6 +67,7 @@ export function parseSheet(markdown) { // role with its `tier`, and the `alias` a released sheet may still use), its // tier defaults, and an `orchestrator_capabilities` catalog. A role with no // line runs on its tier's default, so that default must dispatch here too. +// With no catalog, as in the installer's doctor, only the grammar is checked. export function checkSheet(sheet, { roles, defaults, catalog }) { const problems = []; const byRole = new Map(roles.flatMap((r) => [[r.role, r], ...(r.alias ? [[r.alias, r]] : [])])); @@ -128,6 +129,7 @@ export function checkSheet(sheet, { roles, defaults, catalog }) { } function checkTarget({ providerInstanceId, model, options }, catalog) { + if (!catalog) return []; const at = `${providerInstanceId}/${model}`; const provider = catalog.providers.find((p) => p.providerInstanceId === providerInstanceId); if (!provider) return [`${at}: no provider instance "${providerInstanceId}" in this T3`]; diff --git a/tests/t3-sheet.test.mjs b/tests/t3-sheet.test.mjs index 81d6d867..cc9d58aa 100644 --- a/tests/t3-sheet.test.mjs +++ b/tests/t3-sheet.test.mjs @@ -66,6 +66,16 @@ describe("parseEntry", () => { }); }); +describe("checkSheet without a catalog", () => { + test("checks the grammar and the panel rule, not whether a target dispatches here", () => { + expect(check("bug-fix: someprovider/unlisted-model effort=max\nsession hook: on\n", null)).toEqual([]); + expect(check(`bug-fix: claude-opus\n${PANEL}: someprovider/claude-a, otherprovider/claude-b\n`, null)).toEqual([ + 'bug-fix: "claude-opus" does not start with /', + `${PANEL}: a panel needs at least two model families among its targets (inherit-parent does not count), got claude`, + ]); + }); +}); + describe("checkSheet against a catalog", () => { test("the default sheet setup-pstack writes dispatches", () => { const setup = readFileSync(join(root, "plugins/pstack/skills/setup-pstack/SKILL.md"), "utf8"); From 8fb4980297546e6a37ce60f9ee9753994f0c09be Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 14:04:04 +0200 Subject: [PATCH 02/10] Pin both hooks files, and fail a change no release announces Codex trusts a plugin hook by a hash of its entry, and a Git marketplace upgrades the plugin at every app-server start, so a changed entry stops the hook in every Codex home with nobody told to trust it again. tools/frozen-hooks.json pins each hooks file's digest (its JSON, so formatting does not count) and the release that last changed it. generate.mjs --check fails on an unpinned change, and on a pinned one whose release entry in CHANGES.md does not tell users to rerun the installer. It replaces the test that pinned the 1.0.14 entries literally, which failed on any change, announced or not. The pins hold today's files: hooks.json last changed in 0.9.47 and codex-hooks.json in 0.9.66. --- CONTRIBUTING.md | 4 ++-- tests/generate.test.mjs | 48 ++++++++++++++++++++++++++++++++++--- tests/session-hook.test.mjs | 20 ---------------- tools/frozen-hooks.json | 10 ++++++++ tools/generate.mjs | 48 +++++++++++++++++++++++++++++++++++-- 5 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 tools/frozen-hooks.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8b6d4ad..ddd53bab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ bun tools/generate.mjs GIT_CONFIG_GLOBAL=/dev/null bun test tests/ --timeout 30000 ``` -The generator stamps `VERSION` into the Claude Code and Codex manifests, the marketplace entry, and `package.json`. It renders each model-consuming skill's Models section, interrogate's reviewer table, and setup-pstack's sheet block from `plugins/pstack/models.json`, lists the plugin's agents in the Claude Code manifest, and copies the license and notice files into the installed plugin. It checks each skill's frontmatter, every local Markdown link, the plugin layout, agent frontmatter, the Codex marketplace, and both hooks files. CI runs `bun tools/generate.mjs --check`, which writes nothing and fails naming each generated file that is stale or orphaned, so commit the generator's output. `.pre-commit-config.yaml` runs the same check before each commit once you run `prek install` (or `pre-commit install`). +The generator stamps `VERSION` into the Claude Code and Codex manifests, the marketplace entry, and `package.json`. It renders each model-consuming skill's Models section, interrogate's reviewer table, and setup-pstack's sheet block from `plugins/pstack/models.json`, lists the plugin's agents in the Claude Code manifest, and copies the license and notice files into the installed plugin. It checks each skill's frontmatter, every local Markdown link, the plugin layout, agent frontmatter, the Codex marketplace, and both hooks files against their pins in `tools/frozen-hooks.json`. CI runs `bun tools/generate.mjs --check`, which writes nothing and fails naming each generated file that is stale or orphaned, so commit the generator's output. `.pre-commit-config.yaml` runs the same check before each commit once you run `prek install` (or `pre-commit install`). Change model defaults in `models.json`, never in a skill body. A role names a tier (`default`, `strongest`, or `panel`), and each tier holds T3 entries in the grammar `t3-sheet.mjs` checks. The panel must span two model families. A role may carry an `alias`, the label a sheet written before upstream renamed the role still uses. @@ -75,7 +75,7 @@ uvx zizmor@1.29.0 --persona pedantic --min-severity low --collect all -- . - **A substitution that no longer matches upstream.** Update it to upstream's current sentence or delete it. - **`disable-model-invocation` on `poteto-mode`.** The session-start hook invokes it by name, and the flag makes the Skill tool refuse that. - **A root `plugins/pstack/plugin.json`.** Codex reads a plugin with upstream's Agent Plugins manifest as an Agent Plugin and truncates each injected SKILL.md to 8,000 bytes, which cuts `poteto-mode` off mid-file. `tools/upstream.json` excludes the file. -- **A changed hook entry.** Codex trusts a plugin hook by a hash of its entry, so changing a command, matcher, or timeout in `hooks/hooks.json` or `hooks/codex-hooks.json` silently stops routing until each user trusts it again. Change `session-start.sh` or the routing text instead. +- **A changed hooks file.** Codex trusts a plugin hook by a hash of its entry, and a Git marketplace delivers a new entry at the next app-server start, so any change to `hooks/hooks.json` or `hooks/codex-hooks.json` stops routing in every Codex home until the installer reruns. `tools/frozen-hooks.json` pins both. Change `session-start.sh` or the routing text instead. When an entry must change, pin its new digest with `changedIn` set to the release, and tell users to rerun the installer in that release's `CHANGES.md` entry. - **Stale generated output, or a missing `CHANGES.md` heading for `VERSION`.** - **An unresolved sync conflict marker** anywhere under `plugins/pstack`. - **A missing or escaping local Markdown link.** diff --git a/tests/generate.test.mjs b/tests/generate.test.mjs index 7df7a096..642b82f1 100644 --- a/tests/generate.test.mjs +++ b/tests/generate.test.mjs @@ -25,6 +25,8 @@ import { apply, assertChangesHeading, changes, + frozenHookFaults, + hookDigest, loadModels, parseFrontmatter, plan, @@ -528,7 +530,10 @@ describe("plan, changes, apply", () => { const root = repoCopy(); const hooks = "plugins/pstack/hooks/codex-hooks.json"; writeFileSync(join(root, hooks), readFileSync(join(root, hooks), "utf8").replace("hooks/session-start.ps1", "hooks/gone.ps1")); - expect(problems(root)).toEqual([expect.stringContaining("hooks/gone.ps1 does not exist")]); + expect(problems(root)).toEqual([ + expect.stringContaining("hooks/gone.ps1 does not exist"), + expect.stringContaining(`${hooks} changed, which stops its hook`), + ]); const marketplace = ".agents/plugins/marketplace.json"; writeFileSync(join(root, marketplace), readFileSync(join(root, marketplace), "utf8").replace('"name": "t3-pstack",\n "source"', '"name": "other",\n "source"')); expect(problems(root)).toContainEqual(expect.stringContaining('plugin name "other" != Codex manifest name "t3-pstack"')); @@ -538,16 +543,53 @@ describe("plan, changes, apply", () => { const root = repoCopy(); const hooks = "plugins/pstack/hooks/hooks.json"; const text = readFileSync(join(root, hooks), "utf8"); + const changed = expect.stringContaining(`${hooks} changed, which stops its hook`); writeFileSync(join(root, hooks), text.replace("hooks/session-start.sh", "../../tools/generate.mjs")); - expect(problems(root)).toEqual([expect.stringContaining("../../tools/generate.mjs does not exist in the plugin")]); + expect(problems(root)).toEqual([expect.stringContaining("../../tools/generate.mjs does not exist in the plugin"), changed]); const outside = join(scratch("pstack-outside-"), "start.sh"); writeFileSync(outside, "#!/bin/sh\n", { mode: 0o755 }); symlinkSync(outside, join(root, "plugins/pstack/hooks/linked.sh")); writeFileSync(join(root, hooks), text.replace("hooks/session-start.sh", "hooks/linked.sh")); - expect(problems(root)).toEqual([expect.stringContaining("hooks/linked.sh does not exist in the plugin")]); + expect(problems(root)).toEqual([expect.stringContaining("hooks/linked.sh does not exist in the plugin"), changed]); }); test("problems reports a root with no plugin directory, down to the last check, and does not throw", () => { expect(problems(scratch("pstack-empty-"))).toContainEqual(expect.stringContaining("plugins/pstack/hooks/hooks.json")); }); }); + +describe("frozen hook entries", () => { + const path = "plugins/pstack/hooks/codex-hooks.json"; + const hooks = '{"hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"x"}]}]}}'; + const changed = hooks.replace("startup", "startup|resume"); + const changelog = (body) => `# CHANGES\n\n## 2.1.0 - Next\n\n${body}\n\n## 2.0.0 - Before\n\nRerun the installer.\n`; + + test("pass while each hooks file matches its pin, whatever its formatting", () => { + const pins = { [path]: { sha256: hookDigest(hooks), changedIn: "2.0.0" } }; + const files = { [path]: JSON.stringify(JSON.parse(hooks), null, 2) }; + expect(frozenHookFaults({ pins, files, version: "2.1.0", changelog: changelog("Nothing.") })).toEqual([]); + }); + + test("fail on a change until it is pinned to the release that announces it", () => { + const pins = { [path]: { sha256: hookDigest(hooks), changedIn: "2.0.0" } }; + expect(frozenHookFaults({ pins, files: { [path]: changed }, version: "2.1.0", changelog: changelog("Nothing.") })).toEqual([ + `${path} changed, which stops its hook in every Codex home until the installer reruns. ` + + `Pin "sha256": "${hookDigest(changed)}" and "changedIn": "2.1.0" in tools/frozen-hooks.json, ` + + "and tell users to rerun the installer in CHANGES.md's 2.1.0 entry.", + ]); + const repinned = { [path]: { sha256: hookDigest(changed), changedIn: "2.1.0" } }; + expect(frozenHookFaults({ pins: repinned, files: { [path]: changed }, version: "2.1.0", changelog: changelog("Nothing.") })).toEqual([ + `${path} changes in 2.1.0, but CHANGES.md's 2.1.0 entry does not tell users to rerun the installer`, + ]); + expect( + frozenHookFaults({ pins: repinned, files: { [path]: changed }, version: "2.1.0", changelog: changelog("Rerun the installer.") }), + ).toEqual([]); + }); + + test("fail on a hooks file with no pin and a pin with no file", () => { + expect(frozenHookFaults({ pins: { "a.json": { sha256: "x" } }, files: { [path]: hooks }, version: "2.1.0", changelog: "" })).toEqual([ + `${path} has no pin in tools/frozen-hooks.json`, + "tools/frozen-hooks.json pins a.json, which no manifest names", + ]); + }); +}); diff --git a/tests/session-hook.test.mjs b/tests/session-hook.test.mjs index 9e97f5c7..d5dddec3 100644 --- a/tests/session-hook.test.mjs +++ b/tests/session-hook.test.mjs @@ -70,26 +70,6 @@ describe("SessionStart hook", () => { expect(sessionStart.codex.matcher).toBe("startup|resume|clear|compact"); }); - // Codex trusts a plugin hook by a hash of its entry, so a changed command, - // matcher, or timeout silently stops routing on Codex until the user trusts - // it again. The script and the routing text can change freely. - test("keeps the hook entries released in 1.0.14", () => { - expect(sessionStart.claude).toEqual({ - matcher: "startup|resume|clear|compact", - hooks: [{ type: "command", command: '"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh" claude' }], - }); - expect(sessionStart.codex).toEqual({ - matcher: "startup|resume|clear|compact", - hooks: [ - { - type: "command", - command: '"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh" codex', - commandWindows: 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.ps1"', - }, - ], - }); - }); - test("names only skills that exist", () => { const named = [...mandate.matchAll(/(?:pstack:|`\/)([a-z0-9-]+)/g)].map((m) => m[1]); const skills = new Set(agentSkills(join(pluginRoot, "skills")).map(({ name }) => name)); diff --git a/tools/frozen-hooks.json b/tools/frozen-hooks.json new file mode 100644 index 00000000..3cc4b014 --- /dev/null +++ b/tools/frozen-hooks.json @@ -0,0 +1,10 @@ +{ + "plugins/pstack/hooks/hooks.json": { + "sha256": "95229a0e55867feef1b7e85e21738cf3df63ba66e79d3a2a805c73d683509c3b", + "changedIn": "0.9.47" + }, + "plugins/pstack/hooks/codex-hooks.json": { + "sha256": "40d3c6f0c4b4038d900bb0e84eda481dd1f42e9069506431abe6adfdefba5123", + "changedIn": "0.9.66" + } +} diff --git a/tools/generate.mjs b/tools/generate.mjs index 6a3f1178..19691b33 100644 --- a/tools/generate.mjs +++ b/tools/generate.mjs @@ -20,8 +20,9 @@ // // Also validated: skill frontmatter, the skills tree's links, // the plugin layout, agent frontmatter, the Codex marketplace, and both hooks -// files. +// files against their pins in tools/frozen-hooks.json. +import { createHash } from "node:crypto"; import { existsSync, lstatSync, @@ -378,6 +379,38 @@ export function validateHooks(hooksJson, { statOf, file = "hooks/hooks.json", ro if (faults.length) throw new Error(`${file}:\n ${faults.join("\n ")}`); } +// Codex trusts a plugin hook by a hash of its entry, and a Git marketplace +// delivers a new entry at the next app-server start, so a changed hooks file +// silently stops the hook in every home until the installer trusts it again. +// Each hooks file is pinned with the release that last changed it, and that +// release's CHANGES entry must tell users to rerun the installer. +export const FROZEN_HOOKS = "tools/frozen-hooks.json"; +export const hookDigest = (text) => createHash("sha256").update(JSON.stringify(JSON.parse(text))).digest("hex"); + +export function frozenHookFaults({ pins, files, version, changelog }) { + const faults = Object.keys(files) + .filter((path) => !Object.hasOwn(pins, path)) + .map((path) => `${path} has no pin in ${FROZEN_HOOKS}`); + const entry = changelog.split(/^## /m).find((section) => section.startsWith(`${version} `)) ?? ""; + for (const [path, pin] of Object.entries(pins)) { + if (!Object.hasOwn(files, path)) { + faults.push(`${FROZEN_HOOKS} pins ${path}, which no manifest names`); + continue; + } + const digest = hookDigest(files[path]); + if (digest !== pin.sha256) { + faults.push( + `${path} changed, which stops its hook in every Codex home until the installer reruns. ` + + `Pin "sha256": "${digest}" and "changedIn": "${version}" in ${FROZEN_HOOKS}, ` + + `and tell users to rerun the installer in CHANGES.md's ${version} entry.`, + ); + } else if (pin.changedIn === version && !/rerun the installer/i.test(entry)) { + faults.push(`${path} changes in ${version}, but CHANGES.md's ${version} entry does not tell users to rerun the installer`); + } + } + return faults; +} + // Every file the generator writes, as exact text by repo-relative path, // computed from the sources under `root` without writing. Any other entry in // an owned directory is an orphan. Throws when a source cannot be planned. @@ -489,9 +522,20 @@ export function problems(root, models) { if (!existsSync(full) || !pathIsInside(realpathSync(pluginRoot), realpathSync(full))) return null; return statSync(full); }; - for (const file of ["hooks/hooks.json", ...(codex?.hooks ? [codex.hooks.replace(/^\.\//, "")] : [])]) { + const hookFiles = ["hooks/hooks.json", ...(codex?.hooks ? [codex.hooks.replace(/^\.\//, "")] : [])]; + for (const file of hookFiles) { attempt(() => validateHooks(readFileSync(join(pluginRoot, file), "utf8"), { statOf, file })); } + // A Codex manifest that does not parse names no hooks file to compare. + if (codex) attempt(() => { + const faults = frozenHookFaults({ + pins: JSON.parse(read(FROZEN_HOOKS)), + files: Object.fromEntries(hookFiles.map((file) => [`${PLUGIN}/${file}`, read(`${PLUGIN}/${file}`)])), + version: read("VERSION").trim(), + changelog: read("CHANGES.md"), + }); + if (faults.length) throw new Error(faults.join("\n")); + }); return failures; } From 605059686b4812fe6ea3ed5e67cea5e2f7e8686c Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 14:04:04 +0200 Subject: [PATCH 03/10] Read one model sheet per machine, falling back to the home's Provider instance IDs differ per machine, not per harness, so the sheet moves to ~/.config/t3-pstack/models.md ($XDG_CONFIG_HOME/t3-pstack/ models.md when that is set). The session-start hooks, the routing text, the stamped Models sections, and setup-pstack read it there, and fall back to t3-pstack-models.md in the host's config directory while it is missing, so a home updated before the installer moves the sheet keeps its choices. setup-pstack writes the machine sheet and links this host's old path to it, so an older release reads the same sheet. The hooks files are unchanged; only session-start.sh and .ps1 read the new path. --- CONTEXT.md | 2 +- plugins/pstack/README.md | 2 +- plugins/pstack/hooks/session-start-context.md | 2 +- plugins/pstack/hooks/session-start.ps1 | 17 ++++++-- plugins/pstack/hooks/session-start.sh | 9 +++- plugins/pstack/skills/architect/SKILL.md | 2 +- plugins/pstack/skills/arena/SKILL.md | 2 +- plugins/pstack/skills/how/SKILL.md | 2 +- plugins/pstack/skills/interrogate/SKILL.md | 2 +- plugins/pstack/skills/poteto-help/SKILL.md | 2 +- plugins/pstack/skills/poteto-mode/SKILL.md | 2 +- plugins/pstack/skills/reflect/SKILL.md | 2 +- plugins/pstack/skills/setup-pstack/SKILL.md | 6 +-- plugins/pstack/skills/swarm/SKILL.md | 2 +- plugins/pstack/skills/why/SKILL.md | 2 +- tests/session-hook-windows.test.mjs | 13 +++++- tests/session-hook.test.mjs | 42 ++++++++++++++++++- tools/derive-t3.mjs | 7 ++-- tools/substitutions.json | 2 +- 19 files changed, 92 insertions(+), 28 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f510a749..82e32a70 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,7 +5,7 @@ Names for the concepts this repo's design discussions keep reaching for. Archite - **Host** - the harness a T3 thread runs on and that loads the plugin: Claude Code (`.claude-plugin/`) or Codex (`.codex-plugin/`). Both read the same `plugins/pstack/` tree. The skill text names each host's own form inline where the two differ, such as a skills directory or a question tool. - **T3 form** - upstream's skill text with every Cursor mechanism rewritten into what T3 provides: `delegate_task` children and persona briefs read by path, `schedule_task`, `watch_pull_request`, `t3_thread_*` reads, and sheet entries for models. It lives in the text itself, so a skill needs no second file to run on T3. - **Agent Skill** - one directory under `plugins/pstack/skills/` with portable `name` and `description` frontmatter. Upstream marks every pstack and thermos skill but `setup-pstack` `disable-model-invocation: true`, and the six cursor-team-kit skills carry no flag. The port drops the flag from `poteto-mode` alone, so the session-start hook can invoke it, and `poteto-mode` opens the others by path. -- **Model sheet** - `t3-pstack-models.md` in the host's config directory, one role per line, each value a T3 entry (`/` plus `=` pairs) or `inherit-parent`. `setup-pstack` writes it and `t3-sheet.mjs` checks it against `orchestrator_capabilities`. +- **Model sheet** - `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, one role per line, each value a T3 entry (`/` plus `=` pairs) or `inherit-parent`. `setup-pstack` writes it and `t3-sheet.mjs` checks it against `orchestrator_capabilities`. - **Model policy** - the role defaults in `plugins/pstack/models.json`, as tiers of T3 entries. The generator stamps them into each skill's Models section, interrogate's reviewer table, and setup-pstack's sheet block. - **Role** - a named unit of delegated work with its own model choice (`arena runners`, `how explorer`, `swarm workers`). The role vocabulary lives in `models.json`. - **Panel** - a role whose value is a list (`arena runners`, `architect runners`, `interrogate reviewers`, `arena cross-judge pool`). A panel spans at least two model families; a family is the first segment of a model ID's last `/` part. diff --git a/plugins/pstack/README.md b/plugins/pstack/README.md index 6d83ad85..912b20d7 100644 --- a/plugins/pstack/README.md +++ b/plugins/pstack/README.md @@ -8,7 +8,7 @@ Tell `poteto-mode` your goal and it runs the workflow that fits: reproduce and r - Skills: upstream's skill text, with its Cursor mechanics rewritten for T3. Each appears as `/t3-pstack:`. - Agents: `poteto-agent`, `comment-sicko`, and the two thermo-nuclear reviewers, each read by path in a `delegate_task` brief. -- A session-start hook that tells each chat when to enter `poteto-mode`, unless your `t3-pstack-models.md` sheet turns it off. +- A session-start hook that tells each chat when to enter `poteto-mode`, unless your model sheet turns it off. - Local scripts for watching pull requests, orchestrating multi-phase plans, checking plans, and auditing worktrees. ## Data handling diff --git a/plugins/pstack/hooks/session-start-context.md b/plugins/pstack/hooks/session-start-context.md index 6d697248..f939d835 100644 --- a/plugins/pstack/hooks/session-start-context.md +++ b/plugins/pstack/hooks/session-start-context.md @@ -11,7 +11,7 @@ It routes to the right pstack skill from there. On Codex, open its SKILL.md whol When the intent is already specific, such as TDD, a design sketch, how or why something works, an arena, or an adversarial review, still enter through `t3-pstack:poteto-mode` and open that skill from the bundled path it names. The other pstack skills run only from poteto-mode or when the user types them. -This runs on T3: before the first subagent or panel in a session, read the `t3-pstack-models.md` sheet in this host's config directory. This chat runs the playbook itself. A step it says to delegate goes through `delegate_task` to that role's sheet entry, and the reply lists which provider and model ran each step. +This runs on T3: before the first subagent or panel in a session, read the model sheet, `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), or `t3-pstack-models.md` in this host's config directory when that file is missing. This chat runs the playbook itself. A step it says to delegate goes through `delegate_task` to that role's sheet entry, and the reply lists which provider and model ran each step. User instructions (CLAUDE.md, AGENTS.md, direct requests) take precedence. Other session-start mandates, such as superpowers, still apply. Their skill checks run as before, and when a task meets the criteria above they route implementation through poteto-mode. diff --git a/plugins/pstack/hooks/session-start.ps1 b/plugins/pstack/hooks/session-start.ps1 index 2c59fa0f..cbe93744 100644 --- a/plugins/pstack/hooks/session-start.ps1 +++ b/plugins/pstack/hooks/session-start.ps1 @@ -1,10 +1,19 @@ $ErrorActionPreference = 'Stop' -$sheetRoot = $env:CODEX_HOME -if (-not $sheetRoot) { - $sheetRoot = Join-Path $env:USERPROFILE '.codex' +# One sheet per machine. Until the installer moves an older home's sheet +# there, that home's sheet still counts. +$configRoot = $env:XDG_CONFIG_HOME +if (-not $configRoot) { + $configRoot = Join-Path $env:USERPROFILE '.config' +} +$sheet = Join-Path $configRoot 't3-pstack\models.md' +if (-not (Test-Path -LiteralPath $sheet -PathType Leaf)) { + $sheetRoot = $env:CODEX_HOME + if (-not $sheetRoot) { + $sheetRoot = Join-Path $env:USERPROFILE '.codex' + } + $sheet = Join-Path $sheetRoot 't3-pstack-models.md' } -$sheet = Join-Path $sheetRoot 't3-pstack-models.md' $off = $false if (Test-Path -LiteralPath $sheet -PathType Leaf) { # An unreadable sheet leaves the hook on, as session-start.sh does. diff --git a/plugins/pstack/hooks/session-start.sh b/plugins/pstack/hooks/session-start.sh index 0042738b..5a4d91a7 100755 --- a/plugins/pstack/hooks/session-start.sh +++ b/plugins/pstack/hooks/session-start.sh @@ -5,11 +5,11 @@ set -eu # Literal plugin paths, so a static reader of the hooks files can follow them. case "${1:-}" in claude) - sheet="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/t3-pstack-models.md" + home_sheet="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/t3-pstack-models.md" reader="${CLAUDE_PLUGIN_ROOT}/skills/setup-pstack/scripts/read-sheet.sh" ;; codex) - sheet="${CODEX_HOME:-$HOME/.codex}/t3-pstack-models.md" + home_sheet="${CODEX_HOME:-$HOME/.codex}/t3-pstack-models.md" reader="${CLAUDE_PLUGIN_ROOT}/skills/setup-pstack/scripts/read-sheet.sh" ;; *) @@ -18,6 +18,11 @@ case "${1:-}" in ;; esac +# One sheet per machine. Until the installer moves an older home's sheet +# there, that home's sheet still counts. +sheet="${XDG_CONFIG_HOME:-$HOME/.config}/t3-pstack/models.md" +[ -f "$sheet" ] || sheet=$home_sheet + # A sheet that cannot be decoded counts as missing, so injection stays on. if ! { [ -f "$sheet" ] && [ -r "$sheet" ] && normalized=$(sh "$reader" "$sheet"); }; then normalized= diff --git a/plugins/pstack/skills/architect/SKILL.md b/plugins/pstack/skills/architect/SKILL.md index 7ecc59b5..b1dbc7f3 100644 --- a/plugins/pstack/skills/architect/SKILL.md +++ b/plugins/pstack/skills/architect/SKILL.md @@ -80,6 +80,6 @@ The caller's usage is written first and the type sketch derived from it. One fil ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - architect runners: `claudeAgent/claude-opus-5-5 effort=xhigh`, `codex/gpt-6.1-sol reasoningEffort=xhigh`, `grok/grok-4.7 reasoningEffort=xhigh` diff --git a/plugins/pstack/skills/arena/SKILL.md b/plugins/pstack/skills/arena/SKILL.md index 7688f968..9359dd0f 100644 --- a/plugins/pstack/skills/arena/SKILL.md +++ b/plugins/pstack/skills/arena/SKILL.md @@ -49,7 +49,7 @@ One synthesized artifact, with a short synthesis note alongside naming the base ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - arena runners: `claudeAgent/claude-opus-5-5 effort=xhigh`, `codex/gpt-6.1-sol reasoningEffort=xhigh`, `grok/grok-4.7 reasoningEffort=xhigh` - arena cross-judge pool: `claudeAgent/claude-opus-5-5 effort=xhigh`, `codex/gpt-6.1-sol reasoningEffort=xhigh`, `grok/grok-4.7 reasoningEffort=xhigh` diff --git a/plugins/pstack/skills/how/SKILL.md b/plugins/pstack/skills/how/SKILL.md index 2d136a0d..7bae233f 100644 --- a/plugins/pstack/skills/how/SKILL.md +++ b/plugins/pstack/skills/how/SKILL.md @@ -37,7 +37,7 @@ The explanation uses the sections defined in `references/explainer-prompt.md`, d ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - how explorer: `claudeAgent/claude-opus-5-5 effort=xhigh` - how explainer: `claudeAgent/claude-opus-5-5 effort=xhigh` diff --git a/plugins/pstack/skills/interrogate/SKILL.md b/plugins/pstack/skills/interrogate/SKILL.md index 3fd88022..eded4a39 100644 --- a/plugins/pstack/skills/interrogate/SKILL.md +++ b/plugins/pstack/skills/interrogate/SKILL.md @@ -76,6 +76,6 @@ You are the lead reviewer, a pragmatic senior engineer, not a neutral aggregator ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - interrogate reviewers: `claudeAgent/claude-opus-5-5 effort=xhigh`, `codex/gpt-6.1-sol reasoningEffort=xhigh`, `grok/grok-4.7 reasoningEffort=xhigh` diff --git a/plugins/pstack/skills/poteto-help/SKILL.md b/plugins/pstack/skills/poteto-help/SKILL.md index 18ad5bac..88b5bed4 100644 --- a/plugins/pstack/skills/poteto-help/SKILL.md +++ b/plugins/pstack/skills/poteto-help/SKILL.md @@ -24,7 +24,7 @@ Infer the need from the message and the conversation. A named situation, such as Check the state that changes the answer, and mention it only when it does: -- No model sheet (`t3-pstack-models.md`, see `/setup-pstack`) means `/setup-pstack` hasn't run for this user, so every role uses its default model. +- No model sheet (`~/.config/t3-pstack/models.md`, see `/setup-pstack`) means `/setup-pstack` hasn't run for this user, so every role uses its default model. - No `verify-*` skill or other app harness in the project means agents have no scripted way to drive the app. Mention `/create-verification-skill` when the question is about proving a change works. When the model sheet is missing and it matters, ask whether the user wants to pick a model for each role and a reasoning budget now. It matters when the user is new, the question is about setup or cost, or the answer depends on which models run. Ask at most once per chat. If the need is also unclear, ask both questions together. Offer two choices: diff --git a/plugins/pstack/skills/poteto-mode/SKILL.md b/plugins/pstack/skills/poteto-mode/SKILL.md index d113c7d2..344539fd 100644 --- a/plugins/pstack/skills/poteto-mode/SKILL.md +++ b/plugins/pstack/skills/poteto-mode/SKILL.md @@ -159,7 +159,7 @@ A large or cross-cutting effort (a migration across many call sites, an ambitiou ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - feature, refactoring: `claudeAgent/claude-opus-5-5 effort=xhigh` - bug-fix: `claudeAgent/claude-fable-5-1 effort=xhigh` diff --git a/plugins/pstack/skills/reflect/SKILL.md b/plugins/pstack/skills/reflect/SKILL.md index 7c9bfbad..9f5d1a1e 100644 --- a/plugins/pstack/skills/reflect/SKILL.md +++ b/plugins/pstack/skills/reflect/SKILL.md @@ -78,7 +78,7 @@ Short list, no preamble: ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - reflect tooling: `claudeAgent/claude-opus-5-5 effort=xhigh` - reflect judgment, divergent, synthesizer: `claudeAgent/claude-opus-5-5 effort=xhigh` diff --git a/plugins/pstack/skills/setup-pstack/SKILL.md b/plugins/pstack/skills/setup-pstack/SKILL.md index 112cf432..548a458d 100644 --- a/plugins/pstack/skills/setup-pstack/SKILL.md +++ b/plugins/pstack/skills/setup-pstack/SKILL.md @@ -5,7 +5,7 @@ description: Configure which T3 provider and model pstack uses per role and at w # Setup pstack -Write the model sheet, `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). It sets pstack's T3 provider and model per role, and every pstack skill reads it before it dispatches. +Write the model sheet, `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine. It sets pstack's T3 provider and model per role, and every pstack skill reads it before it dispatches. ## Steps @@ -15,7 +15,7 @@ Call `orchestrator_capabilities`, or `acp-mcp-call orchestrator_capabilities '{} ### 2. Load current state -The default role-to-target mapping is the sheet shape in step 5 below. If the sheet already exists, read it and treat its `# budget` line and its role values as the current choices. Otherwise start from those defaults. A line whose role is not in step 5, such as `how critics`, is from a retired role. Drop it. Carry a `strongest judgment` value over to `hardest tasks`, its new name. Also drop any value that is not a T3 entry, such as a bare family name, a `claude-` slug, or an `@` suffix, and a `default effort` line. +The default role-to-target mapping is the sheet shape in step 5 below. If the sheet already exists, read it and treat its `# budget` line and its role values as the current choices. If it does not, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), where releases before 2.0.0-rc.3 kept the sheet, the same way. Otherwise start from those defaults. A line whose role is not in step 5, such as `how critics`, is from a retired role. Drop it. Carry a `strongest judgment` value over to `hardest tasks`, its new name. Also drop any value that is not a T3 entry, such as a bare family name, a `claude-` slug, or an `@` suffix, and a `default effort` line. ### 3. Budget, map, and confirm @@ -38,7 +38,7 @@ Write the candidate to a temp file. Call `orchestrator_capabilities` again and s ### 5. Write the sheet -Overwrite the sheet with the candidate, writing through any symlink at its path (resolve it with `realpath`). One sheet serves both hosts, so link the other host's sheet path to it with `ln -s`. If a different file is already there, run the checker on it and ask before you replace it. Overwrite the whole file so re-runs stay idempotent. On Codex, remove any pstack model rows an earlier setup pasted into `/AGENTS.md`. With every default kept, the sheet reads: +Overwrite the sheet with the candidate, creating its directory and writing through any symlink at its path (resolve it with `realpath`). If a different file is already there, run the checker on it and ask before you replace it. Overwrite the whole file so re-runs stay idempotent. Then replace `t3-pstack-models.md` in the host's config directory with a link to the sheet (`ln -sf`), so an older t3-pstack reads the same sheet. Step 2 already read what it held. On Codex, remove any pstack model rows an earlier setup pasted into `/AGENTS.md`. With every default kept, the sheet reads: ```markdown # pstack model configuration (T3). One line per role. Delete a line to fall back to the skill default. diff --git a/plugins/pstack/skills/swarm/SKILL.md b/plugins/pstack/skills/swarm/SKILL.md index c529eb05..134c74ec 100644 --- a/plugins/pstack/skills/swarm/SKILL.md +++ b/plugins/pstack/skills/swarm/SKILL.md @@ -47,6 +47,6 @@ Return one consolidated in-chat report with the table, issue one-liners, gaps or ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - swarm workers: `claudeAgent/claude-opus-5-5 effort=xhigh` diff --git a/plugins/pstack/skills/why/SKILL.md b/plugins/pstack/skills/why/SKILL.md index 56607873..242086bc 100644 --- a/plugins/pstack/skills/why/SKILL.md +++ b/plugins/pstack/skills/why/SKILL.md @@ -99,7 +99,7 @@ After Sources Consulted, if the user's `why` question is a precursor to changing ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `/` plus `=` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `/`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - why investigators: `claudeAgent/claude-opus-5-5 effort=xhigh` - why synthesizer: `claudeAgent/claude-opus-5-5 effort=xhigh` diff --git a/tests/session-hook-windows.test.mjs b/tests/session-hook-windows.test.mjs index ec3ff828..3a65c6ac 100644 --- a/tests/session-hook-windows.test.mjs +++ b/tests/session-hook-windows.test.mjs @@ -12,7 +12,7 @@ const manifest = JSON.parse(readFileSync(join(pluginRoot, ".codex-plugin/plugin. const handler = JSON.parse(readFileSync(join(pluginRoot, manifest.hooks), "utf8")).hooks.SessionStart[0].hooks[0]; const mandate = readFileSync(join(pluginRoot, "hooks/session-start-context.md"), "utf8"); -function runHook({ sheet, codexHome, homeSheet, context = mandate, host = "cmd" } = {}) { +function runHook({ sheet, codexHome, homeSheet, machineSheet, context = mandate, host = "cmd" } = {}) { const fixture = mkdtempSync(join(tmpdir(), "pstack windows hook ")); try { const plugin = join(fixture, "plugin with spaces"); @@ -25,6 +25,10 @@ function runHook({ sheet, codexHome, homeSheet, context = mandate, host = "cmd" writeSheet(join(dir, "t3-pstack-models.md"), content); } } + if (machineSheet !== undefined) { + mkdirSync(join(profile, ".config/t3-pstack"), { recursive: true }); + writeSheet(join(profile, ".config/t3-pstack/models.md"), machineSheet); + } const contextPath = join(plugin, "hooks/session-start-context.md"); if (context === null) rmSync(contextPath); else writeFileSync(contextPath, context); @@ -82,6 +86,13 @@ describe.skipIf(process.platform !== "win32" && !required)("Windows Codex Sessio }); }); + test("the machine sheet decides over the Codex home sheet", () => { + expect(runHook({ machineSheet: "session hook: off\n", sheet: "session hook: on\n" })).toEqual({ status: 0, out: "", err: "" }); + expect(runHook({ machineSheet: "session hook: on\n", sheet: "session hook: off\n" })).toEqual({ + status: 0, out: mandate, err: "", + }); + }); + for (const { name, sheet, off } of sheetCases) { test(`${off ? "injects nothing" : "injects the context"} when the sheet has ${name}`, () => { expect(runHook({ sheet })).toEqual({ status: 0, out: off ? "" : mandate, err: "" }); diff --git a/tests/session-hook.test.mjs b/tests/session-hook.test.mjs index d5dddec3..a907b4b0 100644 --- a/tests/session-hook.test.mjs +++ b/tests/session-hook.test.mjs @@ -1,5 +1,6 @@ // Each runtime's shipped SessionStart command, run for real: the mandate is -// injected unless that runtime's model sheet turns it off. +// injected unless the model sheet turns it off. The sheet is the machine's, +// or, until the installer moves it there, that runtime's home sheet. import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -112,7 +113,44 @@ describe("SessionStart hook", () => { } }); -describe("each host reads only its own sheet", () => { +describe("the machine sheet", () => { + const run = (runtime, home, env = {}) => + spawnSync("sh", ["-c", sessionStart[runtime].hooks[0].command], { + env: { PATH: process.env.PATH, HOME: home, CLAUDE_PLUGIN_ROOT: pluginRoot, ...env }, + encoding: "utf8", + }).stdout; + + test.each(["claude", "codex"])("decides over the %s home sheet", (runtime) => { + const home = mkdtempSync(join(tmpdir(), "pstack-hook-")); + try { + const machineSheet = join(home, ".config/t3-pstack/models.md"); + mkdirSync(join(home, ".config/t3-pstack"), { recursive: true }); + mkdirSync(join(home, `.${runtime}`)); + writeFileSync(machineSheet, "session hook: off\n"); + writeFileSync(join(home, `.${runtime}`, "t3-pstack-models.md"), "session hook: on\n"); + expect(run(runtime, home)).toBe(""); + writeFileSync(machineSheet, "session hook: on\n"); + writeFileSync(join(home, `.${runtime}`, "t3-pstack-models.md"), "session hook: off\n"); + expect(run(runtime, home)).toBe(mandate); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test.each(["claude", "codex"])("moves with XDG_CONFIG_HOME on %s", (runtime) => { + const home = mkdtempSync(join(tmpdir(), "pstack-hook-")); + try { + mkdirSync(join(home, "xdg/t3-pstack"), { recursive: true }); + writeFileSync(join(home, "xdg/t3-pstack/models.md"), "session hook: off\n"); + expect(run(runtime, home)).toBe(mandate); + expect(run(runtime, home, { XDG_CONFIG_HOME: join(home, "xdg") })).toBe(""); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + +describe("without a machine sheet, each host reads only its own home sheet", () => { test("an off sheet in the other host's config directory leaves injection on", () => { const home = mkdtempSync(join(tmpdir(), "pstack-hook-")); try { diff --git a/tools/derive-t3.mjs b/tools/derive-t3.mjs index c04aa821..e76cca94 100644 --- a/tools/derive-t3.mjs +++ b/tools/derive-t3.mjs @@ -77,9 +77,10 @@ export const fenceUnder = (title, lang) => (lines) => { }; const SHEET = - "A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config " + - "directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and " + - "`/setup-pstack` writes it. An entry is `/` plus `=` pairs, and it " + + "A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` " + + "(`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. " + + "When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or " + + "`~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `/` plus `=` pairs, and it " + "maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, " + "and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's " + "`/`, is written whole after it. Option IDs differ per provider, so take them from " + diff --git a/tools/substitutions.json b/tools/substitutions.json index e6e83042..f0f4de10 100644 --- a/tools/substitutions.json +++ b/tools/substitutions.json @@ -14,7 +14,7 @@ }, { "pattern": "- No `~/.cursor/rules/pstack-models.mdc` means `/setup-pstack` hasn't run for this user", - "replacement": "- No model sheet (`t3-pstack-models.md`, see `/setup-pstack`) means `/setup-pstack` hasn't run for this user", + "replacement": "- No model sheet (`~/.config/t3-pstack/models.md`, see `/setup-pstack`) means `/setup-pstack` hasn't run for this user", "rationale": "the T3 sheet replaces the Cursor rule (The sheet)", "files": "^skills/poteto-help/SKILL\\.md$" }, From 2078777d5d28234fef2cf23505fda4b2c18c9041 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 14:04:04 +0200 Subject: [PATCH 04/10] Add the installer and doctor for Claude Code and Codex npx -y github:chhoumann/t3-pstack runs tools/install.mjs, the package's t3-pstack bin, on Node 20 or later with no dependencies. It serves every Claude Code and Codex home T3 can run: ~/.claude and ~/.codex, CLAUDE_CONFIG_DIR and CODEX_HOME, each claudeAgent and codex instance in ${T3CODE_HOME:-~/.t3}/userdata/ settings.json, resolved as T3 resolves it and disabled ones included, and any --claude-home or --codex-home. A Codex shadow home is skipped, since it links into its shared home. Per Claude Code home it adds the marketplace, installs at user scope or, when the home lags the installer's version, refreshes the marketplace and updates, enables the plugin, turns on marketplace auto-update, and brings any stale record at another scope to the user-scope install, after a backup of plugins/installed_plugins.json. A second config home such as ~/.claude_pool keeps re-acquiring a project-scope record for the home folder, because Claude Code reads ~/.claude/settings.json as that folder's project settings, so `claude plugin uninstall --scope project` there would edit the main home's settings. Per Codex home it adds the marketplace, installs or updates when the home lags, and trusts the session-start hook through codex app-server: hooks/list, then config/value/write of hooks.state with the hash Codex reports and an upsert, checked by a second hooks/list. Then it moves the model sheet to its machine path once and links each home's old path to it. Every step converges: a home at the installer's version gets only what it lacks, and a second run writes nothing. doctor checks each home's version, enabled state, hook trust, other-scope records, plain copies of the plugin's skills, and the sheet, and exits 1 with a fix per problem. tests/install.test.mjs covers home discovery, the record repair, and the sheet move on constructed homes. tests/install-e2e.test.mjs, opt-in with T3_PSTACK_E2E=1, drives the real CLIs in a throwaway HOME: before the installer a T3-shaped Codex session runs no hook and its rollout has no routing text; after it the hook runs and the rollout has it; a rerun leaves every config file unchanged; and a release with a changed hook entry is updated and re-trusted. --- CONTEXT.md | 2 + CONTRIBUTING.md | 6 + package.json | 5 +- tests/install-e2e.test.mjs | 277 ++++++++++++++++++++++++++++++++++++ tests/install.test.mjs | 280 +++++++++++++++++++++++++++++++++++++ tools/install.mjs | 148 ++++++++++++++++++++ tools/install/claude.mjs | 123 ++++++++++++++++ tools/install/codex.mjs | 174 +++++++++++++++++++++++ tools/install/common.mjs | 60 ++++++++ tools/install/homes.mjs | 74 ++++++++++ tools/install/sheet.mjs | 87 ++++++++++++ 11 files changed, 1235 insertions(+), 1 deletion(-) create mode 100644 tests/install-e2e.test.mjs create mode 100644 tests/install.test.mjs create mode 100755 tools/install.mjs create mode 100644 tools/install/claude.mjs create mode 100644 tools/install/codex.mjs create mode 100644 tools/install/common.mjs create mode 100644 tools/install/homes.mjs create mode 100644 tools/install/sheet.mjs diff --git a/CONTEXT.md b/CONTEXT.md index 82e32a70..077ea88c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -6,6 +6,8 @@ Names for the concepts this repo's design discussions keep reaching for. Archite - **T3 form** - upstream's skill text with every Cursor mechanism rewritten into what T3 provides: `delegate_task` children and persona briefs read by path, `schedule_task`, `watch_pull_request`, `t3_thread_*` reads, and sheet entries for models. It lives in the text itself, so a skill needs no second file to run on T3. - **Agent Skill** - one directory under `plugins/pstack/skills/` with portable `name` and `description` frontmatter. Upstream marks every pstack and thermos skill but `setup-pstack` `disable-model-invocation: true`, and the six cursor-team-kit skills carry no flag. The port drops the flag from `poteto-mode` alone, so the session-start hook can invoke it, and `poteto-mode` opens the others by path. - **Model sheet** - `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, one role per line, each value a T3 entry (`/` plus `=` pairs) or `inherit-parent`. `setup-pstack` writes it and `t3-sheet.mjs` checks it against `orchestrator_capabilities`. +- **Installer** - `tools/install.mjs`, the package's `t3-pstack` bin, run as `npx -y github:chhoumann/t3-pstack `. It installs the plugin in every Claude Code and Codex home T3 can run, trusts the Codex session-start hook, moves the model sheet to its machine path, and checks the result. +- **Frozen hooks file** - `hooks/hooks.json` or `hooks/codex-hooks.json`, pinned in `tools/frozen-hooks.json`. Codex trusts a hook by a hash of its entry, so a change stops the hook in every Codex home until the installer reruns. What the hook says lives in `session-start.sh` and the routing text, which change freely. - **Model policy** - the role defaults in `plugins/pstack/models.json`, as tiers of T3 entries. The generator stamps them into each skill's Models section, interrogate's reviewer table, and setup-pstack's sheet block. - **Role** - a named unit of delegated work with its own model choice (`arena runners`, `how explorer`, `swarm workers`). The role vocabulary lives in `models.json`. - **Panel** - a role whose value is a list (`arena runners`, `architect runners`, `interrogate reviewers`, `arena cross-judge pool`). A panel spans at least two model families; a family is the first segment of a model ID's last `/` part. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddd53bab..7914a7b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,6 +63,12 @@ bunx prettier@3.6.2 --check watch-pr/settle.ts watch-pr/settle.test.ts resolve-c Upstream's scripts are not Prettier output, so the format check covers the port's own TypeScript only. +If you touched the installer (`tools/install.mjs` or `tools/install/`), also run its end-to-end test. It needs the `claude` and `codex` CLIs, runs in a throwaway `HOME`, and takes a few minutes: + +```shell +T3_PSTACK_E2E=1 GIT_CONFIG_GLOBAL=/dev/null bun test tests/install-e2e.test.mjs --timeout 300000 +``` + If you touched a workflow, audit it before pushing: ```shell diff --git a/package.json b/package.json index 0ebe5793..52eadf16 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "t3-pstack", "private": true, "version": "2.0.0-rc.2", - "description": "Development tooling for t3-pstack, Lauren Tan's pstack rewritten in place for T3 Code. The plugin itself lives in plugins/pstack.", + "description": "The installer and development tooling for t3-pstack, Lauren Tan's pstack rewritten in place for T3 Code. The plugin itself lives in plugins/pstack.", "author": { "name": "Christian Bager Bach Houmann", "url": "https://github.com/chhoumann" @@ -10,6 +10,9 @@ "homepage": "https://github.com/chhoumann/t3-pstack", "repository": "https://github.com/chhoumann/t3-pstack", "license": "MIT", + "bin": { + "t3-pstack": "tools/install.mjs" + }, "devDependencies": { "typebox": "1.3.34" } diff --git a/tests/install-e2e.test.mjs b/tests/install-e2e.test.mjs new file mode 100644 index 00000000..91b2f2ef --- /dev/null +++ b/tests/install-e2e.test.mjs @@ -0,0 +1,277 @@ +// The installer end to end, with the real claude and codex CLIs, in a +// throwaway HOME that T3's settings file describes. Opt in with +// T3_PSTACK_E2E=1; it needs both CLIs and takes about a minute. This checkout +// is the marketplace source. The Codex probe turn has no credentials, so it +// fails at the model call, after the session-start hook has run. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { AppServer } from "../tools/install/codex.mjs"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const version = JSON.parse(readFileSync(join(repoRoot, "plugins/pstack/.claude-plugin/plugin.json"), "utf8")).version; +const ROUTING = "You have t3-pstack, pstack for T3 Code."; +const SHEET = "# budget: large (xhigh)\nbug-fix: inherit-parent\nsession hook: on\n"; + +let root; +let home; +const saved = {}; +const ISOLATED = ["HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "T3CODE_HOME", "XDG_CONFIG_HOME", "GIT_CONFIG_GLOBAL"]; + +function sh(bin, args, env = {}) { + const r = spawnSync(bin, args, { cwd: root, env: { ...process.env, ...env }, encoding: "utf8" }); + expect(r.status, `${bin} ${args.join(" ")}\n${r.stdout}\n${r.stderr}`).toBe(0); + return r.stdout; +} + +function installer(command, source = repoRoot) { + const r = spawnSync("node", [join(repoRoot, "tools/install.mjs"), command, "--source", source, "--json"], { + cwd: root, + env: process.env, + encoding: "utf8", + }); + return { status: r.status, report: JSON.parse(r.stdout) }; +} + +const row = (report, dir) => report.homes.find((h) => h.dir === join(home, dir)); +const failing = (checks) => checks.filter((c) => !c.ok && !c.warn).map((c) => `${c.name}: ${c.detail}`); + +async function until(done, ms) { + for (const deadline = Date.now() + ms; Date.now() < deadline; await new Promise((r) => setTimeout(r, 200))) { + if (done()) return true; + } + return false; +} + +// A T3-shaped Codex session: thread/start, then one turn. SessionStart runs +// at the first turn, before the model call. +async function probe(codexHome) { + const server = await AppServer.start({ dir: codexHome, bin: "codex" }); + try { + const { thread } = await server.request("thread/start", { cwd: root, approvalPolicy: "never", sandbox: "read-only" }); + await server.request("turn/start", { threadId: thread.id, input: [{ type: "text", text: "ping" }] }); + expect(await until(() => server.notifications.some((m) => m.method === "turn/completed"), 90_000), "turn completed").toBe(true); + const rollout = readFileSync(thread.path, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + const developer = rollout + .filter((item) => item.payload?.type === "message" && item.payload.role === "developer") + .flatMap((item) => item.payload.content.map((part) => part.text ?? "")); + return { + hookEvents: server.notifications.filter((m) => m.method.startsWith("hook/")).map((m) => m.method), + routedMessages: developer.filter((text) => text.includes(ROUTING)).length, + rollout: thread.path, + }; + } finally { + server.close(); + } +} + +// Every config file in every home, and the sheet, by content. +function snapshot() { + const out = {}; + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (!["cache", "marketplaces", ".tmp", "sessions", "log", "skills", "shell_snapshots"].includes(entry.name)) walk(path); + } else if (/\.(json|toml|md)$/.test(entry.name)) { + out[relative(home, path)] = entry.isSymbolicLink() + ? `-> ${readlinkSync(path)}` + : createHash("sha256").update(readFileSync(path)).digest("hex"); + } + } + }; + walk(home); + return out; +} + +const enabled = process.env.T3_PSTACK_E2E === "1"; + +// A fresh HOME per suite, with T3's settings listing `providerInstances`. +function isolate(providerInstances) { + beforeAll(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "t3-pstack-e2e-"))); + home = join(root, "home"); + for (const name of ISOLATED) saved[name] = process.env[name]; + for (const name of ISOLATED) delete process.env[name]; + Object.assign(process.env, { HOME: home, GIT_CONFIG_GLOBAL: "/dev/null" }); + mkdirSync(join(home, ".t3/userdata"), { recursive: true }); + writeFileSync(join(home, ".t3/userdata/settings.json"), JSON.stringify({ providerInstances })); + }); + + afterAll(() => { + for (const name of ISOLATED) { + if (saved[name] === undefined) delete process.env[name]; + else process.env[name] = saved[name]; + } + rmSync(root, { recursive: true, force: true }); + }); +} + +describe.skipIf(!enabled)("the installer, end to end", () => { + isolate({ + claudeAgent: { driver: "claudeAgent", enabled: true, config: { binaryPath: "claude", homePath: "" } }, + claude_pool: { driver: "claudeAgent", enabled: true, config: { homePath: "~/.claude-pool" } }, + codex: { driver: "codex", enabled: true, config: { binaryPath: "codex", homePath: "" } }, + codex_work: { driver: "codex", enabled: false, config: { homePath: "~/.codex-work" } }, + }); + + test("before: a hand-installed Codex home never runs the hook, and doctor says so", async () => { + // The pre-installer README way, and the released per-home sheet. + const codexHome = join(home, ".codex"); + mkdirSync(codexHome, { recursive: true }); + sh("codex", ["plugin", "marketplace", "add", repoRoot], { CODEX_HOME: codexHome }); + sh("codex", ["plugin", "add", "t3-pstack@t3-pstack"], { CODEX_HOME: codexHome }); + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync(join(home, ".claude/t3-pstack-models.md"), SHEET); + symlinkSync(join(home, ".claude/t3-pstack-models.md"), join(codexHome, "t3-pstack-models.md")); + + // A second Claude home with a stale project-scope record for the home folder. + const pool = join(home, ".claude-pool"); + mkdirSync(pool, { recursive: true }); + sh("claude", ["plugin", "marketplace", "add", repoRoot], { CLAUDE_CONFIG_DIR: pool }); + sh("claude", ["plugin", "install", "t3-pstack@t3-pstack", "--scope", "user"], { CLAUDE_CONFIG_DIR: pool }); + const recordsPath = join(pool, "plugins/installed_plugins.json"); + const records = JSON.parse(readFileSync(recordsPath, "utf8")); + records.plugins["t3-pstack@t3-pstack"].push({ + scope: "project", + projectPath: home, + installPath: join(pool, "plugins/cache/t3-pstack/t3-pstack/1.0.11"), + version: "1.0.11", + installedAt: "2026-10-13T04:43:26.759Z", + lastUpdated: "2026-10-13T04:43:26.759Z", + }); + writeFileSync(recordsPath, JSON.stringify(records, null, 2) + "\n"); + + const before = await probe(codexHome); + console.log("before:", JSON.stringify(before)); + expect(before.hookEvents).toEqual([]); + expect(before.routedMessages).toBe(0); + + const { status, report } = installer("doctor"); + expect(status).toBe(1); + expect(failing(row(report, ".codex").checks)).toEqual([ + "session-start hook trusted: t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0 untrusted", + ]); + expect(failing(row(report, ".claude-pool").checks)).toEqual([ + `other-scope records: project ${home} at 1.0.11`, + ]); + expect(failing(row(report, ".codex-work").checks)).toEqual(["installed: the home does not exist"]); + expect(failing(report.sheet.checks)).toEqual([ + `machine sheet: missing; ${join(home, ".claude/t3-pstack-models.md")}, ${join(home, ".codex/t3-pstack-models.md")} not moved yet`, + ]); + }, 300_000); + + test("install: every home converges, the hook is trusted, and its routing text reaches the rollout", async () => { + const { status, report } = installer("install"); + console.log("install:", JSON.stringify(report.homes.map(({ dir, steps }) => ({ dir, steps })).concat(report.sheet.steps))); + expect(failing([...report.homes.flatMap((h) => h.checks), ...report.sheet.checks])).toEqual([]); + expect(status).toBe(0); + expect(row(report, ".codex").steps).toEqual([ + "trusted hook t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0 (was untrusted)", + ]); + expect(row(report, ".codex-work").steps).toEqual([ + `added the t3-pstack marketplace from ${repoRoot.replace(/\/$/, "")}`, + `installed ${version}`, + "trusted hook t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0 (was untrusted)", + ]); + const pool = row(report, ".claude-pool").steps; + expect(pool[0]).toBe(`moved the project-scope record for ${home} from 1.0.11 to ${version}`); + expect(pool[1]).toStartWith("backed up plugins/installed_plugins.json to "); + expect(report.sheet.steps).toEqual([ + `moved the sheet from ${join(home, ".claude/t3-pstack-models.md")}`, + `linked ${join(home, ".claude/t3-pstack-models.md")} to it`, + `linked ${join(home, ".claude-pool/t3-pstack-models.md")} to it`, + `linked ${join(home, ".codex-work/t3-pstack-models.md")} to it`, + ]); + expect(readFileSync(join(home, ".config/t3-pstack/models.md"), "utf8")).toBe(SHEET); + + const after = await probe(join(home, ".codex")); + console.log("after:", JSON.stringify(after)); + expect(after.hookEvents).toEqual(["hook/started", "hook/completed"]); + expect(after.routedMessages).toBe(1); + + // Claude Code needs no trust; the same routing text arrives through its hook. + const claude = spawnSync("claude", ["-p", "ping", "--output-format", "stream-json", "--verbose"], { + cwd: root, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, ".claude-pool"), + ANTHROPIC_API_KEY: "sk-ant-invalid", + ANTHROPIC_BASE_URL: "http://127.0.0.1:9", + }, + encoding: "utf8", + timeout: 120_000, + }); + const hookResponse = claude.stdout + .split("\n") + .filter((line) => line.includes('"hook_response"')) + .map((line) => JSON.parse(line)) + .find((event) => event.hook_event === "SessionStart"); + expect(hookResponse?.output ?? hookResponse?.stdout ?? "", "Claude Code's SessionStart hook_response").toContain(ROUTING); + console.log("claude:", JSON.stringify({ hook: hookResponse.hook_name, routed: true })); + }, 300_000); + + test("a second install changes nothing, and doctor passes", () => { + const before = snapshot(); + const { status, report } = installer("update"); + expect(status).toBe(0); + expect([...report.homes.flatMap((h) => h.steps), ...report.sheet.steps]).toEqual([]); + expect(snapshot()).toEqual(before); + console.log(`rerun: ${Object.keys(before).length} config files unchanged`); + expect(installer("doctor").status).toBe(0); + expect(existsSync(join(home, ".codex-work/config.toml"))).toBe(true); + }, 300_000); +}); + +describe.skipIf(!enabled)("the installer, on a release that changes the hook entry", () => { + isolate({}); + + test("updates both homes and trusts the changed hook again", async () => { + const source = join(root, "source"); + for (const path of [".claude-plugin", ".agents", "plugins"]) { + cpSync(join(repoRoot, path), join(source, path), { recursive: true, filter: (src) => !src.includes("node_modules") }); + } + expect(installer("install", source).status).toBe(0); + + const next = "9.9.9"; + for (const file of [".claude-plugin/marketplace.json", "plugins/pstack/.claude-plugin/plugin.json", "plugins/pstack/.codex-plugin/plugin.json"]) { + const path = join(source, file); + writeFileSync(path, readFileSync(path, "utf8").replace(`"version": "${version}"`, `"version": "${next}"`)); + } + const hooks = join(source, "plugins/pstack/hooks/codex-hooks.json"); + writeFileSync(hooks, readFileSync(hooks, "utf8").replace("startup|resume|clear|compact", "startup|resume|compact|clear")); + + const { status, report } = installer("update", source); + console.log("release:", JSON.stringify(report.homes.map(({ dir, steps }) => ({ dir: relative(home, dir), steps })))); + expect(failing([...report.homes.flatMap((h) => h.checks), ...report.sheet.checks])).toEqual([]); + expect(status).toBe(0); + expect(row(report, ".claude").steps).toEqual([`updated ${version} to ${next}`]); + expect(row(report, ".codex").steps).toEqual([ + `updated ${version} to ${next}`, + "trusted hook t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0 (was modified)", + ]); + const after = await probe(join(home, ".codex")); + expect(after.hookEvents).toEqual(["hook/started", "hook/completed"]); + expect(after.routedMessages).toBe(1); + }, 300_000); +}); diff --git a/tests/install.test.mjs b/tests/install.test.mjs new file mode 100644 index 00000000..077f006f --- /dev/null +++ b/tests/install.test.mjs @@ -0,0 +1,280 @@ +// The installer's file-level behavior, on constructed homes: which homes it +// finds, how it repairs a stale Claude Code record, how it moves the model +// sheet, and what doctor reports. tests/install-e2e.test.mjs drives the real +// CLIs. +import { afterEach, describe, expect, test } from "bun:test"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { doctorClaude, syncScopedRecords } from "../tools/install/claude.mjs"; +import { discoverHomes } from "../tools/install/homes.mjs"; +import { doctorSheet, moveSheet } from "../tools/install/sheet.mjs"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const { roles, tiers } = JSON.parse(readFileSync(join(repoRoot, "plugins/pstack/models.json"), "utf8")); +const FIX = "rerun `npx -y github:chhoumann/t3-pstack install`"; + +const scratch = []; +function tmp() { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "t3-pstack-install-"))); + scratch.push(dir); + return dir; +} +afterEach(() => { + while (scratch.length) rmSync(scratch.pop(), { recursive: true, force: true }); +}); +const writeJson = (path, value) => { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, JSON.stringify(value, null, 2) + "\n"); +}; + +describe("discoverHomes", () => { + test("resolves each home as T3 does and merges the sources that name it", () => { + const t3 = tmp(); + writeJson(join(t3, "userdata/settings.json"), { + providerInstances: { + claudeAgent: { driver: "claudeAgent", enabled: true, config: { binaryPath: "claude", homePath: "" } }, + claude_pool: { driver: "claudeAgent", config: { homePath: "~/.claude_pool", binaryPath: "/opt/bin/claude" } }, + claude_env: { driver: "claudeAgent", environment: [{ name: "CLAUDE_CONFIG_DIR", value: "/opt/claude-env" }] }, + codex: { driver: "codex", config: { homePath: "", shadowHomePath: "~/.codex-acct2" } }, + codex_pool: { driver: "codex", enabled: false, config: { homePath: "/home/u/.codex-pool" } }, + codex_env: { driver: "codex", config: { enabled: false }, environment: [{ name: "CODEX_HOME", value: "/opt/codex-env" }] }, + grok: { driver: "grok", enabled: true }, + }, + }); + const found = discoverHomes({ + env: { T3CODE_HOME: t3, CODEX_HOME: "/home/u/.codex-acct2", CLAUDE_CONFIG_DIR: "/srv/claude" }, + home: "/home/u", + claudeHomes: ["~/.claude_pool"], + codexHomes: ["/data/codex"], + }); + expect(found).toEqual({ + homes: [ + { harness: "claude", dir: "/home/u/.claude", from: ["default", "T3 instance claudeAgent"], bin: "claude" }, + { harness: "codex", dir: "/home/u/.codex", from: ["default", "T3 instance codex"], bin: "codex" }, + { harness: "claude", dir: "/home/u/.claude_pool", from: ["T3 instance claude_pool", "--claude-home"], bin: "/opt/bin/claude" }, + { harness: "claude", dir: "/opt/claude-env", from: ["T3 instance claude_env"], bin: "claude" }, + { harness: "codex", dir: "/home/u/.codex-pool", from: ["T3 instance codex_pool (disabled)"], bin: "codex" }, + { harness: "codex", dir: "/opt/codex-env", from: ["T3 instance codex_env (disabled)"], bin: "codex" }, + { harness: "claude", dir: "/srv/claude", from: ["CLAUDE_CONFIG_DIR"], bin: "claude" }, + { harness: "codex", dir: "/data/codex", from: ["--codex-home"], bin: "codex" }, + ], + settings: { path: join(t3, "userdata/settings.json"), instances: 6, error: undefined }, + }); + }); + + test("falls back to the defaults when T3's settings are missing", () => { + const t3 = tmp(); + expect(discoverHomes({ env: { T3CODE_HOME: t3 }, home: "/home/u" })).toEqual({ + homes: [ + { harness: "claude", dir: "/home/u/.claude", from: ["default"], bin: "claude" }, + { harness: "codex", dir: "/home/u/.codex", from: ["default"], bin: "codex" }, + ], + settings: { path: join(t3, "userdata/settings.json"), instances: 0, error: "not found" }, + }); + }); +}); + +// The record a second config home keeps re-acquiring for the home folder. +function claudeHome({ autoUpdate } = {}) { + const dir = tmp(); + const user = { + scope: "user", + installPath: `${dir}/plugins/cache/t3-pstack/t3-pstack/2.0.0-rc.3`, + version: "2.0.0-rc.3", + installedAt: "2026-10-06T08:38:28.344Z", + lastUpdated: "2026-10-14T11:20:44.228Z", + gitCommitSha: "1111111111111111111111111111111111111111", + }; + const project = { + scope: "project", + projectPath: "/home/u", + installPath: `${dir}/plugins/cache/t3-pstack/t3-pstack/1.0.11`, + version: "1.0.11", + installedAt: "2026-10-13T04:43:26.759Z", + lastUpdated: "2026-10-13T04:43:26.759Z", + gitCommitSha: "0000000000000000000000000000000000000000", + }; + const other = { scope: "project", projectPath: "/home/u", installPath: "/x", version: "0.1.0" }; + writeJson(join(dir, "plugins/installed_plugins.json"), { + version: 2, + plugins: { "t3-pstack@t3-pstack": [user, project], "other@market": [other] }, + }); + writeJson(join(dir, "settings.json"), { + enabledPlugins: { "t3-pstack@t3-pstack": true }, + extraKnownMarketplaces: { + "t3-pstack": { source: { source: "github", repo: "chhoumann/t3-pstack" }, ...(autoUpdate && { autoUpdate }) }, + }, + }); + return { dir, user, project, other }; +} + +describe("a stale other-scope Claude Code record", () => { + test("moves to the user-scope install, after a backup, and only once", () => { + const { dir, user, project, other } = claudeHome(); + const path = join(dir, "plugins/installed_plugins.json"); + const original = readFileSync(path, "utf8"); + + const steps = syncScopedRecords(dir); + const backups = readdirSync(join(dir, "plugins")).filter((name) => name.endsWith(".bak")); + expect(backups).toHaveLength(1); + expect(steps).toEqual([ + "moved the project-scope record for /home/u from 1.0.11 to 2.0.0-rc.3", + `backed up plugins/installed_plugins.json to ${join(dir, "plugins", backups[0])}`, + ]); + expect(readFileSync(join(dir, "plugins", backups[0]), "utf8")).toBe(original); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ + version: 2, + plugins: { + "t3-pstack@t3-pstack": [ + user, + { ...project, installPath: user.installPath, version: user.version, gitCommitSha: user.gitCommitSha, lastUpdated: user.lastUpdated }, + ], + "other@market": [other], + }, + }); + + const fixed = readFileSync(path, "utf8"); + expect(syncScopedRecords(dir)).toEqual([]); + expect(readFileSync(path, "utf8")).toBe(fixed); + expect(readdirSync(join(dir, "plugins")).filter((name) => name.endsWith(".bak"))).toHaveLength(1); + }); + + test("doctor names it, the missing auto-update, and plain skill copies, and passes once they are fixed", () => { + const { dir } = claudeHome(); + mkdirSync(join(dir, "skills/how"), { recursive: true }); + const home = { harness: "claude", dir, from: ["default"], bin: "claude" }; + const context = { version: "2.0.0-rc.3", skills: ["how", "why"] }; + expect(doctorClaude(home, context)).toEqual([ + { name: "installed", ok: true, detail: "2.0.0-rc.3" }, + { name: "enabled", ok: true, detail: "yes" }, + { name: "marketplace auto-update", ok: false, detail: "false", fix: FIX }, + { name: "other-scope records", ok: false, detail: "project /home/u at 1.0.11", fix: FIX }, + { + name: "no plain copies of plugin skills", + ok: false, + detail: join(dir, "skills/how"), + fix: "delete these folders; the plugin ships the same skills", + }, + ]); + expect(doctorClaude(home, { ...context, version: "2.0.0-rc.4" })[0]).toEqual({ + name: "installed", + ok: false, + detail: "2.0.0-rc.3, expected 2.0.0-rc.4", + fix: FIX, + }); + + syncScopedRecords(dir); + rmSync(join(dir, "skills"), { recursive: true }); + const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8")); + settings.extraKnownMarketplaces["t3-pstack"].autoUpdate = true; + writeJson(join(dir, "settings.json"), settings); + expect(doctorClaude(home, context).filter((c) => !c.ok)).toEqual([]); + }); +}); + +describe("the model sheet", () => { + const SHEET = "# budget: large (xhigh)\nbug-fix: inherit-parent\nsession hook: on\n"; + + function machine() { + const root = tmp(); + const homes = [".claude", ".codex", ".codex-pool", ".claude-never-run"].map((name) => ({ dir: join(root, name) })); + for (const { dir } of homes.slice(0, 3)) mkdirSync(dir); + return { root, homes, sheet: join(root, ".config/t3-pstack/models.md") }; + } + + test("moves a linked home sheet to the machine path once and links every existing home to it", () => { + const { root, homes, sheet } = machine(); + writeFileSync(join(root, ".claude/t3-pstack-models.md"), SHEET); + symlinkSync(join(root, ".claude/t3-pstack-models.md"), join(root, ".codex/t3-pstack-models.md")); + + expect(moveSheet(sheet, homes)).toEqual([ + `moved the sheet from ${join(root, ".claude/t3-pstack-models.md")}`, + `linked ${join(root, ".claude/t3-pstack-models.md")} to it`, + `linked ${join(root, ".codex-pool/t3-pstack-models.md")} to it`, + ]); + expect(readFileSync(sheet, "utf8")).toBe(SHEET); + expect(readlinkSync(join(root, ".claude/t3-pstack-models.md"))).toBe(sheet); + expect(readlinkSync(join(root, ".codex/t3-pstack-models.md"))).toBe(join(root, ".claude/t3-pstack-models.md")); + expect(readlinkSync(join(root, ".codex-pool/t3-pstack-models.md"))).toBe(sheet); + expect(existsSync(join(root, ".claude-never-run"))).toBe(false); + + expect(moveSheet(sheet, homes)).toEqual([]); + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })).toEqual([ + { name: "machine sheet valid", ok: true, detail: "every line parses (pass --catalog to check targets)" }, + { name: "home sheets", ok: true, detail: "none differ from it" }, + ]); + }); + + test("leaves home sheets that disagree, and doctor says so", () => { + const { root, homes, sheet } = machine(); + writeFileSync(join(root, ".claude/t3-pstack-models.md"), SHEET); + writeFileSync(join(root, ".codex/t3-pstack-models.md"), "session hook: off\n"); + + expect(moveSheet(sheet, homes)).toEqual([]); + expect(existsSync(sheet)).toBe(false); + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })).toEqual([ + { + name: "machine sheet", + ok: false, + detail: `missing; ${join(root, ".claude/t3-pstack-models.md")}, ${join(root, ".codex/t3-pstack-models.md")} differ, so the installer leaves them`, + fix: `keep one, then ${FIX}`, + }, + ]); + }); + + test("keeps a home sheet that differs from an existing machine sheet, and links one that matches", () => { + const { root, homes, sheet } = machine(); + mkdirSync(join(root, ".config/t3-pstack"), { recursive: true }); + writeFileSync(sheet, SHEET); + writeFileSync(join(root, ".claude/t3-pstack-models.md"), SHEET); + writeFileSync(join(root, ".codex/t3-pstack-models.md"), "session hook: off\n"); + + expect(moveSheet(sheet, homes)).toEqual([ + `linked ${join(root, ".claude/t3-pstack-models.md")} to it`, + `linked ${join(root, ".codex-pool/t3-pstack-models.md")} to it`, + ]); + expect(lstatSync(join(root, ".codex/t3-pstack-models.md")).isSymbolicLink()).toBe(false); + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })[1]).toEqual({ + name: "home sheets", + ok: false, + detail: `${join(root, ".codex/t3-pstack-models.md")} differ from the machine sheet`, + fix: `move each aside, then ${FIX} to link it`, + }); + }); + + test("doctor warns without a sheet and fails on a malformed one", () => { + const { homes, sheet } = machine(); + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })).toEqual([ + { + name: "machine sheet", + ok: false, + detail: "none, so every role runs on its skill's defaults", + fix: "run /t3-pstack:setup-pstack in a T3 thread", + warn: true, + }, + ]); + mkdirSync(join(sheet, ".."), { recursive: true }); + writeFileSync(sheet, "bug-fix: claude-opus\nhow critics: inherit-parent\n"); + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })[0]).toEqual({ + name: "machine sheet valid", + ok: false, + detail: 'bug-fix: "claude-opus" does not start with /; "how critics" is not a pstack role', + fix: "run /t3-pstack:setup-pstack in a T3 thread", + }); + }); +}); diff --git a/tools/install.mjs b/tools/install.mjs new file mode 100755 index 00000000..faaa0f34 --- /dev/null +++ b/tools/install.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +// t3-pstack's installer. `install`, or `update`, the same command, brings +// every Claude Code and Codex home T3 can run to this package's version of +// t3-pstack, trusts the Codex session-start hook, and moves the model sheet to +// its one path per machine. Each step converges, so a second run changes +// nothing. `doctor` checks the same homes and writes nothing itself, though +// the Codex app-server it starts refreshes a Git marketplace, as any T3 Codex +// thread does. Both exit 1 when a check fails. +// +// npx -y github:chhoumann/t3-pstack +// --source marketplace source, default chhoumann/t3-pstack +// --claude-home , --codex-home more homes, repeatable +// --catalog an orchestrator_capabilities dump to check the sheet's targets against +// --json +// Node 20 or later, no dependencies. +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +import { doctorClaude, installClaude } from "./install/claude.mjs"; +import { doctorCodex, installCodex } from "./install/codex.mjs"; +import { check, hasCli, INSTALL_HINT } from "./install/common.mjs"; +import { discoverHomes } from "./install/homes.mjs"; +import { doctorSheet, moveSheet, sheetPath } from "./install/sheet.mjs"; + +const HARNESSES = { + claude: { name: "Claude Code", install: installClaude, doctor: doctorClaude }, + codex: { name: "Codex", install: installCodex, doctor: doctorCodex }, +}; + +const USAGE = + "usage: t3-pstack [--source ] " + + "[--claude-home ]... [--codex-home ]... [--catalog ] [--json]"; + +// The version and roles to converge on come from the plugin this run installs: +// the local source, or else this package, which is the marketplace's own repo. +function pluginFacts(root) { + const plugin = join(root, "plugins/pstack"); + const { roles, tiers } = JSON.parse(readFileSync(join(plugin, "models.json"), "utf8")); + return { + version: JSON.parse(readFileSync(join(plugin, ".claude-plugin/plugin.json"), "utf8")).version, + skills: readdirSync(join(plugin, "skills")), + roles, + tiers, + }; +} + +async function run(argv, env, home) { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + source: { type: "string" }, + "claude-home": { type: "string", multiple: true, default: [] }, + "codex-home": { type: "string", multiple: true, default: [] }, + catalog: { type: "string" }, + json: { type: "boolean", default: false }, + }, + }); + const [command] = positionals; + if (positionals.length !== 1 || !["install", "update", "doctor"].includes(command)) return { usage: true }; + + const localSource = values.source && existsSync(values.source) ? resolve(values.source) : undefined; + const source = localSource ?? values.source ?? "chhoumann/t3-pstack"; + const facts = pluginFacts(localSource ?? fileURLToPath(new URL("..", import.meta.url))); + const context = { source, localSource, version: facts.version, skills: facts.skills }; + const { homes, settings } = discoverHomes({ + env, + home, + claudeHomes: values["claude-home"], + codexHomes: values["codex-home"], + }); + const catalog = values.catalog ? JSON.parse(readFileSync(values.catalog, "utf8")) : null; + + const report = { command, version: facts.version, source, settings, homes: [] }; + for (const target of homes) { + const harness = HARNESSES[target.harness]; + const row = { harness: target.harness, dir: target.dir, from: target.from, steps: [], checks: [] }; + report.homes.push(row); + if (!hasCli(target.bin)) { + row.checks.push(check("CLI", false, `${target.bin} not found, so this home was skipped`, `install ${harness.name} if T3 runs it`, true)); + continue; + } + if (command === "doctor" && !existsSync(target.dir)) { + row.checks.push(check("installed", false, "the home does not exist", INSTALL_HINT)); + continue; + } + try { + if (command !== "doctor") row.steps = await harness.install(target, context); + row.checks.push(...(await harness.doctor(target, context))); + } catch (err) { + row.checks.push(check(command === "doctor" ? "doctor" : "install", false, err.message, INSTALL_HINT)); + } + } + const sheet = sheetPath(env, home); + report.sheet = { path: sheet, steps: [], checks: [] }; + try { + if (command !== "doctor") report.sheet.steps = moveSheet(sheet, homes); + } catch (err) { + report.sheet.checks.push(check("move", false, err.message, INSTALL_HINT)); + } + report.sheet.checks.push(...doctorSheet(sheet, homes, { catalog, roles: facts.roles, tiers: facts.tiers })); + const all = [...report.homes.flatMap((row) => row.checks), ...report.sheet.checks]; + report.failed = all.filter((c) => !c.ok && !c.warn).length; + report.warnings = all.filter((c) => !c.ok && c.warn).length; + return { report, json: values.json }; +} + +function render({ command, version, source, settings, homes, sheet, failed, warnings }, home) { + const lines = [`t3-pstack ${version} from ${source}`]; + lines.push(`T3 settings: ${settings.path}${settings.error ? ` (${settings.error})` : `, ${settings.instances} Claude Code or Codex instances`}`); + const section = (title, steps, checks) => { + lines.push("", title); + for (const step of steps) lines.push(` - ${step}`); + for (const c of checks) { + lines.push(` ${c.ok ? "ok " : c.warn ? "warn" : "FAIL"} ${c.name}: ${c.detail}`); + if (!c.ok) lines.push(` fix: ${c.fix}`); + } + }; + for (const row of homes) section(`${HARNESSES[row.harness].name} ${row.dir} (${row.from.join(", ")})`, row.steps, row.checks); + section(`Model sheet ${sheet.path}`, sheet.steps, sheet.checks); + const changed = [...homes.flatMap((row) => row.steps), ...sheet.steps].length; + const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`; + lines.push( + "", + `${command === "doctor" ? "" : changed ? `${count(changed, "change")}. ` : "Nothing to change. "}` + + (failed ? `${count(failed, "check")} failed` : "Every check passes") + + (warnings ? `, ${count(warnings, "warning")}.` : "."), + ); + return lines.join("\n").replaceAll(home, "~"); +} + +const home = homedir(); +try { + const { usage, report, json } = await run(process.argv.slice(2), process.env, home); + if (usage) { + console.error(USAGE); + process.exitCode = 2; + } else { + console.log(json ? JSON.stringify(report, null, 2) : render(report, home)); + process.exitCode = report.failed ? 1 : 0; + } +} catch (err) { + console.error(`t3-pstack: ${err.message}`); + process.exitCode = 2; +} diff --git a/tools/install/claude.mjs b/tools/install/claude.mjs new file mode 100644 index 00000000..94f38089 --- /dev/null +++ b/tools/install/claude.mjs @@ -0,0 +1,123 @@ +// Claude Code: the marketplace plugin at user scope, in one config home. +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { check, INSTALL_HINT, MARKETPLACE, PLUGIN_ID, runCli, writeAtomic } from "./common.mjs"; + +const readJson = (path, fallback) => (existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback); +const RECORDS = "plugins/installed_plugins.json"; + +function read(dir) { + const settings = readJson(join(dir, "settings.json"), {}); + const known = readJson(join(dir, "plugins/known_marketplaces.json"), {}); + const records = readJson(join(dir, RECORDS), {}).plugins?.[PLUGIN_ID] ?? []; + const user = records.find((r) => r.scope === "user"); + return { + settings, + marketplace: settings.extraKnownMarketplaces?.[MARKETPLACE] ?? known[MARKETPLACE], + user, + stale: user ? records.filter((r) => isStale(r, user)) : [], + enabled: settings.enabledPlugins?.[PLUGIN_ID] === true, + }; +} + +const isStale = (record, user) => + record.scope !== "user" && (record.version !== user.version || record.installPath !== user.installPath); + +// Third-party marketplaces default to no auto-update; the /plugin toggle +// writes this key, and Claude Code copies it into known_marketplaces.json at +// its next start. A directory source loads in place and has nothing to fetch. +const autoUpdates = (marketplace) => marketplace?.autoUpdate === true || marketplace?.source?.source === "directory"; + +export function installClaude(home, { source, version }) { + const steps = []; + const plugin = (...args) => runCli(home.bin, ["plugin", ...args], { CLAUDE_CONFIG_DIR: home.dir }); + mkdirSync(home.dir, { recursive: true }); + let state = read(home.dir); + if (!state.marketplace) { + plugin("marketplace", "add", source); + steps.push(`added the ${MARKETPLACE} marketplace from ${source}`); + } + if (!state.user) { + plugin("install", PLUGIN_ID, "--scope", "user"); + steps.push(`installed ${read(home.dir).user?.version}`); + } else if (state.user.version !== version) { + // `plugin update` reads the marketplace as last fetched. + plugin("marketplace", "update", MARKETPLACE); + plugin("update", PLUGIN_ID, "--scope", "user"); + steps.push(`updated ${state.user.version} to ${read(home.dir).user?.version}`); + } + if (!read(home.dir).enabled) { + plugin("enable", PLUGIN_ID, "--scope", "user"); + steps.push("enabled it"); + } + state = read(home.dir); + if (state.marketplace && !autoUpdates(state.marketplace)) { + const declared = state.settings.extraKnownMarketplaces?.[MARKETPLACE] ?? { source: state.marketplace.source }; + state.settings.extraKnownMarketplaces = { + ...state.settings.extraKnownMarketplaces, + [MARKETPLACE]: { ...declared, autoUpdate: true }, + }; + writeAtomic(join(home.dir, "settings.json"), JSON.stringify(state.settings, null, 2) + "\n"); + steps.push("turned on marketplace auto-update"); + } + steps.push(...syncScopedRecords(home.dir)); + return steps; +} + +// Claude Code reads ~/.claude/settings.json as the project settings of the +// home folder, so a session started there under another config home adds a +// project-scope record, and that record keeps the version it found. +// `claude plugin uninstall --scope project` would edit ~/.claude/settings.json, +// which belongs to the main home. So the record is brought to the user-scope +// install in place, after a backup. +export function syncScopedRecords(dir) { + const path = join(dir, RECORDS); + const { user, stale } = read(dir); + if (!stale.length) return []; + const text = readFileSync(path, "utf8"); + const backup = `${path}.${new Date().toISOString().replace(/[:.]/g, "-")}.bak`; + writeFileSync(backup, text); + const data = JSON.parse(text); + const steps = []; + for (const record of data.plugins[PLUGIN_ID].filter((r) => isStale(r, user))) { + steps.push(`moved the ${record.scope}-scope record for ${record.projectPath} from ${record.version} to ${user.version}`); + Object.assign(record, { + installPath: user.installPath, + version: user.version, + gitCommitSha: user.gitCommitSha, + lastUpdated: user.lastUpdated, + }); + } + writeAtomic(path, JSON.stringify(data, null, 2) + "\n"); + return [...steps, `backed up ${RECORDS} to ${backup}`]; +} + +export function doctorClaude(home, { version, skills }) { + const state = read(home.dir); + const installed = state.user?.version; + const skillsDir = join(home.dir, "skills"); + const plain = existsSync(skillsDir) ? readdirSync(skillsDir).filter((name) => skills.includes(name)) : []; + return [ + check( + "installed", + installed === version, + installed ? `${installed}${installed === version ? "" : `, expected ${version}`}` : "not installed", + INSTALL_HINT, + ), + check("enabled", state.enabled, state.enabled ? "yes" : "no", INSTALL_HINT), + check("marketplace auto-update", autoUpdates(state.marketplace), state.marketplace ? String(autoUpdates(state.marketplace)) : "no marketplace", INSTALL_HINT), + check( + "other-scope records", + state.stale.length === 0, + state.stale.length ? state.stale.map((r) => `${r.scope} ${r.projectPath} at ${r.version}`).join("; ") : "none stale", + INSTALL_HINT, + ), + check( + "no plain copies of plugin skills", + plain.length === 0, + plain.length ? plain.map((name) => join(skillsDir, name)).join(", ") : "none", + "delete these folders; the plugin ships the same skills", + ), + ]; +} diff --git a/tools/install/codex.mjs b/tools/install/codex.mjs new file mode 100644 index 00000000..614cc16e --- /dev/null +++ b/tools/install/codex.mjs @@ -0,0 +1,174 @@ +// Codex: the marketplace plugin in one CODEX_HOME, and trust for its hook. +// Codex runs a plugin hook only once `hooks.state."".trusted_hash` in +// config.toml matches the hook's entry. Its TUI asks for that trust, but +// `codex app-server`, which T3 runs, never does. So the installer trusts the +// hook the way the TUI does, through the app-server, with the hash Codex +// reports, on every install and update. +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { check, INSTALL_HINT, MARKETPLACE, neutralCwd, PLUGIN_ID, runCli } from "./common.mjs"; + +const REQUEST_TIMEOUT = 60_000; + +// A JSON-RPC client over `codex app-server` stdio. +export class AppServer { + static async start(home) { + const server = new AppServer(home); + await server.request("initialize", { clientInfo: { name: "t3-pstack-installer", version: "1" } }); + server.proc.stdin.write(JSON.stringify({ method: "initialized" }) + "\n"); + return server; + } + + constructor(home) { + this.proc = spawn(home.bin, ["app-server"], { + cwd: neutralCwd(), + env: { ...process.env, CODEX_HOME: home.dir }, + stdio: ["pipe", "pipe", "ignore"], + }); + this.nextId = 0; + this.pending = new Map(); + this.notifications = []; + const failAll = (err) => { + for (const { reject, timer } of this.pending.values()) { + clearTimeout(timer); + reject(err); + } + this.pending.clear(); + }; + this.proc.on("error", failAll); + this.proc.stdin.on("error", failAll); + this.proc.on("exit", (code) => failAll(new Error(`codex app-server exited with ${code}`))); + let buffer = ""; + this.proc.stdout.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + const message = line ? JSON.parse(line) : {}; + if (message.method) this.notifications.push(message); + const waiter = this.pending.get(message.id); + if (!waiter || message.method) continue; + this.pending.delete(message.id); + clearTimeout(waiter.timer); + if (message.error) waiter.reject(new Error(`${waiter.method}: ${JSON.stringify(message.error)}`)); + else waiter.resolve(message.result); + } + }); + } + + request(method, params) { + const id = ++this.nextId; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${method}: no reply from codex app-server in ${REQUEST_TIMEOUT / 1000}s`)); + }, REQUEST_TIMEOUT); + this.pending.set(id, { resolve, reject, method, timer }); + this.proc.stdin.write(JSON.stringify({ id, method, params }) + "\n"); + }); + } + + close() { + this.proc.kill(); + } +} + +async function withServer(home, use) { + const server = await AppServer.start(home); + try { + return await use(server); + } finally { + server.close(); + } +} + +// Codex keeps one cache directory per installed version and removes the old +// one when it installs another. +function installedVersions(dir) { + const cache = join(dir, "plugins/cache", MARKETPLACE, PLUGIN_ID.split("@")[0]); + return existsSync(cache) ? readdirSync(cache).filter((name) => !name.startsWith(".")) : []; +} + +async function pluginState(server, dir) { + const { marketplaces = [] } = await server.request("plugin/list", {}); + const plugin = marketplaces.flatMap((m) => m.plugins ?? []).find((p) => p.id === PLUGIN_ID); + return { installed: plugin?.installed === true, enabled: plugin?.enabled === true, versions: installedVersions(dir) }; +} + +async function pluginHooks(server) { + const { data } = await server.request("hooks/list", {}); + return data.flatMap((entry) => entry.hooks).filter((hook) => hook.pluginId === PLUGIN_ID); +} + +const current = (state, version) => state.installed && state.enabled && state.versions.join() === version; + +export async function installCodex(home, { source, version, localSource }) { + const steps = []; + const plugin = (...args) => runCli(home.bin, ["plugin", ...args], { CODEX_HOME: home.dir }); + mkdirSync(home.dir, { recursive: true }); + if (!/^t3-pstack\s/m.test(plugin("marketplace", "list"))) { + plugin("marketplace", "add", source); + steps.push(`added the ${MARKETPLACE} marketplace from ${source}`); + } + const before = await withServer(home, (server) => pluginState(server, home.dir)); + if (!current(before, version)) { + // A local marketplace is read in place; a Git one is a snapshot to refresh. + if (!localSource) plugin("marketplace", "upgrade", MARKETPLACE); + plugin("add", PLUGIN_ID); + const after = installedVersions(home.dir).join(", "); + steps.push(before.versions.length ? `updated ${before.versions.join(", ")} to ${after}` : `installed ${after}`); + } + steps.push(...(await withServer(home, trustHooks))); + return steps; +} + +// `upsert`: `replace` would drop the user's other trusted hooks. +async function trustHooks(server) { + const pending = (await pluginHooks(server)).filter((h) => h.trustStatus === "untrusted" || h.trustStatus === "modified"); + if (!pending.length) return []; + await server.request("config/value/write", { + keyPath: "hooks.state", + mergeStrategy: "upsert", + value: Object.fromEntries(pending.map((h) => [h.key, { trusted_hash: h.currentHash }])), + }); + const after = new Map((await pluginHooks(server)).map((h) => [h.key, h.trustStatus])); + const failed = pending.filter((h) => after.get(h.key) !== "trusted"); + if (failed.length) throw new Error(`Codex still reports ${failed.map((h) => `${h.key} ${after.get(h.key)}`).join(", ")} after the trust write`); + return pending.map((h) => `trusted hook ${h.key} (was ${h.trustStatus})`); +} + +export async function doctorCodex(home, { version, skills }) { + return withServer(home, async (server) => { + const state = await pluginState(server, home.dir); + const hooks = await pluginHooks(server); + const listed = (await server.request("skills/list", { cwds: [neutralCwd()] })).data.flatMap((entry) => entry.skills); + const ours = new Set(skills); + const plain = listed.filter((skill) => ours.has(skill.name)).map((skill) => skill.path); + const installed = state.versions.join(", "); + const untrusted = hooks.filter((h) => h.trustStatus !== "trusted"); + return [ + check( + "installed", + state.installed && installed === version, + state.installed ? `${installed}${installed === version ? "" : `, expected ${version}`}` : "not installed", + INSTALL_HINT, + ), + check("enabled", state.enabled, state.enabled ? "yes" : "no", INSTALL_HINT), + check( + "session-start hook trusted", + hooks.length > 0 && untrusted.length === 0, + hooks.length ? hooks.map((h) => `${h.key} ${h.trustStatus}`).join("; ") : "no t3-pstack hook listed", + INSTALL_HINT, + ), + check( + "no plain copies of plugin skills", + plain.length === 0, + plain.length ? plain.slice(0, 5).join(", ") + (plain.length > 5 ? ` and ${plain.length - 5} more` : "") : "none", + "delete these copies, for example from ~/.agents/skills; the plugin ships the same skills", + ), + ]; + }); +} diff --git a/tools/install/common.mjs b/tools/install/common.mjs new file mode 100644 index 00000000..3edf21e3 --- /dev/null +++ b/tools/install/common.mjs @@ -0,0 +1,60 @@ +// What the Claude Code and Codex halves of the installer share: the plugin's +// ids, the CLI runner, and the shape of a doctor check. +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; + +export const PLUGIN_ID = "t3-pstack@t3-pstack"; +export const MARKETPLACE = "t3-pstack"; +export const INSTALL_HINT = "rerun `npx -y github:chhoumann/t3-pstack install`"; + +// Every CLI runs from an empty directory. Claude Code reads `/.claude` +// as project settings, and from the home folder that is the main home's +// settings.json, so the user's own working directory could leak into the +// home being installed. +let neutral; +export function neutralCwd() { + if (!neutral) { + neutral = mkdtempSync(join(tmpdir(), "t3-pstack-")); + process.on("exit", () => rmSync(neutral, { recursive: true, force: true })); + } + return neutral; +} + +export function runCli(bin, args, env) { + try { + return execFileSync(bin, args, { + cwd: neutralCwd(), + env: { ...process.env, ...env }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 300_000, + }); + } catch (err) { + throw new Error(`${bin} ${args.join(" ")}: ${(err.stderr || err.stdout || err.message).trim()}`); + } +} + +// Looked up on PATH rather than run: `codex --version` already writes into +// the default CODEX_HOME. +export function hasCli(bin) { + if (bin.includes("/")) return existsSync(bin); + const extensions = process.platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE").split(";")] : [""]; + return (process.env.PATH ?? "") + .split(delimiter) + .some((dir) => dir && extensions.some((ext) => existsSync(join(dir, bin + ext)))); +} + +// A crash between the write and the rename leaves the old file whole. The +// rename lands on the symlink's target, so a settings file one home links +// from another stays shared. +export function writeAtomic(path, text) { + const target = existsSync(path) ? realpathSync(path) : path; + const tmp = join(dirname(target), `.${Date.now()}-${process.pid}.t3-pstack.tmp`); + writeFileSync(tmp, text); + renameSync(tmp, target); +} + +// A doctor check. `warn` marks one that never fails the run. +export const check = (name, ok, detail, fix, warn = false) => ({ name, ok, detail, ...(ok ? {} : { fix }), ...(warn && { warn }) }); diff --git a/tools/install/homes.mjs b/tools/install/homes.mjs new file mode 100644 index 00000000..e8b53f48 --- /dev/null +++ b/tools/install/homes.mjs @@ -0,0 +1,74 @@ +// Every Claude Code and Codex config home the installer serves: the defaults, +// the shell's CLAUDE_CONFIG_DIR and CODEX_HOME, each claudeAgent and codex +// instance in T3's settings, enabled or not, and the homes named by flag. +// An instance's home resolves as T3 resolves it (apps/server +// Drivers/ClaudeHome.ts and Drivers/CodexHomeLayout.ts): `config.homePath`, +// else the instance's CLAUDE_CONFIG_DIR or CODEX_HOME, else the default. +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const DRIVERS = { claudeAgent: "claude", codex: "codex" }; +const ENV_VAR = { claude: "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME" }; +const DEFAULT_HOME = { claude: ".claude", codex: ".codex" }; + +const expand = (path, home) => (path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path); + +// T3 keeps its state in `${T3CODE_HOME:-~/.t3}/userdata` (apps/server os-jank.ts and config.ts). +export function t3SettingsPath(env, home) { + const base = env.T3CODE_HOME?.trim() ? resolve(expand(env.T3CODE_HOME.trim(), home)) : join(home, ".t3"); + return join(base, "userdata", "settings.json"); +} + +function readT3Settings(path) { + try { + return { instances: JSON.parse(readFileSync(path, "utf8")).providerInstances ?? {} }; + } catch (err) { + return { instances: {}, error: err.code === "ENOENT" ? "not found" : err.message }; + } +} + +// Returns `{ homes, settings }`. A home is `{ harness, dir, from, bin }`: +// `from` lists where it was found, and `bin` is the CLI T3 runs for it. +export function discoverHomes({ env, home, claudeHomes = [], codexHomes = [] }) { + const homes = new Map(); + const add = (harness, dir, from, bin) => { + const key = `${harness}\0${existsSync(dir) ? realpathSync(dir) : dir}`; + const entry = homes.get(key) ?? { harness, dir, from: [], bin: harness }; + entry.from.push(from); + if (bin && entry.bin === harness) entry.bin = bin; + homes.set(key, entry); + }; + + for (const harness of ["claude", "codex"]) add(harness, join(home, DEFAULT_HOME[harness]), "default"); + + const settingsPath = t3SettingsPath(env, home); + const { instances, error } = readT3Settings(settingsPath); + // A Codex shadow home is T3's auth overlay: its config.toml, plugins, and + // skills are symlinks into the shared home, which is the one to install into. + const shadows = new Set(); + for (const [id, instance] of Object.entries(instances)) { + const harness = DRIVERS[instance.driver]; + if (!harness) continue; + const config = instance.config ?? {}; + const instanceEnv = Object.fromEntries((instance.environment ?? []).map((v) => [v.name, v.value ?? ""])); + if (config.shadowHomePath?.trim()) shadows.add(resolve(expand(config.shadowHomePath.trim(), home))); + // T3 does not expand `~` in an inherited variable. + const dir = config.homePath?.trim() + ? resolve(expand(config.homePath.trim(), home)) + : instanceEnv[ENV_VAR[harness]]?.trim() + ? resolve(instanceEnv[ENV_VAR[harness]].trim()) + : join(home, DEFAULT_HOME[harness]); + const disabled = instance.enabled === false || config.enabled === false; + add(harness, dir, `T3 instance ${id}${disabled ? " (disabled)" : ""}`, config.binaryPath?.trim()); + } + + for (const harness of ["claude", "codex"]) { + const dir = env[ENV_VAR[harness]]?.trim(); + if (dir && !shadows.has(resolve(dir))) add(harness, resolve(dir), ENV_VAR[harness]); + } + for (const dir of claudeHomes) add("claude", resolve(expand(dir, home)), "--claude-home"); + for (const dir of codexHomes) add("codex", resolve(expand(dir, home)), "--codex-home"); + + const counted = Object.values(instances).filter((instance) => DRIVERS[instance.driver]).length; + return { homes: [...homes.values()], settings: { path: settingsPath, instances: counted, error } }; +} diff --git a/tools/install/sheet.mjs b/tools/install/sheet.mjs new file mode 100644 index 00000000..332ccc53 --- /dev/null +++ b/tools/install/sheet.mjs @@ -0,0 +1,87 @@ +// The model sheet. Provider instance IDs differ per machine, not per harness, +// so since 2.0.0-rc.3 there is one sheet per machine, at +// `${XDG_CONFIG_HOME:-~/.config}/t3-pstack/models.md`. Released versions read +// `t3-pstack-models.md` in each config home, so the installer moves that sheet +// to the machine path once and leaves a symlink at each home's old path. +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +import { checkSheet, parseSheet } from "../../plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs"; +import { check, INSTALL_HINT, writeAtomic } from "./common.mjs"; + +export const HOME_SHEET = "t3-pstack-models.md"; +const SETUP_HINT = "run /t3-pstack:setup-pstack in a T3 thread"; + +export const sheetPath = (env, home) => + join(env.XDG_CONFIG_HOME?.trim() ? resolve(env.XDG_CONFIG_HOME.trim()) : join(home, ".config"), "t3-pstack", "models.md"); + +const isFile = (path) => statSync(path, { throwIfNoEntry: false })?.isFile() ?? false; +const sameBytes = (a, b) => readFileSync(a).equals(readFileSync(b)); +const resolvesTo = (path, target) => isFile(path) && isFile(target) && realpathSync(path) === realpathSync(target); +const homeSheets = (homes) => homes.map((home) => join(home.dir, HOME_SHEET)); + +function link(target, path) { + const tmp = `${path}.t3-pstack-link`; + rmSync(tmp, { force: true }); + symlinkSync(target, tmp); + renameSync(tmp, path); +} + +export function moveSheet(sheet, homes) { + const steps = []; + if (!isFile(sheet)) { + const found = [...new Set(homeSheets(homes).filter(isFile).map((path) => realpathSync(path)))]; + if (!found.length) return steps; + if (new Set(found.map((path) => readFileSync(path, "utf8"))).size > 1) return steps; + mkdirSync(dirname(sheet), { recursive: true }); + writeAtomic(sheet, readFileSync(found[0])); + steps.push(`moved the sheet from ${found[0]}`); + } + // A home sheet that differs from the machine sheet stays, and doctor names it. + for (const path of homeSheets(homes)) { + if (!existsSync(dirname(path)) || resolvesTo(path, sheet)) continue; + const present = lstatSync(path, { throwIfNoEntry: false }); + if (present && isFile(path) && !sameBytes(path, sheet)) continue; + link(sheet, path); + steps.push(`linked ${path} to it`); + } + return steps; +} + +export function doctorSheet(sheet, homes, { catalog, roles, tiers }) { + const old = homeSheets(homes).filter(isFile); + if (!isFile(sheet)) { + const differ = new Set(old.map((path) => readFileSync(path, "utf8"))).size > 1; + if (old.length) { + return [ + check( + "machine sheet", + false, + `missing; ${old.join(", ")} ${differ ? "differ, so the installer leaves them" : "not moved yet"}`, + differ ? `keep one, then ${INSTALL_HINT}` : INSTALL_HINT, + ), + ]; + } + return [check("machine sheet", false, "none, so every role runs on its skill's defaults", SETUP_HINT, true)]; + } + const problems = checkSheet(parseSheet(readFileSync(sheet, "utf8")), { roles, defaults: tiers, catalog }); + const shadowing = old.filter((path) => !resolvesTo(path, sheet) && !sameBytes(path, sheet)); + return [ + check( + "machine sheet valid", + problems.length === 0, + problems.length + ? problems.join("; ") + : catalog + ? "every line parses and dispatches on the catalog" + : "every line parses (pass --catalog to check targets)", + SETUP_HINT, + ), + check( + "home sheets", + shadowing.length === 0, + shadowing.length ? `${shadowing.join(", ")} differ from the machine sheet` : "none differ from it", + `move each aside, then ${INSTALL_HINT} to link it`, + ), + ]; +} From 6cf9ea9605daf3b3d2f6b8cf27e24c765ec6dc17 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 14:04:04 +0200 Subject: [PATCH 05/10] Release 2.0.0-rc.3: the installer in the README, CHANGES, and manifests The README leads with the install, update, and doctor commands, and drops the per-CLI commands and the advice to trust the Codex hook with /hooks, which the installer now does. CHANGES describes the installer, the hook trust with the before and after the end-to-end test observed, the frozen hooks files, and the sheet move, with a behavior table. --- .claude-plugin/marketplace.json | 2 +- CHANGES.md | 22 ++++++++++++++++++++++ README.md | 18 ++++++++---------- VERSION | 2 +- package.json | 2 +- plugins/pstack/.claude-plugin/plugin.json | 2 +- plugins/pstack/.codex-plugin/plugin.json | 2 +- 7 files changed, 35 insertions(+), 15 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f89f359c..21fa7c3e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "t3-pstack", "source": "./plugins/pstack", "description": "pstack for T3 Code: rigorous agent workflows whose panels and fan-outs run across model families through T3. Original pstack by Lauren Tan (poteto).", - "version": "2.0.0-rc.2" + "version": "2.0.0-rc.3" } ] } diff --git a/CHANGES.md b/CHANGES.md index e785dbf0..a0f7a4a6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,28 @@ This file is the release changelog, with one `## - ` entry per release, newest first. The Cursor-to-T3 rewrite rules live in [`tools/substitutions.json`](tools/substitutions.json), and the [sync boundary](CONTRIBUTING.md#the-sync-boundary) in `CONTRIBUTING.md` defines how a change to upstream's skill content is declared. +## 2.0.0-rc.3 - One installer and doctor for Claude Code and Codex, a trusted Codex hook, and one sheet per machine + +`npx -y github:chhoumann/t3-pstack install` installs or updates t3-pstack in every Claude Code and Codex home T3 can run, and `doctor` checks them. The homes are `~/.claude` and `~/.codex`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, each `claudeAgent` and `codex` provider instance in T3's `${T3CODE_HOME:-~/.t3}/userdata/settings.json`, disabled ones included, and any home named with `--claude-home` or `--codex-home`. Each step converges, so a second run changes nothing. + +Until now the Codex session-start hook never ran under T3. Codex 0.162.1 runs a plugin hook only once `hooks.state."<key>".trusted_hash` in `config.toml` matches the hook's entry, and `codex app-server`, which T3 runs, never asks for that trust. The installer trusts it on every run the way Codex's own TUI does: `hooks/list`, then `config/value/write` with the hash Codex reports and `mergeStrategy: "upsert"`, so the user's other trusted hooks stay. In a throwaway home with the plugin installed by hand, a T3-shaped app-server session fired no hook event and its rollout held no routing text. After the installer, the same session fired `hook/started` and `hook/completed`, and its rollout held the routing text as one developer message. `tests/install-e2e.test.mjs` runs that check with the real CLIs. + +Codex trusts a hook by a hash of its entry, and a Git marketplace upgrades the plugin at every app-server start, so a changed entry would stop routing in every home with no announcement. `tools/frozen-hooks.json` now pins both hooks files, and `bun tools/generate.mjs --check` fails when one changes unless its pin names the release and that release's entry here tells users to rerun the installer. Neither file changes in this release. + +The model sheet moves to `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, because provider instance IDs differ per machine, not per harness. The installer moves an existing `t3-pstack-models.md` there once and leaves a symlink at each home's old path, so a release before this one still reads the same sheet. Until the installer runs, the hook, the routing text, the skills' Models sections, and setup-pstack fall back to the old per-home sheet. + +| Change | 2.0.0-rc.2 | 2.0.0-rc.3 | Source | +| --- | --- | --- | --- | +| Install and update | `claude plugin` and `codex plugin` commands by hand, in each home. | `npx -y github:chhoumann/t3-pstack install`, or `update`, the same command, for every home T3 can run. A listed home that does not exist yet is created, and a home whose CLI is not on `PATH` is skipped with a warning. A home already at the installer's version gets only the steps it still lacks, such as the Codex trust. | T3 installer | +| Codex session-start hook | Never ran under T3, because the app-server never asks for hook trust. The README said to trust it with `/hooks` in the Codex CLI. | The installer trusts it in every Codex home on every run, with the hash Codex reports, and fails the home unless Codex then reports it `trusted`. A rerun writes nothing. | T3 installer | +| `doctor` | None. | Reports, per home, the installed version against the installer's, whether the plugin is enabled, the Codex hook's trust, Claude Code records at another scope that pin an old version, plain copies of the plugin's skills, and the model sheet: present, parsed, and with `--catalog <file>` checked against an `orchestrator_capabilities` dump. It exits 1 on any problem and prints a fix for each. A missing sheet is a warning. | T3 installer | +| Claude Code marketplace auto-update | Off, Claude Code's default for a third-party marketplace. | The installer sets `extraKnownMarketplaces.t3-pstack.autoUpdate` in each home's `settings.json`. A home behind the installer's version gets `claude plugin marketplace update` and `claude plugin update`. | T3 installer | +| A stale project-scope record | A second Claude Code config home could keep a project-scope `t3-pstack@t3-pstack` record for the home folder at an old version, because Claude Code reads `~/.claude/settings.json` as that folder's project settings. | The installer backs up that home's `plugins/installed_plugins.json` and brings each stale record at another scope to the user-scope install. It never runs `claude plugin uninstall --scope project`, which would edit the main home's settings. | T3 installer | +| Model sheet location | `t3-pstack-models.md` in each host's config directory, linked between hosts by hand. | `~/.config/t3-pstack/models.md`, one per machine. The installer moves the old sheet there once and links each home's old path to it. A home sheet that differs is left, and `doctor` names it. | T3 adaptation | +| setup-pstack | Wrote the host's sheet and linked the other host's path to it. | Writes the machine sheet, reads the old per-home sheet when the machine sheet is missing, and links this host's old path to the machine sheet. | Declared fork | +| A changed hooks file | `session-hook.test.mjs` pinned the 1.0.14 entries literally. | `tools/frozen-hooks.json` pins each hooks file's digest and the release that last changed it. The generator check fails on an unpinned change, or on a pinned one whose release entry does not tell users to rerun the installer. | T3 adaptation | +| `t3-sheet.mjs` without a catalog | A catalog was required. | `checkSheet` with no catalog checks the grammar only. `doctor` uses it. | T3 adaptation | + ## 2.0.0-rc.2 - Lauren's 0.15.24, four of Michael's rules, and three watch-pr fixes t3-pstack moves to Lauren Tan's pstack 0.15.24, cursor/plugins `1e5c90a`, for all three components. Only pstack changed upstream: the autopilot playbooks catch the operator up through `/ketchup` ([pstack #616](https://github.com/cursor/plugins/pull/616)), a new `/sharpen` skill aims a task before it starts ([pstack #615](https://github.com/cursor/plugins/pull/615)), and the version moves to 0.15.24 ([pstack #617](https://github.com/cursor/plugins/pull/617)). `bun tools/sync.mjs` wrote every change cleanly, and at `1e5c90a` each component's dry run reports no undeclared fork, no denylist hit, and no substitution that matches nothing. diff --git a/README.md b/README.md index 395a8120..e3c19d8e 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,19 @@ The skill text is Lauren's. A generated set of substitutions rewrites only what t3-pstack works only inside T3. -## Install - -| | Claude Code | Codex | -| --- | --- | --- | -| Plugin id | `t3-pstack@t3-pstack` | `t3-pstack@t3-pstack` | -| Marketplace repo | `chhoumann/t3-pstack` | `chhoumann/t3-pstack` | +## Install, update, and check ```shell -claude plugin marketplace add chhoumann/t3-pstack && claude plugin install t3-pstack@t3-pstack -codex plugin marketplace add chhoumann/t3-pstack && codex plugin add t3-pstack@t3-pstack +npx -y github:chhoumann/t3-pstack install +npx -y github:chhoumann/t3-pstack update +npx -y github:chhoumann/t3-pstack doctor ``` -Codex runs a plugin's session-start hook only after you trust it, through `/hooks` in the Codex CLI. +`install` and `update` are the same command, and a second run changes nothing. With Node 20 or later, it installs or updates the `t3-pstack@t3-pstack` plugin in every Claude Code and Codex config home T3 can run: `~/.claude` and `~/.codex`, `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME`, and the home of each `claudeAgent` and `codex` instance in T3's settings, disabled ones included. Name any other home with `--claude-home <dir>` or `--codex-home <dir>`. On Codex it also trusts t3-pstack's session-start hook. Codex runs a plugin's hook only once it is trusted, and T3's Codex sessions never ask, so without this step the hook never runs. Run `update` after each release. + +`doctor` reports on the same homes and changes nothing itself: the installed version and whether it is enabled, the Codex hook's trust, Claude Code records that pin an old version, plain copies of the plugin's skills, and the model sheet. It exits 1 on any problem and prints a fix for each. -Then run `/t3-pstack:setup-pstack` in a T3 thread on each machine. It writes `t3-pstack-models.md` in the host's config directory, with one T3 target per role (`<providerInstanceId>/<modelId>` plus that provider's options), and checks it against the machine's `orchestrator_capabilities`. Without a sheet, every role runs on the defaults in its skill's Models section. +Then run `/t3-pstack:setup-pstack` in a T3 thread on each machine. It writes the model sheet, `~/.config/t3-pstack/models.md`, with one T3 target per role (`<providerInstanceId>/<modelId>` plus that provider's options), and checks it against the machine's `orchestrator_capabilities`. Without a sheet, every role runs on the defaults in its skill's Models section. ## Use diff --git a/VERSION b/VERSION index c8f3a156..b87c4d41 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0-rc.2 +2.0.0-rc.3 diff --git a/package.json b/package.json index 52eadf16..ccfe4666 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "t3-pstack", "private": true, - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.3", "description": "The installer and development tooling for t3-pstack, Lauren Tan's pstack rewritten in place for T3 Code. The plugin itself lives in plugins/pstack.", "author": { "name": "Christian Bager Bach Houmann", diff --git a/plugins/pstack/.claude-plugin/plugin.json b/plugins/pstack/.claude-plugin/plugin.json index 17c9cdbb..f111b0a0 100644 --- a/plugins/pstack/.claude-plugin/plugin.json +++ b/plugins/pstack/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "t3-pstack", "displayName": "t3-pstack (pstack for T3 Code)", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.3", "description": "pstack for T3 Code: Lauren Tan's pstack with its Cursor mechanics rewritten in place for T3, so subagents, panels, schedules, and PR watches dispatch through T3's orchestration and panels mix Claude, GPT, and Grok.", "author": { "name": "Christian Bager Bach Houmann", diff --git a/plugins/pstack/.codex-plugin/plugin.json b/plugins/pstack/.codex-plugin/plugin.json index 3ac6d802..b6e2f37e 100644 --- a/plugins/pstack/.codex-plugin/plugin.json +++ b/plugins/pstack/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "t3-pstack", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.3", "description": "pstack for T3 Code on a Codex host: Lauren Tan's pstack with its Cursor mechanics rewritten in place for T3, so subagents, panels, schedules, and PR watches dispatch through T3's orchestration.", "author": { "name": "Christian Bager Bach Houmann", From 2cc4ea138452ab4822698fde70cc0d5508737ae4 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann <christian@bagerbach.com> Date: Wed, 14 Oct 2026 15:17:13 +0200 Subject: [PATCH 06/10] Compare the hooks files with origin/main in the freeze check A pin's digest could change while its changedIn kept the old release, and the check passed, so a changed hook entry could ship unannounced. The check now reads VERSION and each hooks file at the merge-base of HEAD and origin/main, which the marketplaces deliver. A hooks file that differs from main's must be repinned with a changedIn that names this release, VERSION must be newer than main's, and that release's CHANGES entry must tell users to rerun the installer. Without origin/main the check fails and says so, and CI's generated-files job checks out full history for it. hookDigest and FROZEN_HOOKS move to tools/plugin.mjs, which imports only Node, so the installer can share the digest without generate.mjs's typebox dependency. --- .github/workflows/ci.yml | 2 ++ tests/generate.test.mjs | 55 ++++++++++++++++++++++++++++++++-------- tools/generate.mjs | 38 +++++++++++++++++++++------ tools/plugin.mjs | 11 ++++++-- 4 files changed, 86 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb6ff278..f1181f5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,11 @@ jobs: name: Generated files current runs-on: ubuntu-latest steps: + # Full history: the hooks check compares each hooks file with origin/main. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: "1.3.14" diff --git a/tests/generate.test.mjs b/tests/generate.test.mjs index 642b82f1..83806151 100644 --- a/tests/generate.test.mjs +++ b/tests/generate.test.mjs @@ -26,7 +26,6 @@ import { assertChangesHeading, changes, frozenHookFaults, - hookDigest, loadModels, parseFrontmatter, plan, @@ -35,6 +34,7 @@ import { stampAgentPaths, stampVersion, } from "../tools/generate.mjs"; +import { hookDigest } from "../tools/plugin.mjs"; import { walk } from "../tools/validate-skills.mjs"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); @@ -315,10 +315,19 @@ describe("plan, changes, apply", () => { made.push(dir); return dir; }; + // A copy committed as its own origin/main, which the hooks check compares with. + const git = (dir, ...args) => { + const r = spawnSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], { encoding: "utf8" }); + expect(r.status, r.stderr).toBe(0); + }; const repoCopy = () => { const dir = scratch("pstack-generate-"); cpSync(repoRoot, dir, { recursive: true, filter: (src) => ![".git", "node_modules"].includes(basename(src)) }); symlinkSync(join(repoRoot, "node_modules"), join(dir, "node_modules")); + git(dir, "init", "-q"); + git(dir, "add", "-A"); + git(dir, "commit", "-qm", "main"); + git(dir, "update-ref", "refs/remotes/origin/main", "HEAD"); return dir; }; const snapshot = (dir) => Object.fromEntries(walk(dir).map((path) => [path, readFileSync(path, "utf8")])); @@ -553,6 +562,22 @@ describe("plan, changes, apply", () => { expect(problems(root)).toEqual([expect.stringContaining("hooks/linked.sh does not exist in the plugin"), changed]); }); + test("problems compares the hooks files with origin/main, so a repinned change cannot keep its old changedIn", () => { + const root = repoCopy(); + const hooks = "plugins/pstack/hooks/codex-hooks.json"; + const text = readFileSync(join(root, hooks), "utf8").replace("startup|resume|clear|compact", "startup|resume|compact"); + writeFileSync(join(root, hooks), text); + const pins = JSON.parse(readFileSync(join(root, "tools/frozen-hooks.json"), "utf8")); + pins[hooks].sha256 = hookDigest(text); + writeFileSync(join(root, "tools/frozen-hooks.json"), JSON.stringify(pins, null, 2) + "\n"); + const version = readFileSync(join(root, "VERSION"), "utf8").trim(); + expect(problems(root)).toEqual([ + `${hooks} differs from origin/main, which already ships ${version}, so this change needs a new VERSION`, + ]); + rmSync(join(root, ".git"), { recursive: true }); + expect(problems(root)).toEqual([expect.stringMatching(/^tools\/frozen-hooks.json: cannot find origin\/main to compare the hooks files with: fatal: not a git repository/)]); + }); + test("problems reports a root with no plugin directory, down to the last check, and does not throw", () => { expect(problems(scratch("pstack-empty-"))).toContainEqual(expect.stringContaining("plugins/pstack/hooks/hooks.json")); }); @@ -563,31 +588,41 @@ describe("frozen hook entries", () => { const hooks = '{"hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"x"}]}]}}'; const changed = hooks.replace("startup", "startup|resume"); const changelog = (body) => `# CHANGES\n\n## 2.1.0 - Next\n\n${body}\n\n## 2.0.0 - Before\n\nRerun the installer.\n`; + const main = { version: "2.0.0", files: { [path]: hooks } }; + const faults = ({ pins, file = changed, version = "2.1.0", body = "Rerun the installer.", shipped = main }) => + frozenHookFaults({ pins, files: { [path]: file }, version, changelog: changelog(body), shipped }); - test("pass while each hooks file matches its pin, whatever its formatting", () => { + test("pass while each hooks file matches its pin and main, whatever its formatting", () => { const pins = { [path]: { sha256: hookDigest(hooks), changedIn: "2.0.0" } }; - const files = { [path]: JSON.stringify(JSON.parse(hooks), null, 2) }; - expect(frozenHookFaults({ pins, files, version: "2.1.0", changelog: changelog("Nothing.") })).toEqual([]); + expect(faults({ pins, file: JSON.stringify(JSON.parse(hooks), null, 2), body: "Nothing." })).toEqual([]); }); test("fail on a change until it is pinned to the release that announces it", () => { const pins = { [path]: { sha256: hookDigest(hooks), changedIn: "2.0.0" } }; - expect(frozenHookFaults({ pins, files: { [path]: changed }, version: "2.1.0", changelog: changelog("Nothing.") })).toEqual([ + expect(faults({ pins, body: "Nothing." })).toEqual([ `${path} changed, which stops its hook in every Codex home until the installer reruns. ` + `Pin "sha256": "${hookDigest(changed)}" and "changedIn": "2.1.0" in tools/frozen-hooks.json, ` + "and tell users to rerun the installer in CHANGES.md's 2.1.0 entry.", ]); const repinned = { [path]: { sha256: hookDigest(changed), changedIn: "2.1.0" } }; - expect(frozenHookFaults({ pins: repinned, files: { [path]: changed }, version: "2.1.0", changelog: changelog("Nothing.") })).toEqual([ + expect(faults({ pins: repinned, body: "Nothing." })).toEqual([ `${path} changes in 2.1.0, but CHANGES.md's 2.1.0 entry does not tell users to rerun the installer`, ]); - expect( - frozenHookFaults({ pins: repinned, files: { [path]: changed }, version: "2.1.0", changelog: changelog("Rerun the installer.") }), - ).toEqual([]); + expect(faults({ pins: repinned })).toEqual([]); + }); + + test("fail on a repinned change that keeps main's changedIn or main's VERSION", () => { + const stale = { [path]: { sha256: hookDigest(changed), changedIn: "2.0.0" } }; + const stalePin = `${path} differs from origin/main, so its pin's "changedIn" must be 2.1.0, not 2.0.0`; + expect(faults({ pins: stale })).toEqual([stalePin]); + expect(faults({ pins: stale, shipped: { version: "2.0.0", files: {} } })).toEqual([stalePin]); + expect(faults({ pins: stale, version: "2.0.0" })).toEqual([ + `${path} differs from origin/main, which already ships 2.0.0, so this change needs a new VERSION`, + ]); }); test("fail on a hooks file with no pin and a pin with no file", () => { - expect(frozenHookFaults({ pins: { "a.json": { sha256: "x" } }, files: { [path]: hooks }, version: "2.1.0", changelog: "" })).toEqual([ + expect(frozenHookFaults({ pins: { "a.json": { sha256: "x" } }, files: { [path]: hooks }, version: "2.1.0", changelog: "", shipped: main })).toEqual([ `${path} has no pin in tools/frozen-hooks.json`, "tools/frozen-hooks.json pins a.json, which no manifest names", ]); diff --git a/tools/generate.mjs b/tools/generate.mjs index 19691b33..649c830f 100644 --- a/tools/generate.mjs +++ b/tools/generate.mjs @@ -22,7 +22,7 @@ // the plugin layout, agent frontmatter, the Codex marketplace, and both hooks // files against their pins in tools/frozen-hooks.json. -import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; import { existsSync, lstatSync, @@ -42,7 +42,7 @@ import { Value } from "typebox/value"; import { panelFamilies, parseEntry } from "../plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs"; import { applyRegions, parseFrontmatter, ROUTED_SKILL, t3Regions } from "./derive-t3.mjs"; -import { PLUGIN, SKILLS } from "./plugin.mjs"; +import { FROZEN_HOOKS, hookDigest, PLUGIN, SKILLS } from "./plugin.mjs"; import { pathIsInside, validateSkillsTree, walk } from "./validate-skills.mjs"; export { parseFrontmatter }; @@ -383,11 +383,11 @@ export function validateHooks(hooksJson, { statOf, file = "hooks/hooks.json", ro // delivers a new entry at the next app-server start, so a changed hooks file // silently stops the hook in every home until the installer trusts it again. // Each hooks file is pinned with the release that last changed it, and that -// release's CHANGES entry must tell users to rerun the installer. -export const FROZEN_HOOKS = "tools/frozen-hooks.json"; -export const hookDigest = (text) => createHash("sha256").update(JSON.stringify(JSON.parse(text))).digest("hex"); - -export function frozenHookFaults({ pins, files, version, changelog }) { +// release's CHANGES entry must tell users to rerun the installer. `shipped` +// holds VERSION and the hooks files as origin/main has them, since main is +// what the marketplaces deliver: a file that differs from main's changes in +// this release, so its pin must name this release, and VERSION must be new. +export function frozenHookFaults({ pins, files, version, changelog, shipped }) { const faults = Object.keys(files) .filter((path) => !Object.hasOwn(pins, path)) .map((path) => `${path} has no pin in ${FROZEN_HOOKS}`); @@ -398,12 +398,17 @@ export function frozenHookFaults({ pins, files, version, changelog }) { continue; } const digest = hookDigest(files[path]); + const changed = shipped.files[path] === undefined || hookDigest(shipped.files[path]) !== digest; if (digest !== pin.sha256) { faults.push( `${path} changed, which stops its hook in every Codex home until the installer reruns. ` + `Pin "sha256": "${digest}" and "changedIn": "${version}" in ${FROZEN_HOOKS}, ` + `and tell users to rerun the installer in CHANGES.md's ${version} entry.`, ); + } else if (changed && version === shipped.version) { + faults.push(`${path} differs from origin/main, which already ships ${version}, so this change needs a new VERSION`); + } else if (changed && pin.changedIn !== version) { + faults.push(`${path} differs from origin/main, so its pin's "changedIn" must be ${version}, not ${pin.changedIn}`); } else if (pin.changedIn === version && !/rerun the installer/i.test(entry)) { faults.push(`${path} changes in ${version}, but CHANGES.md's ${version} entry does not tell users to rerun the installer`); } @@ -411,6 +416,21 @@ export function frozenHookFaults({ pins, files, version, changelog }) { return faults; } +// VERSION and `paths` at the merge-base of HEAD and origin/main, with +// undefined for a path main lacks. CI checks out full history for this. +function shippedOnMain(root, paths) { + const git = (...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + let base; + try { + base = git("merge-base", "HEAD", "origin/main").trim(); + } catch (err) { + throw new Error(`${FROZEN_HOOKS}: cannot find origin/main to compare the hooks files with: ${(err.stderr || err.message).trim()}`); + } + const listed = new Set(git("ls-tree", "-r", "--name-only", base, "--", "VERSION", ...paths).split("\n")); + const show = (path) => (listed.has(path) ? git("show", `${base}:${path}`) : undefined); + return { version: show("VERSION")?.trim(), files: Object.fromEntries(paths.map((path) => [path, show(path)])) }; +} + // Every file the generator writes, as exact text by repo-relative path, // computed from the sources under `root` without writing. Any other entry in // an owned directory is an orphan. Throws when a source cannot be planned. @@ -528,11 +548,13 @@ export function problems(root, models) { } // A Codex manifest that does not parse names no hooks file to compare. if (codex) attempt(() => { + const paths = hookFiles.map((file) => `${PLUGIN}/${file}`); const faults = frozenHookFaults({ pins: JSON.parse(read(FROZEN_HOOKS)), - files: Object.fromEntries(hookFiles.map((file) => [`${PLUGIN}/${file}`, read(`${PLUGIN}/${file}`)])), + files: Object.fromEntries(paths.map((path) => [path, read(path)])), version: read("VERSION").trim(), changelog: read("CHANGES.md"), + shipped: shippedOnMain(root, paths), }); if (faults.length) throw new Error(faults.join("\n")); }); diff --git a/tools/plugin.mjs b/tools/plugin.mjs index 87baad5b..4f54511c 100644 --- a/tools/plugin.mjs +++ b/tools/plugin.mjs @@ -1,8 +1,15 @@ -// Where the plugin lives in the repo, and the Markdown code spans the generator -// and the runtime table render identifiers with. +// Where the plugin lives in the repo, how its hooks files are pinned, and the +// Markdown code spans the generator and the runtime table render identifiers +// with. The installer imports this file, so it imports only Node. +import { createHash } from "node:crypto"; export const PLUGIN = "plugins/pstack"; export const SKILLS = `${PLUGIN}/skills`; +// Each hooks file's digest and the release that last changed it. A hooks +// file's digest ignores its formatting. +export const FROZEN_HOOKS = "tools/frozen-hooks.json"; +export const hookDigest = (text) => createHash("sha256").update(JSON.stringify(JSON.parse(text))).digest("hex"); + export const code = (s) => `\`${s}\``; export const codeList = (models) => models.map(code).join(", "); From b330c0207245e63ce130fe7b90b3c9b3cbcce6ca Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann <christian@bagerbach.com> Date: Wed, 14 Oct 2026 15:17:58 +0200 Subject: [PATCH 07/10] Installer: trust only chhoumann/t3-pstack or --source, and the review fixes A marketplace was identified by name only, so a counterfeit named t3-pstack got its plugin installed and its hook trusted, and doctor passed. Each home's marketplace source is now read, from settings.json and known_marketplaces.json for Claude Code and app-server config/read for Codex, and compared with chhoumann/t3-pstack and this run's --source. A home with any other source, a stale local --source included, gets nothing installed and no hook trusted, and doctor fails it showing the source. Codex trusts a hook only when its entry comes from the hooks file the Codex manifest names, at the digest tools/frozen-hooks.json pins. doctor shows each home's marketplace source. Also from review: - A file the installer replaces keeps its mode and, where allowed, its owner, and the records backup keeps its mode. - A JSON file that does not parse is reported by path and "invalid JSON", since JSON.parse's message quotes the input. - doctor checks that the installed version's files exist, and install reinstalls them when they do not, for both harnesses. - A plain skill copy is flagged only when its SKILL.md matches the plugin's, so a user's own skill that shares a name is left alone. - A leading ~ in an instance's CLAUDE_CONFIG_DIR, CODEX_HOME, or binaryPath expands as T3 expands it. - A home sheet that links outside every home becomes the machine sheet's link target instead of a copy, and a home sheet that links to another file is never replaced. - On Windows, a full path in binaryPath is a path, and npm's .cmd shims start through cmd.exe with each argument escaped as cross-spawn does. CI's Windows job runs the launch tests. - Temp files beside a target get a unique name and never replace one. - --source owner/repo is GitHub even where that relative directory exists, as both CLIs read it. - Re-enabling a disabled Codex plugin says "enabled it", and an update the marketplace cannot make says so instead of "updated X to X". - A non-JSON line from codex app-server fails the request cleanly. - The README says npx runs main's latest commit and shows the pinned form. --- .github/workflows/ci.yml | 2 + CHANGES.md | 18 +-- CONTRIBUTING.md | 2 +- README.md | 6 +- tests/install-cli.test.mjs | 68 +++++++++++ tests/install-e2e.test.mjs | 132 +++++++++++++++++--- tests/install.test.mjs | 236 +++++++++++++++++++++++++++++++++++- tests/readme-facts.test.mjs | 5 + tools/install.mjs | 41 ++++--- tools/install/claude.mjs | 72 ++++++++--- tools/install/codex.mjs | 137 ++++++++++++++++----- tools/install/common.mjs | 158 +++++++++++++++++++++--- tools/install/homes.mjs | 20 +-- tools/install/sheet.mjs | 33 +++-- 14 files changed, 802 insertions(+), 128 deletions(-) create mode 100644 tests/install-cli.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1181f5d..0f7cea3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: run: bun test --timeout 10000 tests/session-hook-windows.test.mjs env: PSTACK_REQUIRE_WINDOWS_HOOK: "1" + - name: Test the installer's CLI launch + run: bun test --timeout 30000 tests/install-cli.test.mjs forks: name: Fork registry diff --git a/CHANGES.md b/CHANGES.md index a0f7a4a6..7d643751 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,24 +4,26 @@ This file is the release changelog, with one `## <version> - <title>` entry per ## 2.0.0-rc.3 - One installer and doctor for Claude Code and Codex, a trusted Codex hook, and one sheet per machine -`npx -y github:chhoumann/t3-pstack install` installs or updates t3-pstack in every Claude Code and Codex home T3 can run, and `doctor` checks them. The homes are `~/.claude` and `~/.codex`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, each `claudeAgent` and `codex` provider instance in T3's `${T3CODE_HOME:-~/.t3}/userdata/settings.json`, disabled ones included, and any home named with `--claude-home` or `--codex-home`. Each step converges, so a second run changes nothing. +`npx -y github:chhoumann/t3-pstack install` installs or updates t3-pstack in every Claude Code and Codex home T3 can run, and `doctor` checks them. The homes are `~/.claude` and `~/.codex`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, each `claudeAgent` and `codex` provider instance in T3's `${T3CODE_HOME:-~/.t3}/userdata/settings.json`, disabled ones included, and any home named with `--claude-home` or `--codex-home`. Each step converges, so a second run changes nothing. Each home's `t3-pstack` marketplace must come from `chhoumann/t3-pstack` or from the run's `--source`. A home whose marketplace has another source, a counterfeit or a local `--source` from an earlier run, gets nothing installed and no hook trusted, and `doctor` fails it with that source. -Until now the Codex session-start hook never ran under T3. Codex 0.162.1 runs a plugin hook only once `hooks.state."<key>".trusted_hash` in `config.toml` matches the hook's entry, and `codex app-server`, which T3 runs, never asks for that trust. The installer trusts it on every run the way Codex's own TUI does: `hooks/list`, then `config/value/write` with the hash Codex reports and `mergeStrategy: "upsert"`, so the user's other trusted hooks stay. In a throwaway home with the plugin installed by hand, a T3-shaped app-server session fired no hook event and its rollout held no routing text. After the installer, the same session fired `hook/started` and `hook/completed`, and its rollout held the routing text as one developer message. `tests/install-e2e.test.mjs` runs that check with the real CLIs. +Until now the Codex session-start hook never ran under T3. Codex 0.162.1 runs a plugin hook only once `hooks.state."<key>".trusted_hash` in `config.toml` matches the hook's entry, and `codex app-server`, which T3 runs, never asks for that trust. The installer trusts it on every run the way Codex's own TUI does: `hooks/list`, then `config/value/write` with the hash Codex reports and `mergeStrategy: "upsert"`, so the user's other trusted hooks stay. It trusts an entry only when it comes from the hooks file the Codex manifest names, at the digest `tools/frozen-hooks.json` pins. In a throwaway home with the plugin installed by hand, a T3-shaped app-server session fired no hook event and its rollout held no routing text. After the installer, the same session fired `hook/started` and `hook/completed`, and its rollout held the routing text as one developer message. `tests/install-e2e.test.mjs` runs that check with the real CLIs. -Codex trusts a hook by a hash of its entry, and a Git marketplace upgrades the plugin at every app-server start, so a changed entry would stop routing in every home with no announcement. `tools/frozen-hooks.json` now pins both hooks files, and `bun tools/generate.mjs --check` fails when one changes unless its pin names the release and that release's entry here tells users to rerun the installer. Neither file changes in this release. +Codex trusts a hook by a hash of its entry, and a Git marketplace upgrades the plugin at every app-server start, so a changed entry would stop routing in every home with no announcement. `tools/frozen-hooks.json` now pins both hooks files, and `bun tools/generate.mjs --check` compares each with `origin/main`, which the marketplaces deliver. A file that differs fails the check unless its pin has the new digest, its `changedIn` names a new `VERSION`, and that release's entry here tells users to rerun the installer. Neither file changes in this release. The model sheet moves to `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, because provider instance IDs differ per machine, not per harness. The installer moves an existing `t3-pstack-models.md` there once and leaves a symlink at each home's old path, so a release before this one still reads the same sheet. Until the installer runs, the hook, the routing text, the skills' Models sections, and setup-pstack fall back to the old per-home sheet. | Change | 2.0.0-rc.2 | 2.0.0-rc.3 | Source | | --- | --- | --- | --- | -| Install and update | `claude plugin` and `codex plugin` commands by hand, in each home. | `npx -y github:chhoumann/t3-pstack install`, or `update`, the same command, for every home T3 can run. A listed home that does not exist yet is created, and a home whose CLI is not on `PATH` is skipped with a warning. A home already at the installer's version gets only the steps it still lacks, such as the Codex trust. | T3 installer | -| Codex session-start hook | Never ran under T3, because the app-server never asks for hook trust. The README said to trust it with `/hooks` in the Codex CLI. | The installer trusts it in every Codex home on every run, with the hash Codex reports, and fails the home unless Codex then reports it `trusted`. A rerun writes nothing. | T3 installer | -| `doctor` | None. | Reports, per home, the installed version against the installer's, whether the plugin is enabled, the Codex hook's trust, Claude Code records at another scope that pin an old version, plain copies of the plugin's skills, and the model sheet: present, parsed, and with `--catalog <file>` checked against an `orchestrator_capabilities` dump. It exits 1 on any problem and prints a fix for each. A missing sheet is a warning. | T3 installer | +| Install and update | `claude plugin` and `codex plugin` commands by hand, in each home. | `npx -y github:chhoumann/t3-pstack install`, or `update`, the same command, for every home T3 can run. It runs `main`'s latest commit, the channel the marketplaces auto-update from, and `#v<version>` pins one release. A listed home that does not exist yet is created, and a home whose CLI is not on `PATH` is skipped with a warning. A home already at the installer's version gets only the steps it still lacks, such as the Codex trust, and one whose plugin files are missing is reinstalled. A file it replaces keeps its mode and, where the system allows, its owner. | T3 installer | +| Marketplace source | Whatever each home was given. | `chhoumann/t3-pstack`, or the run's `--source`, which reads `owner/repo` as GitHub as both CLIs do, so a local directory needs a path such as `./t3-pstack`. Nothing installs from a `t3-pstack` marketplace with any other source, and none of its hooks is trusted. | T3 installer | +| Codex session-start hook | Never ran under T3, because the app-server never asks for hook trust. The README said to trust it with `/hooks` in the Codex CLI. | The installer trusts it in every Codex home on every run, with the hash Codex reports, once its hooks file is the one this release pins, and fails the home unless Codex then reports it `trusted`. A rerun writes nothing. | T3 installer | +| `doctor` | None. | Reports, per home, the marketplace's source, the installed version against the installer's and whether its files are all there, whether the plugin is enabled, the Codex hook's trust and pin, Claude Code records at another scope that pin an old version, copies of the plugin's skills whose `SKILL.md` matches the plugin's, and the model sheet: present, parsed, and with `--catalog <file>` checked against an `orchestrator_capabilities` dump. It exits 1 on any problem and prints a fix for each. A missing sheet is a warning. | T3 installer | | Claude Code marketplace auto-update | Off, Claude Code's default for a third-party marketplace. | The installer sets `extraKnownMarketplaces.t3-pstack.autoUpdate` in each home's `settings.json`. A home behind the installer's version gets `claude plugin marketplace update` and `claude plugin update`. | T3 installer | | A stale project-scope record | A second Claude Code config home could keep a project-scope `t3-pstack@t3-pstack` record for the home folder at an old version, because Claude Code reads `~/.claude/settings.json` as that folder's project settings. | The installer backs up that home's `plugins/installed_plugins.json` and brings each stale record at another scope to the user-scope install. It never runs `claude plugin uninstall --scope project`, which would edit the main home's settings. | T3 installer | -| Model sheet location | `t3-pstack-models.md` in each host's config directory, linked between hosts by hand. | `~/.config/t3-pstack/models.md`, one per machine. The installer moves the old sheet there once and links each home's old path to it. A home sheet that differs is left, and `doctor` names it. | T3 adaptation | +| Model sheet location | `t3-pstack-models.md` in each host's config directory, linked between hosts by hand. | `~/.config/t3-pstack/models.md`, one per machine. The installer moves the old sheet there once and links each home's old path to it. A home sheet that links to a file outside every home, such as one in a dotfiles repository, becomes the machine sheet's link target instead of a copy. A home sheet that differs is left, and `doctor` names it. A home sheet that links to another file is left too. | T3 adaptation | | setup-pstack | Wrote the host's sheet and linked the other host's path to it. | Writes the machine sheet, reads the old per-home sheet when the machine sheet is missing, and links this host's old path to the machine sheet. | Declared fork | -| A changed hooks file | `session-hook.test.mjs` pinned the 1.0.14 entries literally. | `tools/frozen-hooks.json` pins each hooks file's digest and the release that last changed it. The generator check fails on an unpinned change, or on a pinned one whose release entry does not tell users to rerun the installer. | T3 adaptation | +| A changed hooks file | `session-hook.test.mjs` pinned the 1.0.14 entries literally. | `tools/frozen-hooks.json` pins each hooks file's digest and the release that last changed it. The generator check compares each hooks file with `origin/main`, and fails on an unpinned change, on a repinned one whose `changedIn` or `VERSION` is not new, or on one whose release entry does not tell users to rerun the installer. CI's generated-files job checks out full history for it. | T3 adaptation | +| Windows | Not tested. | The installer starts the `.cmd` shims npm installs for `claude` and `codex` through `cmd.exe`, with each argument quoted and escaped as cross-spawn does, and reads a full Windows path in `binaryPath` as a path. CI's Windows job runs the launch tests. | T3 installer | | `t3-sheet.mjs` without a catalog | A catalog was required. | `checkSheet` with no catalog checks the grammar only. `doctor` uses it. | T3 adaptation | ## 2.0.0-rc.2 - Lauren's 0.15.24, four of Michael's rules, and three watch-pr fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7914a7b1..309cb648 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,7 +81,7 @@ uvx zizmor@1.29.0 --persona pedantic --min-severity low --collect all -- . - **A substitution that no longer matches upstream.** Update it to upstream's current sentence or delete it. - **`disable-model-invocation` on `poteto-mode`.** The session-start hook invokes it by name, and the flag makes the Skill tool refuse that. - **A root `plugins/pstack/plugin.json`.** Codex reads a plugin with upstream's Agent Plugins manifest as an Agent Plugin and truncates each injected SKILL.md to 8,000 bytes, which cuts `poteto-mode` off mid-file. `tools/upstream.json` excludes the file. -- **A changed hooks file.** Codex trusts a plugin hook by a hash of its entry, and a Git marketplace delivers a new entry at the next app-server start, so any change to `hooks/hooks.json` or `hooks/codex-hooks.json` stops routing in every Codex home until the installer reruns. `tools/frozen-hooks.json` pins both. Change `session-start.sh` or the routing text instead. When an entry must change, pin its new digest with `changedIn` set to the release, and tell users to rerun the installer in that release's `CHANGES.md` entry. +- **A changed hooks file.** Codex trusts a plugin hook by a hash of its entry, and a Git marketplace delivers a new entry at the next app-server start, so any change to `hooks/hooks.json` or `hooks/codex-hooks.json` stops routing in every Codex home until the installer reruns. `tools/frozen-hooks.json` pins both. Change `session-start.sh` or the routing text instead. When an entry must change, pin its new digest with `changedIn` set to the release, and tell users to rerun the installer in that release's `CHANGES.md` entry. The check compares each hooks file with `origin/main`, which the marketplaces deliver, so a file that differs from main's needs a new `VERSION` and a `changedIn` that names it, and the installer trusts only the entry its own release pins. Fetch `origin/main` before `bun tools/generate.mjs --check`. - **Stale generated output, or a missing `CHANGES.md` heading for `VERSION`.** - **An unresolved sync conflict marker** anywhere under `plugins/pstack`. - **A missing or escaping local Markdown link.** diff --git a/README.md b/README.md index e3c19d8e..2185d284 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ npx -y github:chhoumann/t3-pstack update npx -y github:chhoumann/t3-pstack doctor ``` -`install` and `update` are the same command, and a second run changes nothing. With Node 20 or later, it installs or updates the `t3-pstack@t3-pstack` plugin in every Claude Code and Codex config home T3 can run: `~/.claude` and `~/.codex`, `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME`, and the home of each `claudeAgent` and `codex` instance in T3's settings, disabled ones included. Name any other home with `--claude-home <dir>` or `--codex-home <dir>`. On Codex it also trusts t3-pstack's session-start hook. Codex runs a plugin's hook only once it is trusted, and T3's Codex sessions never ask, so without this step the hook never runs. Run `update` after each release. +`npx -y github:chhoumann/t3-pstack` runs the installer from the latest commit on `main`. That is the rolling channel, the same one each home's marketplace auto-updates from. To run one release's installer, name its tag, as in `npx -y github:chhoumann/t3-pstack#v2.0.0-rc.3 doctor`. -`doctor` reports on the same homes and changes nothing itself: the installed version and whether it is enabled, the Codex hook's trust, Claude Code records that pin an old version, plain copies of the plugin's skills, and the model sheet. It exits 1 on any problem and prints a fix for each. +`install` and `update` are the same command, and a second run changes nothing. With Node 20 or later, it installs or updates the `t3-pstack@t3-pstack` plugin in every Claude Code and Codex config home T3 can run: `~/.claude` and `~/.codex`, `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME`, and the home of each `claudeAgent` and `codex` instance in T3's settings, disabled ones included. Name any other home with `--claude-home <dir>` or `--codex-home <dir>`. Each home's `t3-pstack` marketplace must come from `chhoumann/t3-pstack`, or from `--source <owner/repo|git URL|dir>` on the same run, and a home whose marketplace has another source is left as it is. A local directory needs a path such as `./t3-pstack`, since `owner/repo` always means GitHub. On Codex it also trusts t3-pstack's session-start hook, once its hooks file is the one this release pins. Codex runs a plugin's hook only once it is trusted, and T3's Codex sessions never ask, so without this step the hook never runs. Run `update` after each release. + +`doctor` reports on the same homes and changes nothing itself: each marketplace's source, the installed version, whether its files are all there, whether it is enabled, the Codex hook's trust, Claude Code records that pin an old version, plain copies of the plugin's skills, and the model sheet. It exits 1 on any problem and prints a fix for each. Then run `/t3-pstack:setup-pstack` in a T3 thread on each machine. It writes the model sheet, `~/.config/t3-pstack/models.md`, with one T3 target per role (`<providerInstanceId>/<modelId>` plus that provider's options), and checks it against the machine's `orchestrator_capabilities`. Without a sheet, every role runs on the defaults in its skill's Models section. diff --git a/tests/install-cli.test.mjs b/tests/install-cli.test.mjs new file mode 100644 index 00000000..119def5f --- /dev/null +++ b/tests/install-cli.test.mjs @@ -0,0 +1,68 @@ +// The installer finds each CLI by path or on PATH, and starts it with every +// argument intact. On Windows, npm installs claude and codex as .cmd shims, +// which Node starts only through cmd.exe. CI's Windows job runs this file. +// The installer runs on Node, so the calls go through a Node child. +import { afterAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const common = pathToFileURL(fileURLToPath(new URL("../tools/install/common.mjs", import.meta.url))).href; +const windows = process.platform === "win32"; +const dir = realpathSync(mkdtempSync(join(tmpdir(), "t3-pstack-cli-"))); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +// What cmd.exe would split, expand, or run as a second command. +const ARGS = ["plugin", "C:\\Program Files (x86)\\t3 pstack\\", 'say "hi"', "a&b|c", "100%", "%PATH%", "^caret", "!bang!", "<in>out", "x;y,z", ""]; + +writeFileSync(join(dir, "argv.js"), "console.log(JSON.stringify(process.argv.slice(2)));\n"); +if (windows) { + // npm's cmd-shim, as it installs a package's bin, beside the sh script npm + // also writes, which Windows cannot run. + writeFileSync(join(dir, "fake-cli"), '#!/bin/sh\nexec node "$(dirname "$0")/argv.js" "$@"\n'); + writeFileSync( + join(dir, "fake-cli.cmd"), + [ + "@ECHO off", + "GOTO start", + ":find_dp0", + "SET dp0=%~dp0", + "EXIT /b", + ":start", + "SETLOCAL", + "CALL :find_dp0", + 'SET "_prog=node"', + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\argv.js" %*', + "", + ].join("\r\n"), + ); +} else { + writeFileSync(join(dir, "fake-cli"), `#!/bin/sh\nexec node "${join(dir, "argv.js")}" "$@"\n`); + chmodSync(join(dir, "fake-cli"), 0o755); +} + +function installer(bin, args) { + const script = + `import { hasCli, runCli } from ${JSON.stringify(common)};\n` + + "const [bin, args] = [process.argv[1], JSON.parse(process.argv[2])];\n" + + "console.log(JSON.stringify({ found: hasCli(bin), argv: JSON.parse(runCli(bin, args)) }));\n"; + const r = spawnSync("node", ["--input-type=module", "-e", script, bin, JSON.stringify(args)], { + env: { ...process.env, PATH: `${dir}${delimiter}${process.env.PATH}` }, + encoding: "utf8", + }); + expect(r.status, r.stderr).toBe(0); + return JSON.parse(r.stdout); +} + +describe("the installer's CLI launch", () => { + test("finds a CLI on PATH and passes every argument through unchanged", () => { + expect(installer("fake-cli", ARGS)).toEqual({ found: true, argv: ARGS }); + }); + + test("finds a CLI named by its full path", () => { + const path = join(dir, windows ? "fake-cli.cmd" : "fake-cli"); + expect(installer(path, ["plugin", "list"])).toEqual({ found: true, argv: ["plugin", "list"] }); + }); +}); diff --git a/tests/install-e2e.test.mjs b/tests/install-e2e.test.mjs index 91b2f2ef..36be3a16 100644 --- a/tests/install-e2e.test.mjs +++ b/tests/install-e2e.test.mjs @@ -24,6 +24,7 @@ import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { AppServer } from "../tools/install/codex.mjs"; +import { hookDigest } from "../tools/plugin.mjs"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const version = JSON.parse(readFileSync(join(repoRoot, "plugins/pstack/.claude-plugin/plugin.json"), "utf8")).version; @@ -41,8 +42,9 @@ function sh(bin, args, env = {}) { return r.stdout; } +// `source` null runs with no --source, as a user does. function installer(command, source = repoRoot) { - const r = spawnSync("node", [join(repoRoot, "tools/install.mjs"), command, "--source", source, "--json"], { + const r = spawnSync("node", [join(repoRoot, "tools/install.mjs"), command, ...(source ? ["--source", source] : []), "--json"], { cwd: root, env: process.env, encoding: "utf8", @@ -52,6 +54,16 @@ function installer(command, source = repoRoot) { const row = (report, dir) => report.homes.find((h) => h.dir === join(home, dir)); const failing = (checks) => checks.filter((c) => !c.ok && !c.warn).map((c) => `${c.name}: ${c.detail}`); +const HOOK = "t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0"; + +// The plugin as a marketplace of its own, outside this checkout. +function pluginCopy(dir) { + for (const path of [".claude-plugin", ".agents", "plugins", "tools/frozen-hooks.json"]) { + cpSync(join(repoRoot, path), join(dir, path), { recursive: true, filter: (src) => !src.includes("node_modules") }); + } + return dir; +} +const edit = (path, from, to) => writeFileSync(path, readFileSync(path, "utf8").replace(from, to)); async function until(done, ms) { for (const deadline = Date.now() + ms; Date.now() < deadline; await new Promise((r) => setTimeout(r, 200))) { @@ -170,7 +182,7 @@ describe.skipIf(!enabled)("the installer, end to end", () => { const { status, report } = installer("doctor"); expect(status).toBe(1); expect(failing(row(report, ".codex").checks)).toEqual([ - "session-start hook trusted: t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0 untrusted", + `session-start hook trusted: ${HOOK} untrusted`, ]); expect(failing(row(report, ".claude-pool").checks)).toEqual([ `other-scope records: project ${home} at 1.0.11`, @@ -187,12 +199,12 @@ describe.skipIf(!enabled)("the installer, end to end", () => { expect(failing([...report.homes.flatMap((h) => h.checks), ...report.sheet.checks])).toEqual([]); expect(status).toBe(0); expect(row(report, ".codex").steps).toEqual([ - "trusted hook t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0 (was untrusted)", + `trusted hook ${HOOK} (was untrusted)`, ]); expect(row(report, ".codex-work").steps).toEqual([ `added the t3-pstack marketplace from ${repoRoot.replace(/\/$/, "")}`, `installed ${version}`, - "trusted hook t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0 (was untrusted)", + `trusted hook ${HOOK} (was untrusted)`, ]); const pool = row(report, ".claude-pool").steps; expect(pool[0]).toBe(`moved the project-scope record for ${home} from 1.0.11 to ${version}`); @@ -241,16 +253,97 @@ describe.skipIf(!enabled)("the installer, end to end", () => { expect(installer("doctor").status).toBe(0); expect(existsSync(join(home, ".codex-work/config.toml"))).toBe(true); }, 300_000); + + test("install reinstalls a plugin whose files are gone, and enables a disabled one", () => { + const codexCache = join(home, ".codex/plugins/cache/t3-pstack/t3-pstack", version); + rmSync(join(codexCache, "skills/how"), { recursive: true }); + const poolRecord = JSON.parse(readFileSync(join(home, ".claude-pool/plugins/installed_plugins.json"), "utf8")).plugins["t3-pstack@t3-pstack"][0]; + rmSync(poolRecord.installPath, { recursive: true }); + edit(join(home, ".codex-work/config.toml"), /(\[plugins\."t3-pstack@t3-pstack"\]\s*enabled = )true/, "$1false"); + + const doctor = installer("doctor").report; + expect(failing(row(doctor, ".codex").checks)).toEqual([`installed: its files in ${codexCache} are missing`]); + expect(failing(row(doctor, ".claude-pool").checks)).toEqual([ + `installed: ${version}, but its files at ${poolRecord.installPath} are missing`, + ]); + expect(failing(row(doctor, ".codex-work").checks)).toEqual(["enabled: no", "session-start hook trusted: no t3-pstack hook listed"]); + + const { status, report } = installer("install"); + expect(row(report, ".codex").steps).toEqual([`reinstalled ${version}, whose files were missing`]); + expect(row(report, ".claude-pool").steps).toEqual([`reinstalled ${version}, whose files were missing`]); + expect(row(report, ".codex-work").steps).toEqual(["enabled it"]); + expect(status).toBe(0); + }, 300_000); + + test("doctor flags a plain copy of a plugin skill, not a user's own skill that shares a name", () => { + mkdirSync(join(home, ".agents/skills/how"), { recursive: true }); + mkdirSync(join(home, ".agents/skills/why"), { recursive: true }); + writeFileSync(join(home, ".agents/skills/how/SKILL.md"), "---\nname: how\ndescription: My own notes on how things work.\n---\n"); + cpSync(join(repoRoot, "plugins/pstack/skills/why/SKILL.md"), join(home, ".agents/skills/why/SKILL.md")); + const { status, report } = installer("doctor"); + expect(failing(row(report, ".codex").checks)).toEqual([`no plain copies of plugin skills: ${join(home, ".agents/skills/why/SKILL.md")}`]); + expect(status).toBe(1); + rmSync(join(home, ".agents/skills/why"), { recursive: true }); + expect(installer("doctor").status).toBe(0); + }, 300_000); +}); + +describe.skipIf(!enabled)("the installer, on a t3-pstack marketplace from another source", () => { + isolate({ + claudeAgent: { driver: "claudeAgent", enabled: true, config: { binaryPath: "claude", homePath: "" } }, + codex: { driver: "codex", enabled: true, config: { binaryPath: "codex", homePath: "" } }, + codex_dev: { driver: "codex", enabled: true, config: { homePath: "~/.codex-dev" } }, + }); + + test("installs nothing from it, trusts none of its hooks, and doctor names the source", async () => { + // A marketplace that calls itself t3-pstack, with a hook that leaves a mark. + const counterfeit = pluginCopy(join(root, "counterfeit")); + const marked = join(root, "hook-ran"); + edit(join(counterfeit, "plugins/pstack/hooks/codex-hooks.json"), /"command": "[^\n]*",\n/, `"command": "touch ${marked}",\n`); + for (const [bin, dir, variable] of [["codex", ".codex", "CODEX_HOME"], ["claude", ".claude", "CLAUDE_CONFIG_DIR"]]) { + mkdirSync(join(home, dir), { recursive: true }); + sh(bin, ["plugin", "marketplace", "add", counterfeit], { [variable]: join(home, dir) }); + } + sh("codex", ["plugin", "add", "t3-pstack@t3-pstack"], { CODEX_HOME: join(home, ".codex") }); + + const { status, report } = installer("install"); + expect(status).toBe(1); + expect(row(report, ".codex").steps).toEqual([]); + expect(row(report, ".claude").steps).toEqual([]); + const foreign = `marketplace: ${counterfeit}, which is neither chhoumann/t3-pstack nor --source`; + expect(failing(row(report, ".codex").checks)).toEqual([ + foreign, + `session-start hook trusted: ${HOOK} untrusted, not from the hooks/codex-hooks.json this release pins`, + ]); + expect(failing(row(report, ".claude").checks)).toEqual([foreign, "installed: not installed", "enabled: no"]); + expect(row(report, ".codex-dev").steps).toEqual([ + `added the t3-pstack marketplace from ${repoRoot.replace(/\/$/, "")}`, + `installed ${version}`, + `trusted hook ${HOOK} (was untrusted)`, + ]); + + const after = await probe(join(home, ".codex")); + expect(after.hookEvents).toEqual([]); + expect(existsSync(marked)).toBe(false); + }, 300_000); + + test("a later run without that --source reuses none of its marketplaces", () => { + const before = snapshot(); + const { status, report } = installer("install", null); + expect(status).toBe(1); + expect(report.homes.flatMap((h) => h.steps)).toEqual([]); + expect(failing(row(report, ".codex-dev").checks)).toEqual([ + `marketplace: ${repoRoot.replace(/\/$/, "")}, which is neither chhoumann/t3-pstack nor --source`, + ]); + expect(snapshot()).toEqual(before); + }, 300_000); }); describe.skipIf(!enabled)("the installer, on a release that changes the hook entry", () => { isolate({}); - test("updates both homes and trusts the changed hook again", async () => { - const source = join(root, "source"); - for (const path of [".claude-plugin", ".agents", "plugins"]) { - cpSync(join(repoRoot, path), join(source, path), { recursive: true, filter: (src) => !src.includes("node_modules") }); - } + test("updates both homes, and trusts the changed hook once the release pins it", async () => { + const source = pluginCopy(join(root, "source")); expect(installer("install", source).status).toBe(0); const next = "9.9.9"; @@ -259,17 +352,26 @@ describe.skipIf(!enabled)("the installer, on a release that changes the hook ent writeFileSync(path, readFileSync(path, "utf8").replace(`"version": "${version}"`, `"version": "${next}"`)); } const hooks = join(source, "plugins/pstack/hooks/codex-hooks.json"); - writeFileSync(hooks, readFileSync(hooks, "utf8").replace("startup|resume|clear|compact", "startup|resume|compact|clear")); + edit(hooks, "startup|resume|clear|compact", "startup|resume|compact|clear"); + + const unpinned = installer("update", source); + expect(unpinned.status).toBe(1); + expect(row(unpinned.report, ".claude").steps).toEqual([`updated ${version} to ${next}`]); + expect(row(unpinned.report, ".codex").steps).toEqual([`updated ${version} to ${next}`]); + expect(failing(row(unpinned.report, ".codex").checks)).toEqual([ + `session-start hook trusted: ${HOOK} modified, not from the hooks/codex-hooks.json this release pins`, + ]); + const pins = join(source, "tools/frozen-hooks.json"); + const pinned = JSON.parse(readFileSync(pins, "utf8")); + pinned["plugins/pstack/hooks/codex-hooks.json"] = { sha256: hookDigest(readFileSync(hooks, "utf8")), changedIn: next }; + writeFileSync(pins, JSON.stringify(pinned, null, 2) + "\n"); const { status, report } = installer("update", source); console.log("release:", JSON.stringify(report.homes.map(({ dir, steps }) => ({ dir: relative(home, dir), steps })))); expect(failing([...report.homes.flatMap((h) => h.checks), ...report.sheet.checks])).toEqual([]); expect(status).toBe(0); - expect(row(report, ".claude").steps).toEqual([`updated ${version} to ${next}`]); - expect(row(report, ".codex").steps).toEqual([ - `updated ${version} to ${next}`, - "trusted hook t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0 (was modified)", - ]); + expect(row(report, ".claude").steps).toEqual([]); + expect(row(report, ".codex").steps).toEqual([`trusted hook ${HOOK} (was modified)`]); const after = await probe(join(home, ".codex")); expect(after.hookEvents).toEqual(["hook/started", "hook/completed"]); expect(after.routedMessages).toBe(1); diff --git a/tests/install.test.mjs b/tests/install.test.mjs index 077f006f..98c4264c 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -3,7 +3,9 @@ // sheet, and what doctor reports. tests/install-e2e.test.mjs drives the real // CLIs. import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -13,6 +15,7 @@ import { readlinkSync, realpathSync, rmSync, + statSync, symlinkSync, writeFileSync, } from "node:fs"; @@ -20,7 +23,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { doctorClaude, syncScopedRecords } from "../tools/install/claude.mjs"; +import { doctorClaude, installClaude, syncScopedRecords } from "../tools/install/claude.mjs"; +import { AppServer } from "../tools/install/codex.mjs"; +import { parseSource, writeAtomic } from "../tools/install/common.mjs"; import { discoverHomes } from "../tools/install/homes.mjs"; import { doctorSheet, moveSheet } from "../tools/install/sheet.mjs"; @@ -41,6 +46,22 @@ const writeJson = (path, value) => { mkdirSync(join(path, ".."), { recursive: true }); writeFileSync(path, JSON.stringify(value, null, 2) + "\n"); }; +const mode = (path) => statSync(path).mode & 0o777; + +// The plugin's own skills, and a doctor context that trusts the GitHub repo. +const SKILL = (name) => readFileSync(join(repoRoot, "plugins/pstack/skills", name, "SKILL.md"), "utf8"); +const skills = new Map(["how", "why"].map((name) => [name, SKILL(name)])); +const github = parseSource("chhoumann/t3-pstack", "/home/u"); +const context = { source: github, trusted: new Set([github.key]), version: "2.0.0-rc.3", skills }; + +// A `claude` that records each call and changes nothing. +function fakeClaude() { + const dir = tmp(); + const bin = join(dir, "claude"); + writeFileSync(bin, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${dir}/calls"\n`); + chmodSync(bin, 0o755); + return { bin, calls: () => (existsSync(join(dir, "calls")) ? readFileSync(join(dir, "calls"), "utf8").trim().split("\n") : []) }; +} describe("discoverHomes", () => { test("resolves each home as T3 does and merges the sources that name it", () => { @@ -77,6 +98,32 @@ describe("discoverHomes", () => { }); }); + test("expands a leading ~ in an instance's home variable and binary as T3 does", () => { + const t3 = tmp(); + writeJson(join(t3, "userdata/settings.json"), { + providerInstances: { + claude_env: { driver: "claudeAgent", environment: [{ name: "CLAUDE_CONFIG_DIR", value: "~/.claude-env" }], config: { binaryPath: "~/bin/claude" } }, + codex_env: { driver: "codex", environment: [{ name: "CODEX_HOME", value: "~\\.codex-env" }] }, + }, + }); + const { homes } = discoverHomes({ env: { T3CODE_HOME: t3 }, home: "/home/u" }); + expect(homes.slice(2)).toEqual([ + { harness: "claude", dir: "/home/u/.claude-env", from: ["T3 instance claude_env"], bin: "/home/u/bin/claude" }, + { harness: "codex", dir: "/home/u/.codex-env", from: ["T3 instance codex_env"], bin: "codex" }, + ]); + }); + + test("reports T3 settings that do not parse without quoting them", () => { + const t3 = tmp(); + mkdirSync(join(t3, "userdata"), { recursive: true }); + writeFileSync(join(t3, "userdata/settings.json"), '{"providerInstances": {"codex": {"environment": [{"name": "OPENAI_API_KEY", "value": "sk-live-123'); + expect(discoverHomes({ env: { T3CODE_HOME: t3 }, home: "/home/u" }).settings).toEqual({ + path: join(t3, "userdata/settings.json"), + instances: 0, + error: "invalid JSON", + }); + }); + test("falls back to the defaults when T3's settings are missing", () => { const t3 = tmp(); expect(discoverHomes({ env: { T3CODE_HOME: t3 }, home: "/home/u" })).toEqual({ @@ -110,6 +157,11 @@ function claudeHome({ autoUpdate } = {}) { gitCommitSha: "0000000000000000000000000000000000000000", }; const other = { scope: "project", projectPath: "/home/u", installPath: "/x", version: "0.1.0" }; + writeJson(join(user.installPath, ".claude-plugin/plugin.json"), { name: "t3-pstack", version: user.version }); + for (const [name, text] of skills) { + mkdirSync(join(user.installPath, "skills", name), { recursive: true }); + writeFileSync(join(user.installPath, "skills", name, "SKILL.md"), text); + } writeJson(join(dir, "plugins/installed_plugins.json"), { version: 2, plugins: { "t3-pstack@t3-pstack": [user, project], "other@market": [other] }, @@ -154,12 +206,22 @@ describe("a stale other-scope Claude Code record", () => { expect(readdirSync(join(dir, "plugins")).filter((name) => name.endsWith(".bak"))).toHaveLength(1); }); + test("keeps the records file's mode, and its backup's", () => { + const { dir } = claudeHome(); + const path = join(dir, "plugins/installed_plugins.json"); + chmodSync(path, 0o600); + syncScopedRecords(dir); + const backup = readdirSync(join(dir, "plugins")).find((name) => name.endsWith(".bak")); + expect([mode(path), mode(join(dir, "plugins", backup))]).toEqual([0o600, 0o600]); + }); + test("doctor names it, the missing auto-update, and plain skill copies, and passes once they are fixed", () => { const { dir } = claudeHome(); mkdirSync(join(dir, "skills/how"), { recursive: true }); + writeFileSync(join(dir, "skills/how/SKILL.md"), SKILL("how")); const home = { harness: "claude", dir, from: ["default"], bin: "claude" }; - const context = { version: "2.0.0-rc.3", skills: ["how", "why"] }; expect(doctorClaude(home, context)).toEqual([ + { name: "marketplace", ok: true, detail: "chhoumann/t3-pstack" }, { name: "installed", ok: true, detail: "2.0.0-rc.3" }, { name: "enabled", ok: true, detail: "yes" }, { name: "marketplace auto-update", ok: false, detail: "false", fix: FIX }, @@ -171,7 +233,7 @@ describe("a stale other-scope Claude Code record", () => { fix: "delete these folders; the plugin ships the same skills", }, ]); - expect(doctorClaude(home, { ...context, version: "2.0.0-rc.4" })[0]).toEqual({ + expect(doctorClaude(home, { ...context, version: "2.0.0-rc.4" })[1]).toEqual({ name: "installed", ok: false, detail: "2.0.0-rc.3, expected 2.0.0-rc.4", @@ -185,6 +247,129 @@ describe("a stale other-scope Claude Code record", () => { writeJson(join(dir, "settings.json"), settings); expect(doctorClaude(home, context).filter((c) => !c.ok)).toEqual([]); }); + + test("doctor leaves a user's own skill that shares a plugin skill's name", () => { + const { dir } = claudeHome({ autoUpdate: true }); + syncScopedRecords(dir); + mkdirSync(join(dir, "skills/how"), { recursive: true }); + writeFileSync(join(dir, "skills/how/SKILL.md"), "---\nname: how\ndescription: My own notes on how things work.\n---\n"); + const home = { harness: "claude", dir, from: ["default"], bin: "claude" }; + expect(doctorClaude(home, context).filter((c) => !c.ok)).toEqual([]); + }); + + test("doctor fails an install whose files are gone, and install reinstalls it", () => { + const { dir, user } = claudeHome({ autoUpdate: true }); + syncScopedRecords(dir); + rmSync(user.installPath, { recursive: true }); + const claude = fakeClaude(); + const home = { harness: "claude", dir, from: ["default"], bin: claude.bin }; + expect(doctorClaude(home, context).filter((c) => !c.ok)).toEqual([ + { name: "installed", ok: false, detail: `2.0.0-rc.3, but its files at ${user.installPath} are missing`, fix: FIX }, + ]); + expect(installClaude(home, context)).toEqual(["reinstalled 2.0.0-rc.3, whose files were missing"]); + expect(claude.calls()).toEqual(["plugin install t3-pstack@t3-pstack --scope user"]); + }); + + test("install says so when the marketplace has no newer version to update to", () => { + const { dir } = claudeHome({ autoUpdate: true }); + syncScopedRecords(dir); + const claude = fakeClaude(); + const home = { harness: "claude", dir, from: ["default"], bin: claude.bin }; + expect(installClaude(home, { ...context, version: "2.0.0-rc.4" })).toEqual(["the marketplace has no 2.0.0-rc.4, so it stays at 2.0.0-rc.3"]); + expect(claude.calls()).toEqual(["plugin marketplace update t3-pstack", "plugin update t3-pstack@t3-pstack --scope user"]); + }); + + test("doctor reports settings.json that does not parse by path alone", () => { + const { dir } = claudeHome(); + writeFileSync(join(dir, "settings.json"), '{"env": {"ANTHROPIC_API_KEY": "sk-ant-live-123"'); + const home = { harness: "claude", dir, from: ["default"], bin: "claude" }; + expect(() => doctorClaude(home, context)).toThrow(new Error(`${join(dir, "settings.json")}: invalid JSON`)); + }); + + test("turning on auto-update keeps settings.json's mode", () => { + const { dir } = claudeHome(); + chmodSync(join(dir, "settings.json"), 0o600); + const claude = fakeClaude(); + const steps = installClaude({ harness: "claude", dir, from: ["default"], bin: claude.bin }, context); + expect(steps[0]).toBe("turned on marketplace auto-update"); + expect(mode(join(dir, "settings.json"))).toBe(0o600); + }); +}); + +describe("a t3-pstack marketplace from another source", () => { + function counterfeit() { + const { dir } = claudeHome({ autoUpdate: true }); + const elsewhere = tmp(); + const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8")); + settings.extraKnownMarketplaces["t3-pstack"].source = { source: "directory", path: elsewhere }; + writeJson(join(dir, "settings.json"), settings); + rmSync(join(dir, "plugins/installed_plugins.json")); + return { dir, elsewhere }; + } + + test("installs nothing, and doctor fails it with the source", () => { + const { dir, elsewhere } = counterfeit(); + const claude = fakeClaude(); + const home = { harness: "claude", dir, from: ["default"], bin: claude.bin }; + expect(installClaude(home, context)).toEqual([]); + expect(claude.calls()).toEqual([]); + expect(doctorClaude(home, context)[0]).toEqual({ + name: "marketplace", + ok: false, + detail: `${elsewhere}, which is neither chhoumann/t3-pstack nor --source`, + fix: `if you trust it, rerun with --source ${elsewhere}; otherwise remove it with \`CLAUDE_CONFIG_DIR=${dir} claude plugin marketplace remove t3-pstack\`, then ${FIX}`, + }); + }); + + test("is trusted when it is this run's --source", () => { + const { dir, elsewhere } = counterfeit(); + const local = parseSource(elsewhere, "/home/u"); + const home = { harness: "claude", dir, from: ["default"], bin: "claude" }; + expect(doctorClaude(home, { ...context, source: local, trusted: new Set([github.key, local.key]) })[0]).toEqual({ + name: "marketplace", + ok: true, + detail: elsewhere, + }); + }); +}); + +describe("--source", () => { + test("reads owner/repo as GitHub even where that relative directory exists, and a URL to the same repository as the same source", () => { + const cwd = tmp(); + mkdirSync(join(cwd, "chhoumann/t3-pstack"), { recursive: true }); + const r = spawnSync(process.execPath, [join(repoRoot, "tools/install.mjs"), "doctor", "--source", "chhoumann/t3-pstack", "--json"], { + cwd, + env: { HOME: tmp(), PATH: tmp() }, + encoding: "utf8", + }); + expect({ status: r.status, stderr: r.stderr, source: r.status === 0 && JSON.parse(r.stdout).source }).toEqual({ + status: 0, + stderr: "", + source: "chhoumann/t3-pstack", + }); + expect(parseSource("git@github.com:ChHoumann/t3-pstack.git", "/home/u").key).toBe(github.key); + expect(parseSource("https://github.com/chhoumann/t3-pstack", "/home/u").key).toBe(github.key); + expect(() => parseSource("./missing", cwd)).toThrow("--source ./missing: not owner/repo, a Git URL, or an existing directory"); + }); +}); + +describe("files the installer replaces", () => { + test("keep their mode", () => { + const path = join(tmp(), "settings.json"); + writeFileSync(path, "{}\n", { mode: 0o600 }); + writeAtomic(path, '{"a": 1}\n'); + expect([readFileSync(path, "utf8"), mode(path)]).toEqual(['{"a": 1}\n', 0o600]); + }); +}); + +describe("codex app-server output that is not JSON-RPC", () => { + test("fails the request instead of crashing the run", async () => { + const dir = tmp(); + const bin = join(dir, "codex"); + writeFileSync(bin, "#!/bin/sh\necho 'token sk-live-123 expired'\nexec cat >/dev/null\n"); + chmodSync(bin, 0o755); + await expect(AppServer.start({ dir, bin })).rejects.toThrow(new Error("codex app-server printed a line that is not JSON-RPC")); + }); }); describe("the model sheet", () => { @@ -220,6 +405,51 @@ describe("the model sheet", () => { ]); }); + test("links the machine path to a home sheet's file outside every home instead of copying it", () => { + const { root, homes, sheet } = machine(); + const dotfiles = join(root, "dotfiles/t3-pstack-models.md"); + mkdirSync(join(root, "dotfiles")); + writeFileSync(dotfiles, SHEET); + symlinkSync(dotfiles, join(root, ".claude/t3-pstack-models.md")); + writeFileSync(join(root, ".codex/t3-pstack-models.md"), SHEET); + + expect(moveSheet(sheet, homes)).toEqual([ + `linked the sheet to ${dotfiles}`, + `linked ${join(root, ".codex/t3-pstack-models.md")} to it`, + `linked ${join(root, ".codex-pool/t3-pstack-models.md")} to it`, + ]); + expect(readlinkSync(sheet)).toBe(dotfiles); + expect(readlinkSync(join(root, ".claude/t3-pstack-models.md"))).toBe(dotfiles); + expect(readFileSync(dotfiles, "utf8")).toBe(SHEET); + expect(moveSheet(sheet, homes)).toEqual([]); + }); + + test("keeps a home sheet that links to another file, even one that matches the machine sheet", () => { + const { root, homes, sheet } = machine(); + mkdirSync(join(root, ".config/t3-pstack"), { recursive: true }); + writeFileSync(sheet, SHEET); + mkdirSync(join(root, "dotfiles")); + writeFileSync(join(root, "dotfiles/models.md"), SHEET); + symlinkSync(join(root, "dotfiles/models.md"), join(root, ".claude/t3-pstack-models.md")); + + expect(moveSheet(sheet, homes)).toEqual([ + `linked ${join(root, ".codex/t3-pstack-models.md")} to it`, + `linked ${join(root, ".codex-pool/t3-pstack-models.md")} to it`, + ]); + expect(readlinkSync(join(root, ".claude/t3-pstack-models.md"))).toBe(join(root, "dotfiles/models.md")); + }); + + test("moves a sheet with its mode, and leaves a file beside it that only shares the old temp name", () => { + const { root, homes, sheet } = machine(); + writeFileSync(join(root, ".claude/t3-pstack-models.md"), SHEET, { mode: 0o600 }); + writeFileSync(join(root, ".claude/t3-pstack-models.md.t3-pstack-link"), "mine\n"); + + moveSheet(sheet, homes); + expect(mode(sheet)).toBe(0o600); + expect(readFileSync(join(root, ".claude/t3-pstack-models.md.t3-pstack-link"), "utf8")).toBe("mine\n"); + expect(readdirSync(join(root, ".claude")).sort()).toEqual(["t3-pstack-models.md", "t3-pstack-models.md.t3-pstack-link"]); + }); + test("leaves home sheets that disagree, and doctor says so", () => { const { root, homes, sheet } = machine(); writeFileSync(join(root, ".claude/t3-pstack-models.md"), SHEET); diff --git a/tests/readme-facts.test.mjs b/tests/readme-facts.test.mjs index f55bfdc1..591b43d5 100644 --- a/tests/readme-facts.test.mjs +++ b/tests/readme-facts.test.mjs @@ -24,6 +24,11 @@ describe("README facts match the tree", () => { expect(numbersBefore("agents")).toEqual([agents]); }); + test("the pinned installer command names this release's tag", () => { + const version = readFileSync(join(repoRoot, "VERSION"), "utf8").trim(); + expect([...readme.matchAll(/npx -y github:chhoumann\/t3-pstack#v([^\s`]+)/g)].map((m) => m[1])).toEqual([version]); + }); + test("the documented upstream pin matches every component's pin", () => { const named = [...readme.matchAll(/synced against upstream `([0-9a-f]+)`/g)].map((m) => m[1]); expect(named).toHaveLength(1); diff --git a/tools/install.mjs b/tools/install.mjs index faaa0f34..2da40c19 100755 --- a/tools/install.mjs +++ b/tools/install.mjs @@ -7,23 +7,29 @@ // the Codex app-server it starts refreshes a Git marketplace, as any T3 Codex // thread does. Both exit 1 when a check fails. // +// Each home's t3-pstack marketplace must come from chhoumann/t3-pstack or from +// this run's --source. A home whose marketplace has another source, such as a +// local --source from an earlier run, gets nothing installed and no hook +// trusted, and doctor fails it with that source. +// // npx -y github:chhoumann/t3-pstack <install|update|doctor> -// --source <path|owner/repo|git URL> marketplace source, default chhoumann/t3-pstack +// --source <owner/repo|git URL|dir> marketplace source, default chhoumann/t3-pstack // --claude-home <dir>, --codex-home <dir> more homes, repeatable // --catalog <file> an orchestrator_capabilities dump to check the sheet's targets against // --json // Node 20 or later, no dependencies. import { existsSync, readdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { doctorClaude, installClaude } from "./install/claude.mjs"; import { doctorCodex, installCodex } from "./install/codex.mjs"; -import { check, hasCli, INSTALL_HINT } from "./install/common.mjs"; +import { check, hasCli, HOME_REPO, INSTALL_HINT, parseSource, readJson } from "./install/common.mjs"; import { discoverHomes } from "./install/homes.mjs"; import { doctorSheet, moveSheet, sheetPath } from "./install/sheet.mjs"; +import { FROZEN_HOOKS } from "./plugin.mjs"; const HARNESSES = { claude: { name: "Claude Code", install: installClaude, doctor: doctorClaude }, @@ -31,17 +37,22 @@ const HARNESSES = { }; const USAGE = - "usage: t3-pstack <install|update|doctor> [--source <path|owner/repo|git URL>] " + + "usage: t3-pstack <install|update|doctor> [--source <owner/repo|git URL|dir>] " + "[--claude-home <dir>]... [--codex-home <dir>]... [--catalog <file>] [--json]"; -// The version and roles to converge on come from the plugin this run installs: -// the local source, or else this package, which is the marketplace's own repo. +// What to converge on comes from the plugin this run installs: the local +// source, or else this package, which is the marketplace's own repo. `skills` +// maps each skill to its SKILL.md, and `codexHook` is the hooks file the +// Codex manifest names, with its pinned digest. function pluginFacts(root) { const plugin = join(root, "plugins/pstack"); - const { roles, tiers } = JSON.parse(readFileSync(join(plugin, "models.json"), "utf8")); + const { roles, tiers } = readJson(join(plugin, "models.json")); + const hooks = readJson(join(plugin, ".codex-plugin/plugin.json")).hooks.replace(/^\.\//, ""); + const skills = readdirSync(join(plugin, "skills"), { withFileTypes: true }).filter((entry) => entry.isDirectory()); return { - version: JSON.parse(readFileSync(join(plugin, ".claude-plugin/plugin.json"), "utf8")).version, - skills: readdirSync(join(plugin, "skills")), + version: readJson(join(plugin, ".claude-plugin/plugin.json")).version, + skills: new Map(skills.map(({ name }) => [name, readFileSync(join(plugin, "skills", name, "SKILL.md"), "utf8")])), + codexHook: { path: hooks, sha256: readJson(join(root, FROZEN_HOOKS))[`plugins/pstack/${hooks}`].sha256 }, roles, tiers, }; @@ -62,19 +73,19 @@ async function run(argv, env, home) { const [command] = positionals; if (positionals.length !== 1 || !["install", "update", "doctor"].includes(command)) return { usage: true }; - const localSource = values.source && existsSync(values.source) ? resolve(values.source) : undefined; - const source = localSource ?? values.source ?? "chhoumann/t3-pstack"; - const facts = pluginFacts(localSource ?? fileURLToPath(new URL("..", import.meta.url))); - const context = { source, localSource, version: facts.version, skills: facts.skills }; + const source = parseSource(values.source ?? HOME_REPO, home); + const facts = pluginFacts(source.local ?? fileURLToPath(new URL("..", import.meta.url))); + const trusted = new Set([parseSource(HOME_REPO, home).key, source.key]); + const context = { source, trusted, version: facts.version, skills: facts.skills, codexHook: facts.codexHook }; const { homes, settings } = discoverHomes({ env, home, claudeHomes: values["claude-home"], codexHomes: values["codex-home"], }); - const catalog = values.catalog ? JSON.parse(readFileSync(values.catalog, "utf8")) : null; + const catalog = values.catalog ? readJson(values.catalog) : null; - const report = { command, version: facts.version, source, settings, homes: [] }; + const report = { command, version: facts.version, source: source.arg, settings, homes: [] }; for (const target of homes) { const harness = HARNESSES[target.harness]; const row = { harness: target.harness, dir: target.dir, from: target.from, steps: [], checks: [] }; diff --git a/tools/install/claude.mjs b/tools/install/claude.mjs index 94f38089..52956f96 100644 --- a/tools/install/claude.mjs +++ b/tools/install/claude.mjs @@ -1,20 +1,36 @@ // Claude Code: the marketplace plugin at user scope, in one config home. -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { constants, copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; import { join } from "node:path"; -import { check, INSTALL_HINT, MARKETPLACE, PLUGIN_ID, runCli, writeAtomic } from "./common.mjs"; +import { + check, + claudeSource, + INSTALL_HINT, + isPlainCopy, + MARKETPLACE, + marketplaceCheck, + PLUGIN_ID, + pluginIntact, + readJson, + runCli, + updated, + writeAtomic, +} from "./common.mjs"; -const readJson = (path, fallback) => (existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback); +const readOr = (path, fallback) => (existsSync(path) ? readJson(path) : fallback); const RECORDS = "plugins/installed_plugins.json"; function read(dir) { - const settings = readJson(join(dir, "settings.json"), {}); - const known = readJson(join(dir, "plugins/known_marketplaces.json"), {}); - const records = readJson(join(dir, RECORDS), {}).plugins?.[PLUGIN_ID] ?? []; + const settings = readOr(join(dir, "settings.json"), {}); + const known = readOr(join(dir, "plugins/known_marketplaces.json"), {}); + const records = readOr(join(dir, RECORDS), {}).plugins?.[PLUGIN_ID] ?? []; const user = records.find((r) => r.scope === "user"); + const declared = settings.extraKnownMarketplaces?.[MARKETPLACE]; return { settings, - marketplace: settings.extraKnownMarketplaces?.[MARKETPLACE] ?? known[MARKETPLACE], + marketplace: declared ?? known[MARKETPLACE], + // The source settings.json declares and the one Claude Code last fetched. + sources: [declared, known[MARKETPLACE]].filter((m) => m?.source).map((m) => claudeSource(m.source)), user, stale: user ? records.filter((r) => isStale(r, user)) : [], enabled: settings.enabledPlugins?.[PLUGIN_ID] === true, @@ -24,20 +40,24 @@ function read(dir) { const isStale = (record, user) => record.scope !== "user" && (record.version !== user.version || record.installPath !== user.installPath); +const intact = (state, skills) => state.user !== undefined && pluginIntact(state.user.installPath, ".claude-plugin/plugin.json", skills); + // Third-party marketplaces default to no auto-update; the /plugin toggle // writes this key, and Claude Code copies it into known_marketplaces.json at // its next start. A directory source loads in place and has nothing to fetch. const autoUpdates = (marketplace) => marketplace?.autoUpdate === true || marketplace?.source?.source === "directory"; -export function installClaude(home, { source, version }) { +export function installClaude(home, { source, trusted, version, skills }) { const steps = []; const plugin = (...args) => runCli(home.bin, ["plugin", ...args], { CLAUDE_CONFIG_DIR: home.dir }); mkdirSync(home.dir, { recursive: true }); - let state = read(home.dir); - if (!state.marketplace) { - plugin("marketplace", "add", source); - steps.push(`added the ${MARKETPLACE} marketplace from ${source}`); + if (!read(home.dir).sources.length) { + plugin("marketplace", "add", source.arg); + steps.push(`added the ${MARKETPLACE} marketplace from ${source.arg}`); } + // Nothing installs from a marketplace of another source; doctor names it. + let state = read(home.dir); + if (!marketplaceCheck(state.sources, trusted).ok) return steps; if (!state.user) { plugin("install", PLUGIN_ID, "--scope", "user"); steps.push(`installed ${read(home.dir).user?.version}`); @@ -45,7 +65,10 @@ export function installClaude(home, { source, version }) { // `plugin update` reads the marketplace as last fetched. plugin("marketplace", "update", MARKETPLACE); plugin("update", PLUGIN_ID, "--scope", "user"); - steps.push(`updated ${state.user.version} to ${read(home.dir).user?.version}`); + steps.push(updated(state.user.version, read(home.dir).user?.version, version)); + } else if (!intact(state, skills)) { + plugin("install", PLUGIN_ID, "--scope", "user"); + steps.push(`reinstalled ${read(home.dir).user?.version}, whose files were missing`); } if (!read(home.dir).enabled) { plugin("enable", PLUGIN_ID, "--scope", "user"); @@ -75,10 +98,9 @@ export function syncScopedRecords(dir) { const path = join(dir, RECORDS); const { user, stale } = read(dir); if (!stale.length) return []; - const text = readFileSync(path, "utf8"); const backup = `${path}.${new Date().toISOString().replace(/[:.]/g, "-")}.bak`; - writeFileSync(backup, text); - const data = JSON.parse(text); + copyFileSync(path, backup, constants.COPYFILE_EXCL); + const data = readJson(path); const steps = []; for (const record of data.plugins[PLUGIN_ID].filter((r) => isStale(r, user))) { steps.push(`moved the ${record.scope}-scope record for ${record.projectPath} from ${record.version} to ${user.version}`); @@ -93,16 +115,26 @@ export function syncScopedRecords(dir) { return [...steps, `backed up ${RECORDS} to ${backup}`]; } -export function doctorClaude(home, { version, skills }) { +export function doctorClaude(home, { version, skills, trusted }) { const state = read(home.dir); const installed = state.user?.version; + const whole = intact(state, skills); const skillsDir = join(home.dir, "skills"); - const plain = existsSync(skillsDir) ? readdirSync(skillsDir).filter((name) => skills.includes(name)) : []; + const plain = existsSync(skillsDir) + ? readdirSync(skillsDir).filter((name) => isPlainCopy(name, join(skillsDir, name, "SKILL.md"), skills)) + : []; return [ + marketplaceCheck(state.sources, trusted, `remove it with \`CLAUDE_CONFIG_DIR=${home.dir} claude plugin marketplace remove ${MARKETPLACE}\``), check( "installed", - installed === version, - installed ? `${installed}${installed === version ? "" : `, expected ${version}`}` : "not installed", + installed === version && whole, + !installed + ? "not installed" + : installed !== version + ? `${installed}, expected ${version}` + : whole + ? installed + : `${installed}, but its files at ${state.user.installPath} are missing`, INSTALL_HINT, ), check("enabled", state.enabled, state.enabled ? "yes" : "no", INSTALL_HINT), diff --git a/tools/install/codex.mjs b/tools/install/codex.mjs index 614cc16e..462d2d66 100644 --- a/tools/install/codex.mjs +++ b/tools/install/codex.mjs @@ -3,12 +3,27 @@ // config.toml matches the hook's entry. Its TUI asks for that trust, but // `codex app-server`, which T3 runs, never does. So the installer trusts the // hook the way the TUI does, through the app-server, with the hash Codex -// reports, on every install and update. +// reports, on every install and update, once its hooks file is the one +// tools/frozen-hooks.json pins. import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { check, INSTALL_HINT, MARKETPLACE, neutralCwd, PLUGIN_ID, runCli } from "./common.mjs"; +import { hookDigest } from "../plugin.mjs"; +import { + check, + codexSource, + INSTALL_HINT, + isPlainCopy, + launch, + MARKETPLACE, + marketplaceCheck, + neutralCwd, + PLUGIN_ID, + pluginIntact, + runCli, + updated, +} from "./common.mjs"; const REQUEST_TIMEOUT = 60_000; @@ -22,7 +37,9 @@ export class AppServer { } constructor(home) { - this.proc = spawn(home.bin, ["app-server"], { + const run = launch(home.bin, ["app-server"]); + this.proc = spawn(run.file, run.args, { + ...run.options, cwd: neutralCwd(), env: { ...process.env, CODEX_HOME: home.dir }, stdio: ["pipe", "pipe", "ignore"], @@ -47,7 +64,15 @@ export class AppServer { while ((newline = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); - const message = line ? JSON.parse(line) : {}; + let message; + try { + message = line ? JSON.parse(line) : {}; + } catch { + // Not quoted: the line could hold anything the server printed. + failAll(new Error("codex app-server printed a line that is not JSON-RPC")); + this.close(); + return; + } if (message.method) this.notifications.push(message); const waiter = this.pending.get(message.id); if (!waiter || message.method) continue; @@ -71,7 +96,10 @@ export class AppServer { }); } + // On Windows the process may be cmd.exe running npm's shim, and killing it + // leaves the server it started; closing stdin ends that server too. close() { + this.proc.stdin.end(); this.proc.kill(); } } @@ -87,15 +115,25 @@ async function withServer(home, use) { // Codex keeps one cache directory per installed version and removes the old // one when it installs another. +const cacheDir = (dir) => join(dir, "plugins/cache", MARKETPLACE, PLUGIN_ID.split("@")[0]); function installedVersions(dir) { - const cache = join(dir, "plugins/cache", MARKETPLACE, PLUGIN_ID.split("@")[0]); + const cache = cacheDir(dir); return existsSync(cache) ? readdirSync(cache).filter((name) => !name.startsWith(".")) : []; } -async function pluginState(server, dir) { +async function pluginState(server, dir, skills) { + const { config } = await server.request("config/read", {}); + const marketplace = config.marketplaces?.[MARKETPLACE]; const { marketplaces = [] } = await server.request("plugin/list", {}); const plugin = marketplaces.flatMap((m) => m.plugins ?? []).find((p) => p.id === PLUGIN_ID); - return { installed: plugin?.installed === true, enabled: plugin?.enabled === true, versions: installedVersions(dir) }; + const versions = installedVersions(dir); + return { + sources: marketplace ? [codexSource(marketplace)] : [], + installed: plugin?.installed === true, + enabled: plugin?.enabled === true, + versions, + intact: versions.length === 1 && pluginIntact(join(cacheDir(dir), versions[0]), ".codex-plugin/plugin.json", skills), + }; } async function pluginHooks(server) { @@ -103,31 +141,62 @@ async function pluginHooks(server) { return data.flatMap((entry) => entry.hooks).filter((hook) => hook.pluginId === PLUGIN_ID); } -const current = (state, version) => state.installed && state.enabled && state.versions.join() === version; +// The hook's entry comes from the hooks file the Codex manifest names, with +// the digest that file is pinned at. +const isPinned = (hook, pin) => + hook.sourcePath.replaceAll("\\", "/").endsWith(`/${pin.path}`) && + existsSync(hook.sourcePath) && + hookDigest(readFileSync(hook.sourcePath, "utf8")) === pin.sha256; -export async function installCodex(home, { source, version, localSource }) { +// Why `codex plugin add` runs: a first install, an update, a cache with +// missing files, or a disabled plugin, since Codex has no enable command. +// Null when the home is already at `version`. +function codexStep(state, version) { + if (!state.installed) return "install"; + if (state.versions.length && state.versions.join() !== version) return "update"; + if (!state.intact) return "repair"; + if (!state.enabled) return "enable"; + return null; +} + +export async function installCodex(home, { source, trusted, version, skills, codexHook }) { const steps = []; const plugin = (...args) => runCli(home.bin, ["plugin", ...args], { CODEX_HOME: home.dir }); + const state = () => withServer(home, (server) => pluginState(server, home.dir, skills)); mkdirSync(home.dir, { recursive: true }); - if (!/^t3-pstack\s/m.test(plugin("marketplace", "list"))) { - plugin("marketplace", "add", source); - steps.push(`added the ${MARKETPLACE} marketplace from ${source}`); + let before = await state(); + if (!before.sources.length) { + plugin("marketplace", "add", source.arg); + steps.push(`added the ${MARKETPLACE} marketplace from ${source.arg}`); + before = await state(); } - const before = await withServer(home, (server) => pluginState(server, home.dir)); - if (!current(before, version)) { + // Nothing installs from a marketplace of another source; doctor names it. + if (!marketplaceCheck(before.sources, trusted).ok) return steps; + const step = codexStep(before, version); + if (step) { // A local marketplace is read in place; a Git one is a snapshot to refresh. - if (!localSource) plugin("marketplace", "upgrade", MARKETPLACE); + if (step !== "enable" && !source.local) plugin("marketplace", "upgrade", MARKETPLACE); plugin("add", PLUGIN_ID); const after = installedVersions(home.dir).join(", "); - steps.push(before.versions.length ? `updated ${before.versions.join(", ")} to ${after}` : `installed ${after}`); + steps.push( + { + install: `installed ${after}`, + update: updated(before.versions.join(", "), after, version), + repair: `reinstalled ${after}, whose files were missing`, + enable: "enabled it", + }[step], + ); } - steps.push(...(await withServer(home, trustHooks))); + steps.push(...(await withServer(home, (server) => trustHooks(server, codexHook)))); return steps; } -// `upsert`: `replace` would drop the user's other trusted hooks. -async function trustHooks(server) { - const pending = (await pluginHooks(server)).filter((h) => h.trustStatus === "untrusted" || h.trustStatus === "modified"); +// `upsert`: `replace` would drop the user's other trusted hooks. An entry +// from any other hooks file stays untrusted, and doctor names it. +async function trustHooks(server, pin) { + const pending = (await pluginHooks(server)).filter( + (h) => (h.trustStatus === "untrusted" || h.trustStatus === "modified") && isPinned(h, pin), + ); if (!pending.length) return []; await server.request("config/value/write", { keyPath: "hooks.state", @@ -140,27 +209,35 @@ async function trustHooks(server) { return pending.map((h) => `trusted hook ${h.key} (was ${h.trustStatus})`); } -export async function doctorCodex(home, { version, skills }) { +export async function doctorCodex(home, { version, skills, trusted, codexHook }) { return withServer(home, async (server) => { - const state = await pluginState(server, home.dir); + const state = await pluginState(server, home.dir, skills); const hooks = await pluginHooks(server); const listed = (await server.request("skills/list", { cwds: [neutralCwd()] })).data.flatMap((entry) => entry.skills); - const ours = new Set(skills); - const plain = listed.filter((skill) => ours.has(skill.name)).map((skill) => skill.path); + const plain = listed.filter((skill) => isPlainCopy(skill.name, skill.path, skills)).map((skill) => skill.path); const installed = state.versions.join(", "); - const untrusted = hooks.filter((h) => h.trustStatus !== "trusted"); + const good = (h) => h.trustStatus === "trusted" && isPinned(h, codexHook); return [ + marketplaceCheck(state.sources, trusted, `remove it with \`CODEX_HOME=${home.dir} codex plugin marketplace remove ${MARKETPLACE}\``), check( "installed", - state.installed && installed === version, - state.installed ? `${installed}${installed === version ? "" : `, expected ${version}`}` : "not installed", + state.installed && installed === version && state.intact, + !state.installed + ? "not installed" + : state.versions.length && installed !== version + ? `${installed}, expected ${version}` + : state.intact + ? installed + : `its files in ${join(cacheDir(home.dir), installed)} are missing`, INSTALL_HINT, ), check("enabled", state.enabled, state.enabled ? "yes" : "no", INSTALL_HINT), check( "session-start hook trusted", - hooks.length > 0 && untrusted.length === 0, - hooks.length ? hooks.map((h) => `${h.key} ${h.trustStatus}`).join("; ") : "no t3-pstack hook listed", + hooks.length > 0 && hooks.every(good), + hooks.length + ? hooks.map((h) => `${h.key} ${h.trustStatus}${isPinned(h, codexHook) ? "" : `, not from the ${codexHook.path} this release pins`}`).join("; ") + : "no t3-pstack hook listed", INSTALL_HINT, ), check( diff --git a/tools/install/common.mjs b/tools/install/common.mjs index 3edf21e3..f78de9f9 100644 --- a/tools/install/common.mjs +++ b/tools/install/common.mjs @@ -1,14 +1,87 @@ // What the Claude Code and Codex halves of the installer share: the plugin's -// ids, the CLI runner, and the shape of a doctor check. +// ids, the marketplace sources it trusts, the CLI runner, file writes, and the +// shape of a doctor check. import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { chmodSync, chownSync, existsSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, dirname, join } from "node:path"; +import { basename, delimiter, dirname, extname, join, resolve } from "node:path"; export const PLUGIN_ID = "t3-pstack@t3-pstack"; export const MARKETPLACE = "t3-pstack"; +export const HOME_REPO = "chhoumann/t3-pstack"; export const INSTALL_HINT = "rerun `npx -y github:chhoumann/t3-pstack install`"; +// `~`, `~/` and `~\` expand as T3 expands them (provider-core +// pathExpansion.ts). `~user` does not. +export const expandHome = (path, home) => (path === "~" ? home : /^~[/\\]/.test(path) ? join(home, path.slice(2)) : path); + +// JSON.parse's message quotes the text around the fault, which can hold a token. +export function readJson(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (err) { + throw err instanceof SyntaxError ? new Error(`${path}: invalid JSON`) : err; + } +} + +// A marketplace source as `{ key, label }`. The key is the same however the +// source is written: a GitHub repository by its lowercased `owner/repo`, a +// directory by its real path. +const GITHUB_URL = /^(?:https?:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?\/?$/i; +const github = (repo) => ({ key: `github:${repo.toLowerCase()}`, label: repo }); +const gitUrl = (url) => (GITHUB_URL.test(url) ? { ...github(url.match(GITHUB_URL)[1]), label: url } : { key: `git:${url}`, label: url }); +const directory = (path) => ({ key: `local:${existsSync(path) ? realpathSync(path) : resolve(path)}`, label: path }); + +// `--source`, read as both CLIs read it: `owner/repo` is GitHub, a URL or +// `user@host:path` is Git, and anything else is a directory, which must exist. +export function parseSource(text, home) { + if (/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/.test(text)) return { ...github(text), arg: text }; + if (/^[a-z][a-z0-9+.-]*:\/\/|^[^/\\\s]+@[^/\\\s]+:/i.test(text)) return { ...gitUrl(text), arg: text }; + const path = resolve(expandHome(text, home)); + if (!existsSync(path)) throw new Error(`--source ${text}: not owner/repo, a Git URL, or an existing directory`); + return { ...directory(path), arg: path, local: path }; +} + +// The sources a Claude Code home or a Codex home records for a marketplace. +// A kind the installer never adds keys to itself, so it never matches. +export function claudeSource(source) { + if (source.source === "github") return github(source.repo); + if (source.source === "git") return gitUrl(source.url); + if (source.source === "directory") return directory(source.path); + return { key: JSON.stringify(source), label: JSON.stringify(source) }; +} + +export function codexSource(entry) { + if (entry.source_type === "git") return gitUrl(entry.source); + if (entry.source_type === "local") return directory(entry.source); + return { key: JSON.stringify(entry), label: JSON.stringify(entry) }; +} + +// The marketplace check both doctors run. A marketplace named t3-pstack from +// any source but the trusted ones is not installed from, nor its hook trusted. +export function marketplaceCheck(sources, trusted, remove) { + if (!sources.length) return check("marketplace", false, "none", INSTALL_HINT); + const labels = [...new Set(sources.map((s) => s.label))].join(", "); + const foreign = sources.filter((s) => !trusted.has(s.key)); + return check( + "marketplace", + foreign.length === 0, + foreign.length ? `${labels}, which is neither ${HOME_REPO} nor --source` : labels, + `if you trust it, rerun with --source ${foreign[0]?.label}; otherwise ${remove}, then ${INSTALL_HINT}`, + ); +} + +// A plain copy of one of the plugin's skills: its name and its SKILL.md +// match. A user's own skill that only shares the name is theirs. +export const isPlainCopy = (name, skillFile, skills) => + skills.has(name) && existsSync(skillFile) && readFileSync(skillFile, "utf8") === skills.get(name); + +// An install whose recorded version still has its files: the manifest and +// each skill's SKILL.md. +export const pluginIntact = (root, manifest, skills) => + existsSync(join(root, manifest)) && [...skills.keys()].every((name) => existsSync(join(root, "skills", name, "SKILL.md"))); + // Every CLI runs from an empty directory. Claude Code reads `<cwd>/.claude` // as project settings, and from the home folder that is the main home's // settings.json, so the user's own working directory could leak into the @@ -22,9 +95,52 @@ export function neutralCwd() { return neutral; } +// The file `bin` runs: `bin` itself when it is a path, else its first match +// on PATH. Windows, like cmd.exe, tries PATHEXT's extensions on a name that +// has none, and so skips the extensionless sh script npm installs beside a +// .cmd shim. +export function resolveCli(bin) { + if (basename(bin) !== bin) return existsSync(bin) ? bin : undefined; + const extensions = process.platform === "win32" && !extname(bin) ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + for (const ext of extensions) { + if (dir && existsSync(join(dir, bin + ext))) return join(dir, bin + ext); + } + } +} + +// Looked up rather than run: `codex --version` already writes into the +// default CODEX_HOME. +export const hasCli = (bin) => resolveCli(bin) !== undefined; + +// cmd.exe's line for a .cmd or .bat file, as cross-spawn builds it. Each +// argument is quoted for the program's own parser, then every cmd.exe +// metacharacter is escaped twice: once for `cmd /c`, and once for the line in +// the script, such as npm's shim, that passes `%*` on. +const CMD_META = /([()\][%!^"`<>&|;, *?])/g; +function cmdLine(file, args) { + const quote = (arg) => + `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`.replace(CMD_META, "^$1").replace(CMD_META, "^$1"); + return [file.replace(CMD_META, "^$1"), ...args.map(quote)].join(" "); +} + +// How to start `bin`. Node starts a .cmd or .bat file only through cmd.exe, +// and an npm-installed `claude` or `codex` on Windows is one. +export function launch(bin, args) { + const file = resolveCli(bin) ?? bin; + if (process.platform !== "win32" || !/\.(cmd|bat)$/i.test(file)) return { file, args, options: {} }; + return { + file: process.env.comspec || "cmd.exe", + args: ["/d", "/s", "/c", `"${cmdLine(file, args)}"`], + options: { windowsVerbatimArguments: true }, + }; +} + export function runCli(bin, args, env) { + const run = launch(bin, args); try { - return execFileSync(bin, args, { + return execFileSync(run.file, run.args, { + ...run.options, cwd: neutralCwd(), env: { ...process.env, ...env }, encoding: "utf8", @@ -36,25 +152,33 @@ export function runCli(bin, args, env) { } } -// Looked up on PATH rather than run: `codex --version` already writes into -// the default CODEX_HOME. -export function hasCli(bin) { - if (bin.includes("/")) return existsSync(bin); - const extensions = process.platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE").split(";")] : [""]; - return (process.env.PATH ?? "") - .split(delimiter) - .some((dir) => dir && extensions.some((ext) => existsSync(join(dir, bin + ext)))); -} +// A path beside `path` that nothing holds. Creating it with the `wx` flag or +// symlinkSync fails rather than replace a file someone else put there. +export const tempBeside = (path) => join(dirname(path), `.${basename(path)}.${randomUUID()}.t3-pstack.tmp`); // A crash between the write and the rename leaves the old file whole. The // rename lands on the symlink's target, so a settings file one home links -// from another stays shared. -export function writeAtomic(path, text) { +// from another stays shared. The new file takes the mode and, where the +// system allows, the owner of the file it replaces, or of `like`. +export function writeAtomic(path, text, like = path) { const target = existsSync(path) ? realpathSync(path) : path; - const tmp = join(dirname(target), `.${Date.now()}-${process.pid}.t3-pstack.tmp`); - writeFileSync(tmp, text); + const tmp = tempBeside(target); + writeFileSync(tmp, text, { flag: "wx" }); + const old = statSync(like, { throwIfNoEntry: false }); + if (old) { + chmodSync(tmp, old.mode & 0o7777); + try { + chownSync(tmp, old.uid, old.gid); + } catch (err) { + if (err.code !== "EPERM") throw err; + } + } renameSync(tmp, target); } +// An update step. A marketplace behind the installer, such as main before a +// release merges, leaves the version where it was. +export const updated = (from, to, version) => (from === to ? `the marketplace has no ${version}, so it stays at ${to}` : `updated ${from} to ${to}`); + // A doctor check. `warn` marks one that never fails the run. export const check = (name, ok, detail, fix, warn = false) => ({ name, ok, detail, ...(ok ? {} : { fix }), ...(warn && { warn }) }); diff --git a/tools/install/homes.mjs b/tools/install/homes.mjs index e8b53f48..e9d8545f 100644 --- a/tools/install/homes.mjs +++ b/tools/install/homes.mjs @@ -3,16 +3,18 @@ // instance in T3's settings, enabled or not, and the homes named by flag. // An instance's home resolves as T3 resolves it (apps/server // Drivers/ClaudeHome.ts and Drivers/CodexHomeLayout.ts): `config.homePath`, -// else the instance's CLAUDE_CONFIG_DIR or CODEX_HOME, else the default. +// else the instance's CLAUDE_CONFIG_DIR or CODEX_HOME, else the default. T3 +// expands a leading `~` in the first two and in `config.binaryPath` +// (provider-core pathExpansion.ts). import { existsSync, readFileSync, realpathSync } from "node:fs"; import { join, resolve } from "node:path"; +import { expandHome as expand } from "./common.mjs"; + const DRIVERS = { claudeAgent: "claude", codex: "codex" }; const ENV_VAR = { claude: "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME" }; const DEFAULT_HOME = { claude: ".claude", codex: ".codex" }; -const expand = (path, home) => (path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path); - // T3 keeps its state in `${T3CODE_HOME:-~/.t3}/userdata` (apps/server os-jank.ts and config.ts). export function t3SettingsPath(env, home) { const base = env.T3CODE_HOME?.trim() ? resolve(expand(env.T3CODE_HOME.trim(), home)) : join(home, ".t3"); @@ -23,7 +25,8 @@ function readT3Settings(path) { try { return { instances: JSON.parse(readFileSync(path, "utf8")).providerInstances ?? {} }; } catch (err) { - return { instances: {}, error: err.code === "ENOENT" ? "not found" : err.message }; + // JSON.parse's message quotes the text around the fault, which can hold a token. + return { instances: {}, error: err.code === "ENOENT" ? "not found" : err instanceof SyntaxError ? "invalid JSON" : err.message }; } } @@ -52,14 +55,15 @@ export function discoverHomes({ env, home, claudeHomes = [], codexHomes = [] }) const config = instance.config ?? {}; const instanceEnv = Object.fromEntries((instance.environment ?? []).map((v) => [v.name, v.value ?? ""])); if (config.shadowHomePath?.trim()) shadows.add(resolve(expand(config.shadowHomePath.trim(), home))); - // T3 does not expand `~` in an inherited variable. + const fromEnv = instanceEnv[ENV_VAR[harness]]?.trim(); const dir = config.homePath?.trim() ? resolve(expand(config.homePath.trim(), home)) - : instanceEnv[ENV_VAR[harness]]?.trim() - ? resolve(instanceEnv[ENV_VAR[harness]].trim()) + : fromEnv + ? resolve(expand(fromEnv, home)) : join(home, DEFAULT_HOME[harness]); const disabled = instance.enabled === false || config.enabled === false; - add(harness, dir, `T3 instance ${id}${disabled ? " (disabled)" : ""}`, config.binaryPath?.trim()); + const bin = config.binaryPath?.trim(); + add(harness, dir, `T3 instance ${id}${disabled ? " (disabled)" : ""}`, bin && expand(bin, home)); } for (const harness of ["claude", "codex"]) { diff --git a/tools/install/sheet.mjs b/tools/install/sheet.mjs index 332ccc53..94990f9f 100644 --- a/tools/install/sheet.mjs +++ b/tools/install/sheet.mjs @@ -3,11 +3,11 @@ // `${XDG_CONFIG_HOME:-~/.config}/t3-pstack/models.md`. Released versions read // `t3-pstack-models.md` in each config home, so the installer moves that sheet // to the machine path once and leaves a symlink at each home's old path. -import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, symlinkSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { checkSheet, parseSheet } from "../../plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs"; -import { check, INSTALL_HINT, writeAtomic } from "./common.mjs"; +import { check, INSTALL_HINT, tempBeside, writeAtomic } from "./common.mjs"; export const HOME_SHEET = "t3-pstack-models.md"; const SETUP_HINT = "run /t3-pstack:setup-pstack in a T3 thread"; @@ -21,12 +21,20 @@ const resolvesTo = (path, target) => isFile(path) && isFile(target) && realpathS const homeSheets = (homes) => homes.map((home) => join(home.dir, HOME_SHEET)); function link(target, path) { - const tmp = `${path}.t3-pstack-link`; - rmSync(tmp, { force: true }); + const tmp = tempBeside(path); symlinkSync(target, tmp); renameSync(tmp, path); } +// A sheet a home links to from outside every home, such as one kept in a +// dotfiles repository, is the user's file to keep. +function outsideHomes(path, homes) { + return homes.every(({ dir }) => { + const rel = relative(existsSync(dir) ? realpathSync(dir) : resolve(dir), path); + return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); + }); +} + export function moveSheet(sheet, homes) { const steps = []; if (!isFile(sheet)) { @@ -34,14 +42,21 @@ export function moveSheet(sheet, homes) { if (!found.length) return steps; if (new Set(found.map((path) => readFileSync(path, "utf8"))).size > 1) return steps; mkdirSync(dirname(sheet), { recursive: true }); - writeAtomic(sheet, readFileSync(found[0])); - steps.push(`moved the sheet from ${found[0]}`); + const kept = found.find((path) => outsideHomes(path, homes)); + if (kept) { + link(kept, sheet); + steps.push(`linked the sheet to ${kept}`); + } else { + writeAtomic(sheet, readFileSync(found[0]), found[0]); + steps.push(`moved the sheet from ${found[0]}`); + } } - // A home sheet that differs from the machine sheet stays, and doctor names it. + // A home sheet that differs from the machine sheet stays, and doctor names + // it. A link to another file stays too, so that file is never orphaned. for (const path of homeSheets(homes)) { if (!existsSync(dirname(path)) || resolvesTo(path, sheet)) continue; const present = lstatSync(path, { throwIfNoEntry: false }); - if (present && isFile(path) && !sameBytes(path, sheet)) continue; + if (present && isFile(path) && (present.isSymbolicLink() || !sameBytes(path, sheet))) continue; link(sheet, path); steps.push(`linked ${path} to it`); } From 6f098fe9fa50486936da0987e719f411355bdfed Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann <christian@bagerbach.com> Date: Wed, 14 Oct 2026 15:20:40 +0200 Subject: [PATCH 08/10] install-cli test: put the fake CLI on PATH under Windows' spelling of it Windows names the variable Path, and the test added a second PATH key beside it, so the Node child searched the runner's own path and never found the fake CLI. The test now sets whichever key the environment already has. --- tests/install-cli.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/install-cli.test.mjs b/tests/install-cli.test.mjs index 119def5f..f6e1bb7c 100644 --- a/tests/install-cli.test.mjs +++ b/tests/install-cli.test.mjs @@ -43,13 +43,17 @@ if (windows) { chmodSync(join(dir, "fake-cli"), 0o755); } +// Windows spells it Path, and a second key in the child's environment would +// not replace it. +const PATH = Object.keys(process.env).find((key) => key.toUpperCase() === "PATH") ?? "PATH"; + function installer(bin, args) { const script = `import { hasCli, runCli } from ${JSON.stringify(common)};\n` + "const [bin, args] = [process.argv[1], JSON.parse(process.argv[2])];\n" + "console.log(JSON.stringify({ found: hasCli(bin), argv: JSON.parse(runCli(bin, args)) }));\n"; const r = spawnSync("node", ["--input-type=module", "-e", script, bin, JSON.stringify(args)], { - env: { ...process.env, PATH: `${dir}${delimiter}${process.env.PATH}` }, + env: { ...process.env, [PATH]: `${dir}${delimiter}${process.env[PATH]}` }, encoding: "utf8", }); expect(r.status, r.stderr).toBe(0); From f71eb746a62f86e0f023be9e5eea4825463fa8dd Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann <christian@bagerbach.com> Date: Wed, 14 Oct 2026 15:53:20 +0200 Subject: [PATCH 09/10] Freeze check: fetch origin/main and compare with it, not the merge-base A branch cut from 2.0.0-rc.2 could ship 2.0.0-rc.3 with its own hook entry after main had already shipped 2.0.0-rc.3 with another, and the check passed, because it read main at the merge-base. It now fetches main and reads VERSION and the hooks files there. A hooks file that differs from main's needs a changedIn equal to this VERSION, a VERSION newer than main's by semver order, and the CHANGES announcement. A failed fetch fails the check, as a missing origin/main did. The test repositories get a bare origin to fetch from. CI's generated-files job no longer needs full history. --- .github/workflows/ci.yml | 2 -- CHANGES.md | 4 +-- CONTRIBUTING.md | 2 +- tests/generate.test.mjs | 54 +++++++++++++++++++++++++++++++++++++--- tools/generate.mjs | 22 ++++++++-------- 5 files changed, 65 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f7cea3b..25821ab3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,9 @@ jobs: name: Generated files current runs-on: ubuntu-latest steps: - # Full history: the hooks check compares each hooks file with origin/main. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - fetch-depth: 0 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: "1.3.14" diff --git a/CHANGES.md b/CHANGES.md index 7d643751..a79a8e96 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,7 +8,7 @@ This file is the release changelog, with one `## <version> - <title>` entry per Until now the Codex session-start hook never ran under T3. Codex 0.162.1 runs a plugin hook only once `hooks.state."<key>".trusted_hash` in `config.toml` matches the hook's entry, and `codex app-server`, which T3 runs, never asks for that trust. The installer trusts it on every run the way Codex's own TUI does: `hooks/list`, then `config/value/write` with the hash Codex reports and `mergeStrategy: "upsert"`, so the user's other trusted hooks stay. It trusts an entry only when it comes from the hooks file the Codex manifest names, at the digest `tools/frozen-hooks.json` pins. In a throwaway home with the plugin installed by hand, a T3-shaped app-server session fired no hook event and its rollout held no routing text. After the installer, the same session fired `hook/started` and `hook/completed`, and its rollout held the routing text as one developer message. `tests/install-e2e.test.mjs` runs that check with the real CLIs. -Codex trusts a hook by a hash of its entry, and a Git marketplace upgrades the plugin at every app-server start, so a changed entry would stop routing in every home with no announcement. `tools/frozen-hooks.json` now pins both hooks files, and `bun tools/generate.mjs --check` compares each with `origin/main`, which the marketplaces deliver. A file that differs fails the check unless its pin has the new digest, its `changedIn` names a new `VERSION`, and that release's entry here tells users to rerun the installer. Neither file changes in this release. +Codex trusts a hook by a hash of its entry, and a Git marketplace upgrades the plugin at every app-server start, so a changed entry would stop routing in every home with no announcement. `tools/frozen-hooks.json` now pins both hooks files, and `bun tools/generate.mjs --check` fetches `origin/main`, which the marketplaces deliver, and compares each with it. A file that differs fails the check unless its pin has the new digest, its `changedIn` names this `VERSION`, that `VERSION` is newer than main's, and this release's entry here tells users to rerun the installer. Neither file changes in this release. The model sheet moves to `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, because provider instance IDs differ per machine, not per harness. The installer moves an existing `t3-pstack-models.md` there once and leaves a symlink at each home's old path, so a release before this one still reads the same sheet. Until the installer runs, the hook, the routing text, the skills' Models sections, and setup-pstack fall back to the old per-home sheet. @@ -22,7 +22,7 @@ The model sheet moves to `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-p | A stale project-scope record | A second Claude Code config home could keep a project-scope `t3-pstack@t3-pstack` record for the home folder at an old version, because Claude Code reads `~/.claude/settings.json` as that folder's project settings. | The installer backs up that home's `plugins/installed_plugins.json` and brings each stale record at another scope to the user-scope install. It never runs `claude plugin uninstall --scope project`, which would edit the main home's settings. | T3 installer | | Model sheet location | `t3-pstack-models.md` in each host's config directory, linked between hosts by hand. | `~/.config/t3-pstack/models.md`, one per machine. The installer moves the old sheet there once and links each home's old path to it. A home sheet that links to a file outside every home, such as one in a dotfiles repository, becomes the machine sheet's link target instead of a copy. A home sheet that differs is left, and `doctor` names it. A home sheet that links to another file is left too. | T3 adaptation | | setup-pstack | Wrote the host's sheet and linked the other host's path to it. | Writes the machine sheet, reads the old per-home sheet when the machine sheet is missing, and links this host's old path to the machine sheet. | Declared fork | -| A changed hooks file | `session-hook.test.mjs` pinned the 1.0.14 entries literally. | `tools/frozen-hooks.json` pins each hooks file's digest and the release that last changed it. The generator check compares each hooks file with `origin/main`, and fails on an unpinned change, on a repinned one whose `changedIn` or `VERSION` is not new, or on one whose release entry does not tell users to rerun the installer. CI's generated-files job checks out full history for it. | T3 adaptation | +| A changed hooks file | `session-hook.test.mjs` pinned the 1.0.14 entries literally. | `tools/frozen-hooks.json` pins each hooks file's digest and the release that last changed it. The generator check fetches `origin/main` and compares each hooks file with it. It fails on an unpinned change, on a repinned one whose `changedIn` is not this `VERSION` or whose `VERSION` is not newer than main's, and on one whose release entry does not tell users to rerun the installer. | T3 adaptation | | Windows | Not tested. | The installer starts the `.cmd` shims npm installs for `claude` and `codex` through `cmd.exe`, with each argument quoted and escaped as cross-spawn does, and reads a full Windows path in `binaryPath` as a path. CI's Windows job runs the launch tests. | T3 installer | | `t3-sheet.mjs` without a catalog | A catalog was required. | `checkSheet` with no catalog checks the grammar only. `doctor` uses it. | T3 adaptation | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 309cb648..9e07dc68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,7 +81,7 @@ uvx zizmor@1.29.0 --persona pedantic --min-severity low --collect all -- . - **A substitution that no longer matches upstream.** Update it to upstream's current sentence or delete it. - **`disable-model-invocation` on `poteto-mode`.** The session-start hook invokes it by name, and the flag makes the Skill tool refuse that. - **A root `plugins/pstack/plugin.json`.** Codex reads a plugin with upstream's Agent Plugins manifest as an Agent Plugin and truncates each injected SKILL.md to 8,000 bytes, which cuts `poteto-mode` off mid-file. `tools/upstream.json` excludes the file. -- **A changed hooks file.** Codex trusts a plugin hook by a hash of its entry, and a Git marketplace delivers a new entry at the next app-server start, so any change to `hooks/hooks.json` or `hooks/codex-hooks.json` stops routing in every Codex home until the installer reruns. `tools/frozen-hooks.json` pins both. Change `session-start.sh` or the routing text instead. When an entry must change, pin its new digest with `changedIn` set to the release, and tell users to rerun the installer in that release's `CHANGES.md` entry. The check compares each hooks file with `origin/main`, which the marketplaces deliver, so a file that differs from main's needs a new `VERSION` and a `changedIn` that names it, and the installer trusts only the entry its own release pins. Fetch `origin/main` before `bun tools/generate.mjs --check`. +- **A changed hooks file.** Codex trusts a plugin hook by a hash of its entry, and a Git marketplace delivers a new entry at the next app-server start, so any change to `hooks/hooks.json` or `hooks/codex-hooks.json` stops routing in every Codex home until the installer reruns. `tools/frozen-hooks.json` pins both. Change `session-start.sh` or the routing text instead. When an entry must change, pin its new digest with `changedIn` set to the release, and tell users to rerun the installer in that release's `CHANGES.md` entry. The check fetches `origin/main`, which the marketplaces deliver, and compares each hooks file with it, so a file that differs from main's needs a `VERSION` newer than main's and a `changedIn` that names it, and the installer trusts only the entry its own release pins. The fetch means the check needs the network. - **Stale generated output, or a missing `CHANGES.md` heading for `VERSION`.** - **An unresolved sync conflict marker** anywhere under `plugins/pstack`. - **A missing or escaping local Markdown link.** diff --git a/tests/generate.test.mjs b/tests/generate.test.mjs index 83806151..b92ee514 100644 --- a/tests/generate.test.mjs +++ b/tests/generate.test.mjs @@ -327,7 +327,10 @@ describe("plan, changes, apply", () => { git(dir, "init", "-q"); git(dir, "add", "-A"); git(dir, "commit", "-qm", "main"); - git(dir, "update-ref", "refs/remotes/origin/main", "HEAD"); + const origin = scratch("pstack-origin-"); + git(origin, "init", "-q", "--bare"); + git(dir, "remote", "add", "origin", origin); + git(dir, "push", "-q", "origin", "HEAD:refs/heads/main"); return dir; }; const snapshot = (dir) => Object.fromEntries(walk(dir).map((path) => [path, readFileSync(path, "utf8")])); @@ -572,10 +575,50 @@ describe("plan, changes, apply", () => { writeFileSync(join(root, "tools/frozen-hooks.json"), JSON.stringify(pins, null, 2) + "\n"); const version = readFileSync(join(root, "VERSION"), "utf8").trim(); expect(problems(root)).toEqual([ - `${hooks} differs from origin/main, which already ships ${version}, so this change needs a new VERSION`, + `${hooks} differs from origin/main, which ships ${version}, so this change needs a VERSION newer than that, not ${version}`, ]); rmSync(join(root, ".git"), { recursive: true }); - expect(problems(root)).toEqual([expect.stringMatching(/^tools\/frozen-hooks.json: cannot find origin\/main to compare the hooks files with: fatal: not a git repository/)]); + expect(problems(root)).toEqual([expect.stringMatching(/^tools\/frozen-hooks.json: cannot fetch origin\/main to compare the hooks files with: fatal: not a git repository/)]); + }); + + test("problems fetches origin/main and compares with it, not with the merge-base, so a release main already shipped cannot be reused", () => { + const root = repoCopy(); + const hooks = "plugins/pstack/hooks/codex-hooks.json"; + const original = readFileSync(join(root, hooks), "utf8"); + // A release with `matcher` for the hooks file: VERSION, the hooks file, its pin, and a CHANGES entry. + const release = (version, matcher) => { + const text = original.replace("startup|resume|clear|compact", matcher); + writeFileSync(join(root, hooks), text); + const pins = JSON.parse(readFileSync(join(root, "tools/frozen-hooks.json"), "utf8")); + pins[hooks] = { sha256: hookDigest(text), changedIn: version }; + writeFileSync(join(root, "tools/frozen-hooks.json"), JSON.stringify(pins, null, 2) + "\n"); + writeFileSync(join(root, "VERSION"), `${version}\n`); + writeFileSync(join(root, "CHANGES.md"), `# CHANGES\n\n## ${version} - Next\n\nRerun the installer.\n\n` + readFileSync(join(root, "CHANGES.md"), "utf8").replace(/^# CHANGES\n\n/, "")); + }; + writeFileSync(join(root, "VERSION"), "9.0.0-rc.2\n"); + git(root, "commit", "-qam", "9.0.0-rc.2"); + git(root, "push", "-q", "origin", "HEAD:refs/heads/main"); + git(root, "checkout", "-qb", "branch"); + // Main ships 9.0.0-rc.3 with hook A after the branch is cut, and the + // branch's own origin/main still points at 9.0.0-rc.2. + release("9.0.0-rc.3", "startup|resume"); + git(root, "commit", "-qam", "main ships 9.0.0-rc.3 with hook A"); + git(root, "push", "-q", "origin", "HEAD:refs/heads/main"); + git(root, "reset", "-q", "--hard", "HEAD~1"); + git(root, "update-ref", "refs/remotes/origin/main", "HEAD"); + + release("9.0.0-rc.3", "startup|clear"); + expect(problems(root)).toEqual([ + `${hooks} differs from origin/main, which ships 9.0.0-rc.3, so this change needs a VERSION newer than that, not 9.0.0-rc.3`, + ]); + git(root, "checkout", "-q", "."); + release("9.0.0-rc.1", "startup|clear"); + expect(problems(root)).toEqual([ + `${hooks} differs from origin/main, which ships 9.0.0-rc.3, so this change needs a VERSION newer than that, not 9.0.0-rc.1`, + ]); + git(root, "checkout", "-q", "."); + release("9.0.0-rc.4", "startup|clear"); + expect(problems(root)).toEqual([]); }); test("problems reports a root with no plugin directory, down to the last check, and does not throw", () => { @@ -617,7 +660,10 @@ describe("frozen hook entries", () => { expect(faults({ pins: stale })).toEqual([stalePin]); expect(faults({ pins: stale, shipped: { version: "2.0.0", files: {} } })).toEqual([stalePin]); expect(faults({ pins: stale, version: "2.0.0" })).toEqual([ - `${path} differs from origin/main, which already ships 2.0.0, so this change needs a new VERSION`, + `${path} differs from origin/main, which ships 2.0.0, so this change needs a VERSION newer than that, not 2.0.0`, + ]); + expect(faults({ pins: { [path]: { sha256: hookDigest(changed), changedIn: "2.0.0-rc.9" } }, version: "2.0.0-rc.9" })).toEqual([ + `${path} differs from origin/main, which ships 2.0.0, so this change needs a VERSION newer than that, not 2.0.0-rc.9`, ]); }); diff --git a/tools/generate.mjs b/tools/generate.mjs index 649c830f..92667033 100644 --- a/tools/generate.mjs +++ b/tools/generate.mjs @@ -386,7 +386,8 @@ export function validateHooks(hooksJson, { statOf, file = "hooks/hooks.json", ro // release's CHANGES entry must tell users to rerun the installer. `shipped` // holds VERSION and the hooks files as origin/main has them, since main is // what the marketplaces deliver: a file that differs from main's changes in -// this release, so its pin must name this release, and VERSION must be new. +// this release, so its pin must name this release, and VERSION must be newer +// than main's. export function frozenHookFaults({ pins, files, version, changelog, shipped }) { const faults = Object.keys(files) .filter((path) => !Object.hasOwn(pins, path)) @@ -405,8 +406,8 @@ export function frozenHookFaults({ pins, files, version, changelog, shipped }) { `Pin "sha256": "${digest}" and "changedIn": "${version}" in ${FROZEN_HOOKS}, ` + `and tell users to rerun the installer in CHANGES.md's ${version} entry.`, ); - } else if (changed && version === shipped.version) { - faults.push(`${path} differs from origin/main, which already ships ${version}, so this change needs a new VERSION`); + } else if (changed && Bun.semver.order(version, shipped.version) <= 0) { + faults.push(`${path} differs from origin/main, which ships ${shipped.version}, so this change needs a VERSION newer than that, not ${version}`); } else if (changed && pin.changedIn !== version) { faults.push(`${path} differs from origin/main, so its pin's "changedIn" must be ${version}, not ${pin.changedIn}`); } else if (pin.changedIn === version && !/rerun the installer/i.test(entry)) { @@ -416,18 +417,19 @@ export function frozenHookFaults({ pins, files, version, changelog, shipped }) { return faults; } -// VERSION and `paths` at the merge-base of HEAD and origin/main, with -// undefined for a path main lacks. CI checks out full history for this. +// VERSION and `paths` as origin/main has them now, with undefined for a path +// main lacks. It fetches main first, so a branch cut before main's latest +// release is compared with that release and not with the merge-base. function shippedOnMain(root, paths) { const git = (...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); - let base; + const main = "refs/remotes/origin/main"; try { - base = git("merge-base", "HEAD", "origin/main").trim(); + git("fetch", "--quiet", "--no-tags", "origin", `+refs/heads/main:${main}`); } catch (err) { - throw new Error(`${FROZEN_HOOKS}: cannot find origin/main to compare the hooks files with: ${(err.stderr || err.message).trim()}`); + throw new Error(`${FROZEN_HOOKS}: cannot fetch origin/main to compare the hooks files with: ${(err.stderr || err.message).trim()}`); } - const listed = new Set(git("ls-tree", "-r", "--name-only", base, "--", "VERSION", ...paths).split("\n")); - const show = (path) => (listed.has(path) ? git("show", `${base}:${path}`) : undefined); + const listed = new Set(git("ls-tree", "-r", "--name-only", main, "--", "VERSION", ...paths).split("\n")); + const show = (path) => (listed.has(path) ? git("show", `${main}:${path}`) : undefined); return { version: show("VERSION")?.trim(), files: Object.fromEntries(paths.map((path) => [path, show(path)])) }; } From 83a3c1247990b6cc872836d5b293acd1b95a6d5e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann <christian@bagerbach.com> Date: Wed, 14 Oct 2026 15:53:50 +0200 Subject: [PATCH 10/10] Codex trust: one session, the listed entry bound to the pinned bytes Hook trust needed three app-server views to agree, and nothing tied them together. The source check and the trust write ran in separate sessions, so a config.toml edit between them got trust for a source the check never saw. The hash Codex listed and the bytes the pin check read came from two reads, so a hooks file swapped between them, or a symlink, got the listed entry trusted. In a throwaway home with 6f098fe's installer, an entry that runs `touch` was listed, swapped for the pinned file before the installer read it, and swapped back. The installer trusted its hash, and the next session ran it. Each of these needs write access to the Codex home, the boundary Codex's own hook trust has, but the windows are cheap to close: - One session checks the marketplace source, lists the hooks, and writes the trust. The write passes the config version config/read returned as expectedVersion, so Codex refuses it with configVersionConflict when config.toml changed in between. - The hooks file is read once, through a descriptor opened without following a symlink, and must be a regular file at the pinned digest. The event, matcher, and command Codex lists must be one of that text's entries. - A config write makes Codex reread the hooks files, so the listing after the trust write shows a file that changed since the check. If the trusted entry's status, command, or hash differs, hooks.state goes back to its earlier value and the home fails. The script the hook runs is not pinned, as Codex does not pin it either and it changes with each release. SECURITY.md gains a threat model that says so. tests/codex-attacker.mjs is a codex that proxies the real app-server and writes files at one protocol point. The e2e uses it for a symlinked hooks file, the swap before the read, a swap before the trust write, and a config.toml edit before it. --- CHANGES.md | 4 +- SECURITY.md | 8 +++ tests/codex-attacker.mjs | 42 ++++++++++++++ tests/install-e2e.test.mjs | 111 ++++++++++++++++++++++++++++++++++++- tools/install/codex.mjs | 80 ++++++++++++++++++++------ 5 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 tests/codex-attacker.mjs diff --git a/CHANGES.md b/CHANGES.md index a79a8e96..60af7219 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,7 +6,7 @@ This file is the release changelog, with one `## <version> - <title>` entry per `npx -y github:chhoumann/t3-pstack install` installs or updates t3-pstack in every Claude Code and Codex home T3 can run, and `doctor` checks them. The homes are `~/.claude` and `~/.codex`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, each `claudeAgent` and `codex` provider instance in T3's `${T3CODE_HOME:-~/.t3}/userdata/settings.json`, disabled ones included, and any home named with `--claude-home` or `--codex-home`. Each step converges, so a second run changes nothing. Each home's `t3-pstack` marketplace must come from `chhoumann/t3-pstack` or from the run's `--source`. A home whose marketplace has another source, a counterfeit or a local `--source` from an earlier run, gets nothing installed and no hook trusted, and `doctor` fails it with that source. -Until now the Codex session-start hook never ran under T3. Codex 0.162.1 runs a plugin hook only once `hooks.state."<key>".trusted_hash` in `config.toml` matches the hook's entry, and `codex app-server`, which T3 runs, never asks for that trust. The installer trusts it on every run the way Codex's own TUI does: `hooks/list`, then `config/value/write` with the hash Codex reports and `mergeStrategy: "upsert"`, so the user's other trusted hooks stay. It trusts an entry only when it comes from the hooks file the Codex manifest names, at the digest `tools/frozen-hooks.json` pins. In a throwaway home with the plugin installed by hand, a T3-shaped app-server session fired no hook event and its rollout held no routing text. After the installer, the same session fired `hook/started` and `hook/completed`, and its rollout held the routing text as one developer message. `tests/install-e2e.test.mjs` runs that check with the real CLIs. +Until now the Codex session-start hook never ran under T3. Codex 0.162.1 runs a plugin hook only once `hooks.state."<key>".trusted_hash` in `config.toml` matches the hook's entry, and `codex app-server`, which T3 runs, never asks for that trust. The installer trusts it on every run the way Codex's own TUI does: `hooks/list`, then `config/value/write` with the hash Codex reports and `mergeStrategy: "upsert"`, so the user's other trusted hooks stay. It trusts an entry only when it comes from the hooks file the Codex manifest names, at the digest `tools/frozen-hooks.json` pins, and when the event, matcher, and command Codex lists are an entry of that file's text, read once and never through a symlink. It checks the marketplace source, lists the hooks, and writes the trust in one app-server session. The write names the `config.toml` version the source check read, so Codex refuses it when the file changed in between. When Codex then lists a different entry, the installer takes the trust back and fails the home. In a throwaway home with the plugin installed by hand, a T3-shaped app-server session fired no hook event and its rollout held no routing text. After the installer, the same session fired `hook/started` and `hook/completed`, and its rollout held the routing text as one developer message. `tests/install-e2e.test.mjs` runs that check with the real CLIs. Codex trusts a hook by a hash of its entry, and a Git marketplace upgrades the plugin at every app-server start, so a changed entry would stop routing in every home with no announcement. `tools/frozen-hooks.json` now pins both hooks files, and `bun tools/generate.mjs --check` fetches `origin/main`, which the marketplaces deliver, and compares each with it. A file that differs fails the check unless its pin has the new digest, its `changedIn` names this `VERSION`, that `VERSION` is newer than main's, and this release's entry here tells users to rerun the installer. Neither file changes in this release. @@ -16,7 +16,7 @@ The model sheet moves to `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-p | --- | --- | --- | --- | | Install and update | `claude plugin` and `codex plugin` commands by hand, in each home. | `npx -y github:chhoumann/t3-pstack install`, or `update`, the same command, for every home T3 can run. It runs `main`'s latest commit, the channel the marketplaces auto-update from, and `#v<version>` pins one release. A listed home that does not exist yet is created, and a home whose CLI is not on `PATH` is skipped with a warning. A home already at the installer's version gets only the steps it still lacks, such as the Codex trust, and one whose plugin files are missing is reinstalled. A file it replaces keeps its mode and, where the system allows, its owner. | T3 installer | | Marketplace source | Whatever each home was given. | `chhoumann/t3-pstack`, or the run's `--source`, which reads `owner/repo` as GitHub as both CLIs do, so a local directory needs a path such as `./t3-pstack`. Nothing installs from a `t3-pstack` marketplace with any other source, and none of its hooks is trusted. | T3 installer | -| Codex session-start hook | Never ran under T3, because the app-server never asks for hook trust. The README said to trust it with `/hooks` in the Codex CLI. | The installer trusts it in every Codex home on every run, with the hash Codex reports, once its hooks file is the one this release pins, and fails the home unless Codex then reports it `trusted`. A rerun writes nothing. | T3 installer | +| Codex session-start hook | Never ran under T3, because the app-server never asks for hook trust. The README said to trust it with `/hooks` in the Codex CLI. | The installer trusts it in every Codex home on every run, with the hash Codex reports, once its hooks file is the one this release pins and the listed entry is one of that file's. Source check, listing, and trust write share one app-server session, and a write after `config.toml` changed is refused. The home fails unless Codex then reports the same entry `trusted`, and otherwise the trust is taken back. A rerun writes nothing. | T3 installer | | `doctor` | None. | Reports, per home, the marketplace's source, the installed version against the installer's and whether its files are all there, whether the plugin is enabled, the Codex hook's trust and pin, Claude Code records at another scope that pin an old version, copies of the plugin's skills whose `SKILL.md` matches the plugin's, and the model sheet: present, parsed, and with `--catalog <file>` checked against an `orchestrator_capabilities` dump. It exits 1 on any problem and prints a fix for each. A missing sheet is a warning. | T3 installer | | Claude Code marketplace auto-update | Off, Claude Code's default for a third-party marketplace. | The installer sets `extraKnownMarketplaces.t3-pstack.autoUpdate` in each home's `settings.json`. A home behind the installer's version gets `claude plugin marketplace update` and `claude plugin update`. | T3 installer | | A stale project-scope record | A second Claude Code config home could keep a project-scope `t3-pstack@t3-pstack` record for the home folder at an old version, because Claude Code reads `~/.claude/settings.json` as that folder's project settings. | The installer backs up that home's `plugins/installed_plugins.json` and brings each stale record at another scope to the user-scope install. It never runs `claude plugin uninstall --scope project`, which would edit the main home's settings. | T3 installer | diff --git a/SECURITY.md b/SECURITY.md index 6e6566b6..1c86a91e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,3 +5,11 @@ Report a vulnerability privately, through [GitHub's private vulnerability report The skill tree, the hooks, the vendored bun scripts under `plugins/pstack/skills/poteto-mode/scripts`, and the CI workflows are all in scope. Anything that makes an agent run a command the user did not intend, exfiltrate a secret, or reach outside the repository it was pointed at counts, as does a supply-chain weakness in how this repository pins and installs its own dependencies. Findings in T3 Code, Claude Code, or Codex belong to their projects, and findings in the upstream project this repository syncs from belong upstream. Only the latest release on `main` receives fixes. Plugin auto-update installs by version number, so a fix ships as a new release rather than as a patch to an older one, and there are no maintained release branches to back-port to. + +## Threat model + +The installer installs nothing and trusts no hook in a home whose `t3-pstack` marketplace comes from anywhere but `chhoumann/t3-pstack` or the run's explicit `--source`. In Codex it trusts only t3-pstack's own session-start hook, from the hooks file the Codex manifest names, at the digest `tools/frozen-hooks.json` pins. Like Codex's own hook trust, it checks the hook's entry and not the script the entry runs, which changes with each release. The marketplace source is what vouches for the script. + +An attacker who can already write to the user's Claude Code or Codex home, or to the plugin cache inside it, is out of scope. That is the boundary Codex's hook trust has, since such an attacker can write `hooks.state` in `config.toml` themselves. The installer still closes the simple windows. It checks the source, lists the hooks, and writes the trust in one app-server session, reads the hooks file once and never through a symlink, and takes the trust back when the entry changes before Codex confirms it. + +`npx -y github:chhoumann/t3-pstack` runs the latest commit on `main`, the rolling channel the marketplaces auto-update from. `npx -y github:chhoumann/t3-pstack#v<version>` runs one release's installer from its tag. diff --git a/tests/codex-attacker.mjs b/tests/codex-attacker.mjs new file mode 100644 index 00000000..33c7981e --- /dev/null +++ b/tests/codex-attacker.mjs @@ -0,0 +1,42 @@ +// `codex`, with an attacker who writes files once, at one point of an +// app-server session, to reproduce a Codex home that changes while the +// installer trusts the hook. CODEX_ATTACK names a JSON file +// `{ "at": "<point>", "files": { "<path>": "<text>" } }`. A point is +// `before <method>`, when the installer's request reaches this proxy, or +// `after <method>`, before Codex's reply reaches the installer. The file is +// deleted when the attack fires. REAL_CODEX is the codex this runs. +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createInterface } from "node:readline"; + +const args = process.argv.slice(2); +const real = process.env.REAL_CODEX; +if (args[0] !== "app-server") process.exit(spawnSync(real, args, { stdio: "inherit" }).status ?? 1); + +function attack(point) { + const plan = process.env.CODEX_ATTACK; + if (!plan || !existsSync(plan)) return; + const { at, files } = JSON.parse(readFileSync(plan, "utf8")); + if (at !== point) return; + rmSync(plan); + for (const [path, text] of Object.entries(files)) writeFileSync(path, text); +} + +const server = spawn(real, args, { stdio: ["pipe", "pipe", "inherit"] }); +const methods = new Map(); +createInterface({ input: process.stdin }) + .on("line", (line) => { + const message = line.trim() ? JSON.parse(line) : {}; + if (message.method && message.id !== undefined) { + methods.set(message.id, message.method); + attack(`before ${message.method}`); + } + server.stdin.write(`${line}\n`); + }) + .on("close", () => server.stdin.end()); +createInterface({ input: server.stdout }).on("line", (line) => { + const message = line.trim() ? JSON.parse(line) : {}; + if (!message.method && methods.has(message.id)) attack(`after ${methods.get(message.id)}`); + process.stdout.write(`${line}\n`); +}); +server.on("exit", (code) => process.exit(code ?? 1)); diff --git a/tests/install-e2e.test.mjs b/tests/install-e2e.test.mjs index 36be3a16..dc1a5d00 100644 --- a/tests/install-e2e.test.mjs +++ b/tests/install-e2e.test.mjs @@ -3,7 +3,7 @@ // T3_PSTACK_E2E=1; it needs both CLIs and takes about a minute. This checkout // is the marketplace source. The Codex probe turn has no credentials, so it // fails at the model call, after the session-start hook has run. -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { @@ -20,10 +20,11 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; +import { delimiter, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { AppServer } from "../tools/install/codex.mjs"; +import { resolveCli } from "../tools/install/common.mjs"; import { hookDigest } from "../tools/plugin.mjs"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); @@ -377,3 +378,109 @@ describe.skipIf(!enabled)("the installer, on a release that changes the hook ent expect(after.routedMessages).toBe(1); }, 300_000); }); + +describe.skipIf(!enabled)("the installer, on a Codex home that changes while it trusts the hook", () => { + isolate({}); + const path = process.env.PATH; + const hooksFile = () => join(home, ".codex/plugins/cache/t3-pstack/t3-pstack", version, "hooks/codex-hooks.json"); + const configFile = () => join(home, ".codex/config.toml"); + const attack = (at, files) => writeFileSync(process.env.CODEX_ATTACK, JSON.stringify({ at, files })); + let genuine; + let config; + let malicious; + let marked; + + async function withCodex(use) { + const server = await AppServer.start({ dir: join(home, ".codex"), bin: "codex" }); + try { + return await use(server); + } finally { + server.close(); + } + } + const trustState = () => withCodex(async (server) => (await server.request("config/read", {})).config.hooks?.state ?? null); + + beforeAll(() => { + const bin = join(root, "bin"); + mkdirSync(bin); + writeFileSync(join(bin, "codex"), `#!/bin/sh\nexec node "${join(repoRoot, "tests/codex-attacker.mjs")}" "$@"\n`, { mode: 0o755 }); + process.env.REAL_CODEX = resolveCli("codex"); + process.env.CODEX_ATTACK = join(root, "attack.json"); + process.env.PATH = `${bin}${delimiter}${path}`; + expect(installer("install").status).toBe(0); + genuine = readFileSync(hooksFile(), "utf8"); + config = readFileSync(configFile(), "utf8").replace(/\n\[hooks\.state[^]*$/, "\n"); + marked = join(root, "hook-ran"); + malicious = genuine.replace(/"command": "[^\n]*",\n/, `"command": "touch ${marked}",\n`); + }); + + // Each test starts from the installed plugin with its hook untrusted. + beforeEach(() => { + rmSync(process.env.CODEX_ATTACK, { force: true }); + rmSync(hooksFile()); + writeFileSync(hooksFile(), genuine); + writeFileSync(configFile(), config); + }); + + afterAll(() => { + process.env.PATH = path; + delete process.env.REAL_CODEX; + delete process.env.CODEX_ATTACK; + }); + + test("trusts nothing from a hooks file that is a symlink, even to the pinned text", async () => { + const copy = join(root, "codex-hooks.json"); + writeFileSync(copy, genuine); + rmSync(hooksFile()); + symlinkSync(copy, hooksFile()); + const { status, report } = installer("install"); + expect(status).toBe(1); + expect(row(report, ".codex").steps).toEqual([]); + expect(failing(row(report, ".codex").checks)).toEqual([ + `session-start hook trusted: ${HOOK} untrusted, not from the hooks/codex-hooks.json this release pins`, + ]); + expect(await trustState()).toBe(null); + }, 300_000); + + test("trusts nothing when the hooks file Codex listed is swapped for the pinned one before the installer reads it", async () => { + writeFileSync(hooksFile(), malicious); + attack("after hooks/list", { [hooksFile()]: genuine }); + const { status, report } = installer("install"); + expect(existsSync(process.env.CODEX_ATTACK)).toBe(false); + expect(status).toBe(1); + expect(row(report, ".codex").steps).toEqual([]); + expect(failing(row(report, ".codex").checks)).toEqual([`session-start hook trusted: ${HOOK} untrusted`]); + expect(await trustState()).toBe(null); + + // The attacker's entry, back for the next session, does not run. + writeFileSync(hooksFile(), malicious); + const after = await probe(join(home, ".codex")); + expect(after.hookEvents).toEqual([]); + expect(existsSync(marked)).toBe(false); + }, 300_000); + + test("takes the trust back when the hooks file changes between the check and the trust write", async () => { + attack("before config/value/write", { [hooksFile()]: malicious }); + const { status, report } = installer("install"); + expect(existsSync(process.env.CODEX_ATTACK)).toBe(false); + expect(status).toBe(1); + expect(failing(row(report, ".codex").checks)).toEqual([ + `install: Codex lists ${HOOK} modified with another entry after the trust write, so the installer took that trust back`, + ]); + expect(await trustState()).toBe(null); + }, 300_000); + + test("trusts nothing when config.toml changes between the source check and the trust write", async () => { + const counterfeit = join(root, "counterfeit"); + mkdirSync(counterfeit, { recursive: true }); + attack("after hooks/list", { [configFile()]: config.replace(/^source = .*$/m, `source = ${JSON.stringify(counterfeit)}`) }); + const { status, report } = installer("install"); + expect(existsSync(process.env.CODEX_ATTACK)).toBe(false); + expect(status).toBe(1); + expect(failing(row(report, ".codex").checks)).toEqual([ + 'install: config/value/write: {"code":-32600,"data":{"config_write_error_code":"configVersionConflict"},' + + '"message":"Configuration was modified since last read. Fetch latest version and retry."}', + ]); + expect(await trustState()).toBe(null); + }, 300_000); +}); diff --git a/tools/install/codex.mjs b/tools/install/codex.mjs index 462d2d66..1ebccab5 100644 --- a/tools/install/codex.mjs +++ b/tools/install/codex.mjs @@ -4,9 +4,12 @@ // `codex app-server`, which T3 runs, never does. So the installer trusts the // hook the way the TUI does, through the app-server, with the hash Codex // reports, on every install and update, once its hooks file is the one -// tools/frozen-hooks.json pins. +// tools/frozen-hooks.json pins. Only the hooks file is pinned, not the script +// its command runs. Codex's own trust covers the entry alone, and the script +// changes with each release, so the marketplace source check is what vouches +// for the script. import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { hookDigest } from "../plugin.mjs"; @@ -121,14 +124,15 @@ function installedVersions(dir) { return existsSync(cache) ? readdirSync(cache).filter((name) => !name.startsWith(".")) : []; } +const marketplaceSources = (config) => (config.marketplaces?.[MARKETPLACE] ? [codexSource(config.marketplaces[MARKETPLACE])] : []); + async function pluginState(server, dir, skills) { const { config } = await server.request("config/read", {}); - const marketplace = config.marketplaces?.[MARKETPLACE]; const { marketplaces = [] } = await server.request("plugin/list", {}); const plugin = marketplaces.flatMap((m) => m.plugins ?? []).find((p) => p.id === PLUGIN_ID); const versions = installedVersions(dir); return { - sources: marketplace ? [codexSource(marketplace)] : [], + sources: marketplaceSources(config), installed: plugin?.installed === true, enabled: plugin?.enabled === true, versions, @@ -141,12 +145,39 @@ async function pluginHooks(server) { return data.flatMap((entry) => entry.hooks).filter((hook) => hook.pluginId === PLUGIN_ID); } -// The hook's entry comes from the hooks file the Codex manifest names, with -// the digest that file is pinned at. -const isPinned = (hook, pin) => - hook.sourcePath.replaceAll("\\", "/").endsWith(`/${pin.path}`) && - existsSync(hook.sourcePath) && - hookDigest(readFileSync(hook.sourcePath, "utf8")) === pin.sha256; +// A file's text, read once without following a symlink, or null when it is a +// symlink or not a regular file. +function readRegularFile(path) { + if (!lstatSync(path, { throwIfNoEntry: false })?.isFile()) return null; + const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + return fstatSync(fd).isFile() ? readFileSync(fd, "utf8") : null; + } finally { + closeSync(fd); + } +} + +// The listed hook is an entry of the hooks file the Codex manifest names, read +// at the pinned digest. Codex hashes the file when it lists it, so the listed +// event, matcher, and command must be an entry of that text, with the plugin +// root for ${CLAUDE_PLUGIN_ROOT}, or the hash Codex reports is of other text. +function isPinned(hook, pin) { + const unix = (path) => path.replaceAll("\\", "/"); + if (!unix(hook.sourcePath).endsWith(`/${pin.path}`)) return false; + const text = readRegularFile(hook.sourcePath); + if (text === null || hookDigest(text) !== pin.sha256) return false; + const root = hook.sourcePath.slice(0, -pin.path.length - 1); + const entries = Object.entries(JSON.parse(text).hooks).flatMap(([event, groups]) => + groups.flatMap((group) => group.hooks.map((entry) => ({ event, matcher: group.matcher ?? null, entry }))), + ); + const expand = (line) => unix(line.replaceAll("${CLAUDE_PLUGIN_ROOT}", root)); + return entries.some( + ({ event, matcher, entry }) => + event[0].toLowerCase() + event.slice(1) === hook.eventName && + matcher === hook.matcher && + [entry.command, entry.commandWindows].some((line) => line !== undefined && expand(line) === unix(hook.command)), + ); +} // Why `codex plugin add` runs: a first install, an update, a cache with // missing files, or a disabled plugin, since Codex has no enable command. @@ -187,13 +218,21 @@ export async function installCodex(home, { source, trusted, version, skills, cod }[step], ); } - steps.push(...(await withServer(home, (server) => trustHooks(server, codexHook)))); + steps.push(...(await withServer(home, (server) => trustHooks(server, trusted, codexHook)))); return steps; } -// `upsert`: `replace` would drop the user's other trusted hooks. An entry -// from any other hooks file stays untrusted, and doctor names it. -async function trustHooks(server, pin) { +// One app-server session checks the marketplace source, lists the hooks, and +// writes the trust. The write names the config version the source check read, +// so Codex refuses it when config.toml changed in between. A config write +// also makes Codex read the hooks files again, so the listing after it shows +// a hooks file that changed since the check, and then the trust is taken +// back. `upsert`: `replace` would drop the user's other trusted hooks. An +// entry from any other hooks file stays untrusted, and doctor names it. +async function trustHooks(server, trusted, pin) { + const { config, layers } = await server.request("config/read", { includeLayers: true }); + if (!marketplaceCheck(marketplaceSources(config), trusted).ok) return []; + const user = layers.find((layer) => layer.name.type === "user"); const pending = (await pluginHooks(server)).filter( (h) => (h.trustStatus === "untrusted" || h.trustStatus === "modified") && isPinned(h, pin), ); @@ -202,10 +241,17 @@ async function trustHooks(server, pin) { keyPath: "hooks.state", mergeStrategy: "upsert", value: Object.fromEntries(pending.map((h) => [h.key, { trusted_hash: h.currentHash }])), + expectedVersion: user.version, }); - const after = new Map((await pluginHooks(server)).map((h) => [h.key, h.trustStatus])); - const failed = pending.filter((h) => after.get(h.key) !== "trusted"); - if (failed.length) throw new Error(`Codex still reports ${failed.map((h) => `${h.key} ${after.get(h.key)}`).join(", ")} after the trust write`); + const listed = new Map((await pluginHooks(server)).map((h) => [h.key, h])); + const now = (h) => listed.get(h.key) ?? {}; + const same = (h) => now(h).command === h.command && now(h).currentHash === h.currentHash; + const failed = pending.filter((h) => now(h).trustStatus !== "trusted" || !same(h)); + if (failed.length) { + await server.request("config/value/write", { keyPath: "hooks.state", mergeStrategy: "replace", value: user.config.hooks?.state ?? null }); + const status = (h) => `${h.key} ${now(h).trustStatus ?? "unlisted"}${same(h) ? "" : " with another entry"}`; + throw new Error(`Codex lists ${failed.map(status).join(", ")} after the trust write, so the installer took that trust back`); + } return pending.map((h) => `trusted hook ${h.key} (was ${h.trustStatus})`); }