Repository navigation
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 doctornpx -y github:chhoumann/t3-pstackrunsmain's latest commit, the rolling channel the marketplaces auto-update from.npx -y github:chhoumann/t3-pstack#v2.0.0-rc.3pins this release once its tag exists. Nov2.*tag exists on the remote yet, so that tag has to be pushed at merge.What changes for users
hooks.state."<key>".trusted_hashinconfig.tomlmatches 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, thenconfig/value/writeofhooks.statewith the hash Codex reports andmergeStrategy: "upsert", so other trusted hooks stay. A secondhooks/listmust then reporttrusted, or the home fails.t3-pstackmarketplace must come fromchhoumann/t3-pstackor from this run's--source. The installer reads the recorded source (Claude Code'ssettings.jsonandknown_marketplaces.json, Codex'sconfig/read) and compares it by repository or real path. A home whose marketplace has another source, a counterfeit or a local--sourcefrom an earlier run, gets nothing installed and no hook trusted, anddoctorfails it showing the source. Codex trusts a hook only when its entry comes from the hooks file the Codex manifest names, at the digesttools/frozen-hooks.jsonpins.config.tomlversion thatconfig/readreturned asexpectedVersion, so Codex refuses it withconfigVersionConflictwhen 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.stategoes 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.mdgains 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.~/.claude_poolre-acquires a project-scopet3-pstack@t3-pstackrecord for the home folder at an old version, because Claude Code reads~/.claude/settings.jsonas that folder's project settings. The installer backs up that home'splugins/installed_plugins.jsonand brings the record to the user-scope install. It never runsclaude plugin uninstall --scope project, which would edit the main home's settings.~/.config/t3-pstack/models.md($XDG_CONFIG_HOME/t3-pstack/models.mdwhen that is set). The installer moves an existingt3-pstack-models.mdthere 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.doctorreports 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 whoseSKILL.mdmatches the plugin's (a user's own skill that only shares a name is left alone), and the sheet (parsed, and checked against anorchestrator_capabilitiesdump 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.tools/frozen-hooks.jsonpinshooks/hooks.jsonandhooks/codex-hooks.json.bun tools/generate.mjs --checkfetchesorigin/mainand compares each with it. A file that differs fails the check unless its pin has the new digest, itschangedInnames thisVERSION, thatVERSIONis newer than main's by semver order, and this release'sCHANGES.mdentry tells users to rerun the installer. Neither file changes here.binaryPathis a path, and the.cmdshims npm installs forclaudeandcodexstart throughcmd.exe, with each argument escaped as cross-spawn does. CI's Windows job runs the launch tests.How homes are discovered
~/.claudeand~/.codex.CLAUDE_CONFIG_DIRandCODEX_HOME.claudeAgentandcodexprovider 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'sCLAUDE_CONFIG_DIRorCODEX_HOME, else the default. T3 expands a leading~,~/, or~\in those and inconfig.binaryPath(provider-corepathExpansion.ts), and so does the installer. The instance'sconfig.binaryPathruns its CLI.shadowHomePathis skipped, because its config, plugins, and skills link into the shared home.--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
PATHis skipped with a warning.--sourcereads as both CLIs read it:owner/repois GitHub, a URL oruser@host:pathis 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 300000drives Codex CLI 0.162.1 and Claude Code 2.1.299 in a throwawayHOMEdescribed by a T3 settings file. 12 pass, 0 fail.~/.codexby hand, as the old README saidthread/start, oneturn/start) emitted nohook/*event. Its rollout held no developer message with the routing text.doctorexited 1 and named the untrusted hook, the stale project record, the missing disabled home, and the unmoved sheet.install~/.codexgottrusted 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.hook/startedandhook/completed. Its rollout held the routing text as one developer message. Claude Code'sSessionStart:startuphook_responsecarried the same text.updatedoctorexited 0.doctornamed each.installreportedreinstalled 2.0.0-rc.3, whose files were missingtwice andenabled it, and exited 0.~/.agents/skills/whycopied from the plugin and a user's own~/.agents/skills/how,doctorflagged onlywhy.t3-pstackmarketplacetouch <marker>, added to~/.codexand~/.claude.installexited 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.--sourceinstallwith no--sourceover homes whose marketplace came from a local--sourcechanged no file and failed each home, showing that source.modifiedhook untrusted, withdoctornaming it. Pinned in that release'stools/frozen-hooks.json: the installer re-trusted it, and a new session ran it.installexited 1 with no steps.doctornamed the hookuntrusted, not from the hooks/codex-hooks.json this release pins, andhooks.statestayed empty.touch <marker>, and the file is swapped for the pinned one afterhooks/listrepliesinstallfailed the home withCodex lists ... modified with another entry after the trust write, so the installer took that trust back, andhooks.statewas empty again.config.toml's marketplace source is changed to another directory afterhooks/listrepliesconfigVersionConflict, andhooks.statestayed 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, acodexthat 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 reportedupdated 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 thetouchentry's hash and failed the home withCodex still reports ... modified after the trust write. Once the attacker swapped their entry back, the next session firedhook/startedandhook/completed, and the marker was written. The change before the trust write left that trust inhooks.state. Theconfig.tomledit gottrusted hook ... (was untrusted)while the marketplace pointed at the other directory.A manual GitHub-source run, the new installer with no
--sourcein a throwawayHOME, added the GitHub marketplace in both homes and accepted both recorded forms aschhoumann/t3-pstack: Claude Code'schhoumann/t3-pstackand Codex'shttps://github.com/chhoumann/t3-pstack.git. It trustedmain's hook, which matches the pin, and it still does with 83a3c12's installer.installedfails there with2.0.0-rc.2, expected 2.0.0-rc.3until this merges. The e2e has no GitHub-source test: it would need the network, and neither CLI accepts afile://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 10beforeAllandafterAllhooks, and one sync test that needs a case-sensitive filesystem.bun run typecheckpasses, andbun test orch watch-pr check-plan.test.tsgives 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.mjson a real sheet against a live catalog: ok.install.test.mjs,generate.test.mjs, andreadme-facts.test.mjs, and 5 of the 8 e2e tests.install-cli.test.mjspasses on macOS either way. On CI's Windows job it passes: a.cmdshim built like npm's, found onPathand by full path, receives every argument intact, includingC:\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, wherehasClireadC:\...as a command name and Node refuses to start a.cmdfile without a shell.generate.test.mjstest hasmainship 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 localorigin/mainis 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.generate --checkandbun test tests/on their own, as the first five did in the first round.Deviations from the design
--claude-homeand--codex-homereplace--home <dir> --only <harness>. With two harnesses, the flag names the harness.CLAUDE_CONFIG_DIRorCODEX_HOMEfor 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.settings.jsonandplugins/installed_plugins.jsoninstead ofclaude plugin list --json, so it sees records at every scope and needs no CLI. The Codex doctor uses the app-server, as designed.marketplace update/upgradeandplugin update/add. The design reran every native updater, but Claude'smarketplace updaterewritesknown_marketplaces.jsonon every run, and the skip keeps a rerun byte-identical.generate.mjs --check, since there is nocheck.mjs. The announcement it requires is the phrase "rerun the installer" in the release'sCHANGES.mdentry. It fetchesorigin/mainand 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.SKILL.mdgets no note, so a stale copy of an older release's skill goes unflagged too./setup-pstackrunning the installer, a daily update job, the "exactly onet3-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:
t3-sheet.mjs: a grammar-only check when no catalog is given.tools/install.mjs,tools/install/) and its tests.origin/main.Pathunder the spelling the environment already uses.origin/mainand compares with it, not the merge-base.SECURITY.mdthreat model.