Skip to content

2.0.0-rc.3: installer and doctor for Claude Code and Codex, a trusted Codex hook, one sheet per machine - #18

Merged
chhoumann merged 10 commits into
mainfrom
v2/rc3
Oct 14, 2026
Merged

chhoumann merged 10 commits into
mainfrom
v2/rc3

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

t3-pstack 2.0.0-rc.3 adds a one-command installer and a doctor for Claude Code and Codex.

npx -y github:chhoumann/t3-pstack install   # or update, the same command
npx -y github:chhoumann/t3-pstack doctor

npx -y github:chhoumann/t3-pstack runs main's latest commit, the rolling channel the marketplaces auto-update from. npx -y github:chhoumann/t3-pstack#v2.0.0-rc.3 pins this release once its tag exists. No v2.* tag exists on the remote yet, so that tag has to be pushed at merge.

What changes for users

  • Codex routing works under T3 for the first time. Codex 0.162.1 runs a plugin's SessionStart hook only once hooks.state."<key>".trusted_hash in config.toml matches the hook's entry. codex app-server, which T3 runs, never asks for that trust, so t3-pstack's routing hook has never run in a T3 Codex session. The installer trusts it on every install and update the way Codex's TUI does: hooks/list, then config/value/write of hooks.state with the hash Codex reports and mergeStrategy: "upsert", so other trusted hooks stay. A second hooks/list must then report trusted, or the home fails.
  • Only t3-pstack's own marketplace is trusted. Each home's t3-pstack marketplace must come from chhoumann/t3-pstack or from this run's --source. The installer reads the recorded source (Claude Code's settings.json and known_marketplaces.json, Codex's config/read) and compares it by repository or real path. 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 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.
  • The trust Codex records is for the entry the installer checked. One app-server session checks the marketplace source, lists the hooks, and writes the trust. The write passes the config.toml version that config/read returned as expectedVersion, so Codex refuses it with configVersionConflict when the file changed after the source check. The hooks file is read once, through a descriptor that never follows 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 entry's status, command, or hash differs there, 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, and that puts an attacker who can already write to a Claude Code or Codex home out of scope.
  • One command covers every home T3 can run. The installer installs or updates the plugin, enables it, and turns on Claude Code's marketplace auto-update. A rerun changes nothing: a home already at the installer's version gets only the steps it still lacks, and a home whose plugin files are missing gets them reinstalled. A file it replaces keeps its mode and, where the system allows, its owner.
  • Stale project-scope records are repaired. A second Claude Code config home such as ~/.claude_pool re-acquires 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 the record to the user-scope install. It never runs claude plugin uninstall --scope project, which would edit the main home's settings.
  • One model sheet per machine. The sheet moves to ~/.config/t3-pstack/models.md ($XDG_CONFIG_HOME/t3-pstack/models.md when that is set). The installer moves an existing t3-pstack-models.md there once and leaves a symlink at each home's old path, so older releases read the same sheet. 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, so that file stays the one the user edits. Until the installer runs, the hook, the routing text, the Models sections, and setup-pstack fall back to the old per-home sheet.
  • doctor reports per home: the marketplace's source, the installed version against the installer's and whether its files are all there, the enabled state, the Codex hook's trust and pin, other-scope records that pin an old version, copies of the plugin's skills whose SKILL.md matches the plugin's (a user's own skill that only shares a name is left alone), and the sheet (parsed, and checked against an orchestrator_capabilities dump with --catalog). It exits 1 on any problem and prints a fix for each. A missing sheet is a warning. A file that does not parse is reported by path and "invalid JSON", never quoted.
  • A changed hooks file can no longer ship silently. tools/frozen-hooks.json pins hooks/hooks.json and hooks/codex-hooks.json. bun tools/generate.mjs --check fetches origin/main 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 by semver order, and this release's CHANGES.md entry tells users to rerun the installer. Neither file changes here.
  • Windows. A full Windows path in binaryPath is a path, and the .cmd shims npm installs for claude and codex start through cmd.exe, with each argument escaped as cross-spawn does. CI's Windows job runs the launch tests.

How homes are discovered

  • Defaults: ~/.claude and ~/.codex.
  • Environment: the shell's CLAUDE_CONFIG_DIR and CODEX_HOME.
  • T3's settings: each claudeAgent and codex provider instance in ${T3CODE_HOME:-~/.t3}/userdata/settings.json, enabled or disabled. This is where T3's server keeps settings (apps/server/src/os-jank.ts, config.ts). Each instance resolves as T3 resolves it (Drivers/ClaudeHome.ts, Drivers/CodexHomeLayout.ts): config.homePath, else the instance's CLAUDE_CONFIG_DIR or CODEX_HOME, else the default. T3 expands a leading ~, ~/, or ~\ in those and in config.binaryPath (provider-core pathExpansion.ts), and so does the installer. The instance's config.binaryPath runs its CLI.
  • Shadow homes: a Codex shadowHomePath is skipped, because its config, plugins, and skills link into the shared home.
  • Flags: --claude-home <dir> and --codex-home <dir> add homes, for example when T3's settings are not readable.

Homes that resolve to the same directory merge. A listed home that does not exist yet is created, so a never-run disabled instance is ready when enabled. A home whose CLI is not on PATH is skipped with a warning.

--source reads as both CLIs read it: owner/repo is GitHub, a URL or user@host:path is Git, and anything else is a directory that must exist, so a local checkout needs a path such as ./t3-pstack.

Evidence

T3_PSTACK_E2E=1 GIT_CONFIG_GLOBAL=/dev/null bun test tests/install-e2e.test.mjs --timeout 300000 drives Codex CLI 0.162.1 and Claude Code 2.1.299 in a throwaway HOME described by a T3 settings file. 12 pass, 0 fail.

Step Observed
Before: plugin added to ~/.codex by hand, as the old README said A T3-shaped app-server session (thread/start, one turn/start) emitted no hook/* event. Its rollout held no developer message with the routing text. doctor exited 1 and named the untrusted hook, the stale project record, the missing disabled home, and the unmoved sheet.
install Every check passed. ~/.codex got trusted hook t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0 (was untrusted). The disabled home was created, installed, and trusted. The project record moved from 1.0.11 to 2.0.0-rc.3 after a backup. The sheet moved and three home paths were linked.
After The same session emitted hook/started and hook/completed. Its rollout held the routing text as one developer message. Claude Code's SessionStart:startup hook_response carried the same text.
Second update No steps. All 16 config files in the four homes and the sheet were byte-identical. doctor exited 0.
Files gone, plugin disabled With a skill deleted from the Codex cache, the Claude pool home's install folder deleted, and the plugin disabled in one Codex home, doctor named each. install reported reinstalled 2.0.0-rc.3, whose files were missing twice and enabled it, and exited 0.
A plain copy and a user's own skill With ~/.agents/skills/why copied from the plugin and a user's own ~/.agents/skills/how, doctor flagged only why.
A counterfeit t3-pstack marketplace A copy of the plugin whose Codex hook runs touch <marker>, added to ~/.codex and ~/.claude. install exited 1 with no steps in either home, named the counterfeit's path as the marketplace source, and left its hook untrusted. A T3-shaped session fired no hook and the marker was never written. A clean home in the same run was installed and trusted.
A stale --source Rerunning install with no --source over homes whose marketplace came from a local --source changed no file and failed each home, showing that source.
A release that changes the hook entry (version 9.9.9, reordered matcher) Unpinned: both homes updated to 9.9.9, and Codex left the modified hook untrusted, with doctor naming it. Pinned in that release's tools/frozen-hooks.json: the installer re-trusted it, and a new session ran it.
A hooks file that is a symlink to a copy of the pinned file install exited 1 with no steps. doctor named the hook untrusted, not from the hooks/codex-hooks.json this release pins, and hooks.state stayed empty.
The listed entry runs touch <marker>, and the file is swapped for the pinned one after hooks/list replies Nothing was trusted. After the attacker swapped their entry back, a T3-shaped session fired no hook and the marker was never written.
The hooks file is changed to that entry as the trust write is sent install failed the home with Codex lists ... modified with another entry after the trust write, so the installer took that trust back, and hooks.state was empty again.
config.toml's marketplace source is changed to another directory after hooks/list replies Codex refused the trust write with configVersionConflict, and hooks.state stayed empty.

The probe turn has no credentials, so it fails at the model call, after the hook has run. The last four rows use tests/codex-attacker.mjs, a codex that proxies the real app-server and writes files at one point of the session.

The same counterfeit scenario through 6cf9ea9's installer exited 0, trusted the counterfeit's hook, wrote the marker, and passed doctor. Re-enabling a disabled Codex plugin there reported updated 2.0.0-rc.3 to 2.0.0-rc.3.

Through 6f098fe's installer, the same four scenarios ended this way. The symlinked file was trusted. With the swap after hooks/list, the installer trusted the touch entry's hash and failed the home with Codex still reports ... modified after the trust write. Once the attacker swapped their entry back, the next session fired hook/started and hook/completed, and the marker was written. The change before the trust write left that trust in hooks.state. The config.toml edit got trusted hook ... (was untrusted) while the marketplace pointed at the other directory.

A manual GitHub-source run, the new installer with no --source in a throwaway HOME, added the GitHub marketplace in both homes and accepted both recorded forms as chhoumann/t3-pstack: Claude Code's chhoumann/t3-pstack and Codex's https://github.com/chhoumann/t3-pstack.git. It trusted main's hook, which matches the pin, and it still does with 83a3c12's installer. installed fails there with 2.0.0-rc.2, expected 2.0.0-rc.3 until this merges. The e2e has no GitHub-source test: it would need the network, and neither CLI accepts a file:// Git URL that could stand in for one offline.

Checks

  • GIT_CONFIG_GLOBAL=/dev/null bun test tests/ --timeout 30000: 537 pass, 0 fail. The 46 skips are 23 Windows-only hook tests, which CI's Windows job runs, the 12 opt-in e2e tests and their 10 beforeAll and afterAll hooks, and one sync test that needs a case-sensitive filesystem.
  • Scripts: bun run typecheck passes, and bun test orch watch-pr check-plan.test.ts gives 119 pass.
  • bun tools/generate.mjs --check: ok.
  • bun tools/sync.mjs <component> 1e5c90a --dry-run, all three components: exit 0, with no undeclared fork, denylist hit, or dead substitution.
  • t3-sheet.mjs on a real sheet against a live catalog: ok.
  • Every new test was run against 6cf9ea9's code and fails there: 20 unit tests in install.test.mjs, generate.test.mjs, and readme-facts.test.mjs, and 5 of the 8 e2e tests. install-cli.test.mjs passes on macOS either way. On CI's Windows job it passes: a .cmd shim built like npm's, found on Path and by full path, receives every argument intact, including C:\Program Files (x86)\t3 pstack\, say "hi", a&b|c, 100%, %PATH%, ^caret, !bang!, <in>out, and an empty one. It was not run against 6cf9ea9 on Windows, where hasCli read C:\... as a command name and Node refuses to start a .cmd file without a shell.
  • Review round 2's five new tests fail against 6f098fe's code. The four e2e tests are above. The new generate.test.mjs test has main ship 9.0.0-rc.3 with one hook entry after a branch is cut at 9.0.0-rc.2. The branch then ships 9.0.0-rc.3 with another entry, and its local origin/main is left at 9.0.0-rc.2. 6f098fe's check passes that, and 83a3c12's fails it. It also fails the branch at 9.0.0-rc.1 and passes it at 9.0.0-rc.4.
  • Commits 6 through 10 each pass generate --check and bun test tests/ on their own, as the first five did in the first round.

Deviations from the design

  • --claude-home and --codex-home replace --home <dir> --only <harness>. With two harnesses, the flag names the harness.
  • The installer sets only CLAUDE_CONFIG_DIR or CODEX_HOME for each CLI call. It does not pass the T3 instance's other environment entries, which hold API tokens and which Claude Code and Codex do not need to find their home.
  • The Claude doctor reads settings.json and plugins/installed_plugins.json instead of claude plugin list --json, so it sees records at every scope and needs no CLI. The Codex doctor uses the app-server, as designed.
  • A home already at the installer's version skips marketplace update/upgrade and plugin update/add. The design reran every native updater, but Claude's marketplace update rewrites known_marketplaces.json on every run, and the skip keeps a rerun byte-identical.
  • The freeze check lives in generate.mjs --check, since there is no check.mjs. The announcement it requires is the phrase "rerun the installer" in the release's CHANGES.md entry. It fetches origin/main and compares with it rather than with a release tag, because t3-pstack releases are not tagged. So --check, and the pre-commit hook that runs it, need the network.
  • A user's skill that shares a plugin skill's name but not its SKILL.md gets no note, so a stale copy of an older release's skill goes unflagged too.
  • The hook, the routing text, and the skills read the machine sheet with a fallback to the old per-home path, not the new path alone.
  • Not in this PR: /setup-pstack running the installer, a daily update job, the "exactly one t3-pstack@* plugin" check, and the other harnesses (later PRs). skill-sync calling the installer after its plugin step is proposed separately.

Review guide

The ten commits stand alone, in this order:

  1. t3-sheet.mjs: a grammar-only check when no catalog is given.
  2. The hooks-file pins and their generator check.
  3. The machine sheet and its fallback reads, with the eight regenerated Models sections.
  4. The installer (tools/install.mjs, tools/install/) and its tests.
  5. The release: README, CHANGES with its behavior table, and the stamped manifests.
  6. Review round 1: the freeze check compares with origin/main.
  7. Review round 1: marketplace sources and pinned hook trust, and the other installer fixes, with their tests.
  8. Review round 1: the Windows launch test sets Path under the spelling the environment already uses.
  9. Review round 2: the freeze check fetches origin/main and compares with it, not the merge-base.
  10. Review round 2: hook trust in one app-server session, bound to the pinned bytes, re-listed, and taken back on a change. The SECURITY.md threat model.

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.
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.
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.
npx -y github:chhoumann/t3-pstack <install|update|doctor> 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.
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.
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.
… 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.
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.
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.
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.
@chhoumann
chhoumann merged commit 5a8b33f into main Oct 14, 2026
10 checks passed
@chhoumann
chhoumann deleted the v2/rc3 branch October 14, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant