22 KiB
Row 229 design: a per-instance pi agent dir for Chet's Codex login (#428, Gitea #1730)
Design round 2, Gosalyn, 2026-10-22, for Webby's webby-0148 (R1, R2, N1-N5). Brief:
docs/plans/2026-10-20_case2-rows.md, "Brief: row B, a per-instance pi agent dir for Chet's Codex login
(#428)". Base: refactor at aca16404c. Pi is the pinned
@earendil-works/pi-coding-agent in the repository's node_modules. Cites into pi are
node_modules/@earendil-works/pi-coding-agent/dist/... (pi:) and
node_modules/@earendil-works/pi-ai/dist/... (pi-ai:).
This is a design. Nothing in it is built or tested. The brief's gate holds: no build row starts until the live check in section 9 passes, run with Jason's or Mos's go, after 10-27. Mos ruled that "starts after 10-27" covers the build and the live check, not the design, and that the check gates every build row (mos-1306). Mos asks Jason for the go when the build rows are ready.
0. Summary
- Each pi instance whose route uses provider
openai-codexgets its own agent dir,<dataRoot>/pi-agents/<business>/<instance>/. The host makes it with the same checks as a thread store: a real directory, owned by the bus user, mode 0700, no symlink in the path. - The launcher binds that dir read-write at the same path into the session and sets
PI_CODING_AGENT_DIRto it through the pi module'senv(). No other instance's session sees it. - Pi refreshes in place under its own file lock. The new tokens are on the host disk when the run ends, so the next run starts with them.
- No tool in the session can read the dir. The gate holds every path tool to the workspace, and a role
with
bashrefuses at launch withpi-agent-dir-shell. - Before each launch the host checks the dir's contents against a short allowlist:
auth.json, itsauth.json.lockdir, and asettings.jsonholding only the three keys a fresh/loginwrites. Pi readsmodels.json,models-store.jsonandsettings.jsonfrom the agent dir, and any of them could send the token elsewhere or run a command. Anything outside the allowlist refusespi-agent-dir-unsafe. - The pi module's preflight runs
pi auth check --provider openai-codex --no-refresh --jsonagainst the dir. No login refusespi-login-missing, and a broken file refusespi-login-invalid. - The human logs in with a new
mosaic pi-login <business> <instance>. It runs pi's own/loginwith the dir set,--offline,--no-sessionand an empty temporary cwd, so Mosaic gets its own fresh grant and pi writes nothing else there. It never copies Hermes'sauth.json. - Chet's route has no fallback by default (mos-1306). A fallback would quietly move him back onto Bean's Z.ai key.
- Two build rows: 229a, the store, the checks, the bind, the env, the preflight and the tests. 229b, the login command and the README.
1. Facts at aca16404c
Pi side:
getAgentDir()returnsPI_CODING_AGENT_DIR, else~/.pi/agent(pi:config.js:421).auth.json,settings.json,models.json,themes/,tools/,bin/,prompts/,sessions/andpi-debug.logall sit under it (pi:config.js:430-462), and so domodels-store.json(pi:core/models-store.js:30), a cached provider catalog, andkeybindings.json(pi:core/keybindings.js:301).checkProviderAuthbuilds a model runtime, andModelRuntime.createloadsmodels.jsonfrom the agent dir (pi:core/model-runtime.js:76). So the auth check itself readsmodels.json.- Interactive mode downloads
fdandrginto<agentDir>/bin/when they are missing (pi:modes/interactive/interactive-mode.js:699-705,pi:utils/tools-manager.js:300-330) unlessPI_OFFLINEis set, which--offlinedoes (pi:main.js:440-444,pi:utils/tools-manager.js:13). Without--no-sessionit makes<agentDir>/sessions/(pi:core/session-manager.js:242-252). - A fresh interactive start writes
lastChangelogVersiontosettings.json(pi:modes/interactive/interactive-mode.js:918). A first/loginthen selects the provider's default model withpersist: true(:4766), which writesdefaultProvideranddefaultModel(pi:core/settings-manager.js:470-476).enabledModelschanges only when scoped models are set (pi:core/agent-session.js:1272-1285), and a fresh dir has none. No settings write runs outside interactive mode. AuthStoragewritesauth.jsonwith mode 0600 only when it creates the file (pi:core/auth-storage.js:14-15). It never fixes the mode of an existing file. It takes a proper-lockfile lock before a refresh write (:39,:85), which is the directoryauth.json.lockbeside the file, and writes withwriteFileSync(:66,:140). The lock is a sibling path, so the dir itself must be writable. A file-only bind isn't enough.- pi-ai refreshes under a double-checked lock and treats a token with less than 5 minutes left as
expired (
pi-ai:...resolve.js:62-74). Two sessions sharing one dir therefore serialize their refreshes. - The
openai-codexOAuth client has a browser flow with a callback onlocalhost:1455and a device-code flow atauth.openai.com/codex/device(pi-ai:auth/oauth/openai-codex.js:26-33). A refresh postsgrant_type=refresh_token(:125-140), and the response carries a new refresh token. The old one is then spent. - Startup migrations write into the agent dir only when legacy files exist there:
oauth.json, session files in the dir root,commands/,tools/(pi:migrations.js:17-63,:76-129,:161-177).settings-manager.js:101writessettings.jsonwhen a setting changes. pi auth check --provider <p> [--json] [--credentials] [--no-refresh](pi:cli/auth-command.js:5,:23-25).checkProviderAuth(pi:cli/auth-check.js:5-34) returnsready,not_readyorinvalid. It builds its model runtime withallowModelNetwork: false(:43-49). With--no-refreshit only checks that a credential is present: a revoked refresh token still readsready. It prints no credential unless--credentialsis given.auth print-bearer-tokenandprint-api-keyprint secrets, and Mosaic never runs them.
Mosaic side:
binds()lists every mount and says why (packages/seat/src/sandbox.mjs:102-131). The thread store binds read-write at:119-125.sandboxArgvsetsHOMEto the run's ownhome/(:163-166).threadStore()makes<dataRoot>/threads/<business>/<thread>/and refuses a symlink, a non-directory, another owner or any group or other mode bit (packages/seat/src/session.mjs:59-72).ENV_ALLOWdoesn't listPI_CODING_AGENT_DIR(:83-88).- The launcher's check order is at
packages/cli/src/launcher.mjs:1-40. One instance per thread runs at a time (:289,registry.running(b.id, instance, thread)), so a thread instance such as Chet can run in two threads at once. Preflight isharness.preflight({ env })(:333). A failed primary preflight takes the stated fallback (:334-345, row 65). The thread store is made at:388-394, mounts at:456, the session file withharnessEnv: bundle.envat:461-484, the paths at:486-495. - The pi module's
preflight()checks only the binary (packages/plugins/harness/pi/index.mjs:33-37).env({ policy, tools, session })builds the harness env (:63-71). The adapter runs pi with--no-extensions,--no-skills,--no-prompt-templates,--no-approveand--offline(adapters/pi/adapter.sh:104-123). - The gate refuses a path tool whose path is outside the workspace (
packages/harness/src/gate.mjs:79-83,:152). Chet's role,roles/companion.json, has tools["read","web"].
2. Where the dir lives and who can read it
<dataRoot>/pi-agents/<business>/<instance>/. A new seat function agentStore(dataRoot, business, instance) in session.mjs, beside threadStore(), makes each level with mode 0700 and the same refusal:
a symlink, a non-directory, another owner or any 0077 mode bit refuses pi-agent-dir-unsafe. Business and
instance must match ID_PATTERN and hold no ...
The dir belongs to the instance, not the thread. Chet's threads share one login, and pi's lock keeps their refreshes apart (section 1). Only the bus user can read it on the host. A seat that shares the bus uid can read it too, which is the same exposure the thread stores and the bus's own credential files have today (case 2 r11 B1). This row doesn't change that.
Which routes get one. The pi module exports agentStore({ provider }), true for openai-codex. The
launcher asks the primary route's module, and the fallback's if one is taken. A module without the hook
gets no dir. A provider key route such as Bean's Z.ai keeps working exactly as today.
3. How the launcher mounts it
After step 5 picks the route and before the bwrap check:
- If the module says the route needs a store, the launcher calls
agentStore(). A refusal stops the launch with its code. - The contents check in section 5 runs.
- The module's preflight runs with the dir (section 6).
pathsgainsagentStore, andbinds()adds one entry:{ kind: "rw", src: dir, why: "the instance's pi agent dir, its own login, kept across runs (row 229)" }. It binds at the same path, so the path pi sees is the host path.- The session object passed to
bundlegainsagentDir, and the pi module'senv()addsPI_CODING_AGENT_DIR: session.agentDirwhen it is set. It reaches pi throughsession.harnessEnv, which the runner spreads into the adapter's env.ENV_ALLOWdoesn't change, so no host value ofPI_CODING_AGENT_DIRever passes.
The preflight signature changes to preflight({ env, agentDir }). Every other module ignores the new key,
so their behaviour doesn't change. The adapter contract in adapters/README.md gains one line for it.
4. How a refresh survives the run
Pi writes the refreshed auth.json into the bound dir, which is the host dir. Nothing copies it in or out.
A run that dies mid-write leaves pi's lock behind. Pi's lock goes stale on its own timer. The contents
check in section 5 accepts a lock entry and never deletes one, so the host never breaks a lock a running
session holds.
Two clients on one grant would race: a refresh spends the old refresh token, and the other client's next
refresh fails. Mosaic avoids that by never sharing a grant. mosaic pi-login makes a new one (section 7).
Hermes keeps its own grant. Whether OpenAI revokes an older grant when a new one is made, or limits grants
per account, I don't know. Section 9's live check exists to find out.
5. What the session can read, and what the dir may hold
The session's tools:
readand every other path tool go through the gate, which refuses a path outside the workspace. The agent dir is outside it.websends a URL and gets text back. It has no file argument.bashwould reach the dir, since pi itself must open it. A role whose tool list hasbash, or a route with the harness's built-in shell, refusespi-agent-dir-shellat launch. The same check runs invalidatefor the role file, so the conflict shows before a launch.- The pi process reads the dir. That is the point of the bind.
The dir's contents. Pi reads more than auth.json from the agent dir. A models.json there can set a
provider's base URL, which would send the OAuth token to another host, and models-store.json is a
cached catalog of the same kind. settings.json is worse than it looks. Every dangerous setting is a
key pi knows, so a check against pi's own key set would pass all of them (webby-0148 R1):
httpProxyapplies to every request pi makes, the Codex bearer and the refresh POST included (pi:main.js:455,:685)packages,extensions,skills,promptsandthemesname code or text to loadshellPath,shellCommandPrefix,npmCommand,externalEditorCommandandsessionDirname a command to run or a place to write
--no-extensions and the other flags turn discovery off, but this design doesn't rest on them alone.
Before each launch the host lists the dir and allows only:
auth.json, a regular file, mode 0600, owned by the bus user. Pi sets 0600 only when it creates the file, so this check is what holds the modeauth.json.lock, a directory, owned by the bus user. It is the only directory allowed, and the check never deletes it (section 4)settings.json, a regular file of at most 4 KiB that parses to a plain object, empty or with only these keys:lastChangelogVersion, a string of at most 32 characters matching^\d+\.\d+\.\d+[0-9A-Za-z.+-]*$,defaultProvider, exactlyopenai-codex, anddefaultModel, a string of at most 64 characters matching^[A-Za-z0-9._-]+$. These are the keys a fresh/loginwrites (section 1). Any other key refuses,httpProxyand the paths and commands above by name
Anything else refuses pi-agent-dir-unsafe and names the entry: models.json, models-store.json,
keybindings.json, extensions/, tools/, bin/, themes/, prompts/, skills/, sessions/,
pi-debug.log, oauth.json, a symlink, a device, or any other file or directory. Round 1 allowed
models-store.json and keybindings.json. Round 2 refuses both: the first can carry a base URL like
models.json, and nothing in a headless run or a login needs the second. Nothing in the session can
create a refused entry, because no session tool reaches the dir. The check guards against a human
copying a whole ~/.pi/agent in by hand, and against a login that wrote more than it should.
The settings allowlist is the three keys above, written in Mosaic's code, not read from pi. A test pins
the sorted list of SettingsManager setter names in the pinned package by its sha256, so a pi bump that
adds, drops or renames any setting fails the suite, and the human reviewing the bump re-checks the three
keys before re-pinning.
6. How a launch with no login refuses
The pi module's preflight({ env, agentDir }), after the binary check, runs when agentDir is set:
node <pi bin> auth check --provider openai-codex --no-refresh --json
It runs only after the section 5 contents check passes, because the auth check loads models.json from
the dir (section 1). It uses PI_CODING_AGENT_DIR=<agentDir>, HOME and the cwd both set to one empty
temporary dir made with mkdtemp under the scratch tmp and removed afterwards, and PATH from the
system. It runs on the host with a 10-second timeout. Never --credentials. Results:
- exit 0,
ready: pass - exit 1,
not_ready:pi-login-missing, with the message "runmosaic pi-login <business> <instance>" - exit 2,
invalid, a timeout or any other exit:pi-login-invalid
The check never refreshes, so a launch never spends a refresh token on the host. That leaves one gap I
can't close without a network call: a revoked or expired refresh token still reads ready. Pi then
fails the first turn when its refresh fails, and the turn's failure is visible in the run record. The
fix is the same login command. I chose --no-refresh over a refresh at preflight because a host-side
refresh would race a running session of the same instance.
Fallback. Row 65 lets a failed primary preflight take the stated fallback. Chet's route ships with none
(mos-1306). A fallback onto Bean's Z.ai key would make a lost login silent: Chet answers on another
provider and Melanie can't tell. Without one, the launch refuses with the preflight's code. A human can
still add a fallback through routes.set. No agent path can: Chet is a companion instance, a creative
definition (packages/business/src/vocabulary.mjs:207), and creativeHumanOnly
(packages/business/src/launch-settings.mjs:13-17, called for routes at :293) refuses
organization-creative-human-only unless the actor is the business file's human. The tester is not
that human, though routes.set is a tester verb (packages/cli/src/broker.mjs:139). This row only
makes none the default.
7. The login command (229b)
mosaic pi-login <business> <instance> in packages/cli, a human verb:
- Refuses unless the instance's primary route is pi with provider
openai-codex(pi-login-not-needed), and unless the instance isn't running (instance-running). - Calls
agentStore()and runs the contents check. - Moves an existing
auth.jsonout of the dir, to<dataRoot>/pi-agents/<business>/.replaced/<instance>-<UTC>.jsonat 0600, so every login is a fresh grant and the contents allowlist doesn't grow. It never reads or copies~/.pi/agentor Hermes's files. - Runs interactive pi on the attached terminal with
PI_CODING_AGENT_DIR=<dir>,HOMEand the cwd both set to one empty temporary dir under the scratch tmp, and these flags:--offline, so pi downloads nofdorrginto<dir>/bin/and sends no telemetry,--no-session, so it makes no<dir>/sessions/, and the--no-extensions,--no-skillsand--no-prompt-templatesthe adapter uses.--offlinedoesn't block/login: it setsPI_OFFLINEand skips the version check and the catalog refresh, and the OAuth flow makes its own requests. The command tells the human to type/login, choose OpenAI Codex and pick the device code flow, since the callback onlocalhost:1455won't reach a browser on another machine. - Afterwards runs the section 5 contents check, then the section 6 check, and prints only their
status. A login that left any entry outside the allowlist refuses
pi-agent-dir-unsafehere, before any launch.
The command prints no token. .replaced/ gets the agentStore() checks. It can't collide with an
instance dir, since ID_PATTERN doesn't allow a leading dot.
reset.sh wipes the data root, and the login with it. The README says so.
8. Files the build touches
229a:
packages/seat/src/session.mjs:agentStore(), the contents checkpackages/seat/src/sandbox.mjs: theagentStorebind inbinds()packages/cli/src/launcher.mjs: the store, the check and the preflight argument after step 5, thepi-agent-dir-shellrefusal,paths.agentStore,agentDiron the session- no change to
packages/harness/src/bundle.mjs, which already passes{ ...session, ...model }toenv()(:375), soagentDiron the launcher's session reaches the module packages/plugins/harness/pi/index.mjs:agentStore(), the preflight,PI_CODING_AGENT_DIRinenv()adapters/README.md: theagentDirpreflight key- the role validator for
pi-agent-dir-shell - tests in
packages/seat/tests,packages/cli/tests,packages/plugins/harness/pi/tests
229b:
packages/cli/src/pi-login.mjsand its dispatchpackages/cli/README.md- tests in
packages/cli/tests
9. The live check (the gate)
Run after 10-27, with Jason's or Mos's go, on Chet's real ChatGPT login. A human does the login.
- Hermes answers as Chet. Record the time.
mosaic pi-loginfor Chet. The status readsready.- Launch Chet on the new route. Run one turn.
- Force a Mosaic refresh. The human edits only
expiresin Mosaic'sauth.jsonto a past time, then runs one more turn. It succeeds, andauth.jsonchanged. After steps 2, 3 and 4 the record lists the dir's entry names, and each passes the section 5 check. - Hermes answers again. This is the check that Mosaic's refresh didn't revoke Hermes's grant.
- Wait for Hermes's own next refresh, at least one access-token lifetime, then run a Mosaic turn. This is the check the other way.
- The record lists each step's time and result, and never a token.
Any failure stops the row. The answer then goes to Jason: Chet waits until Hermes stops, or he moves alone. No build row starts before step 6 passes.
10. Tests the build rows carry
229a:
agentStore()refuses a symlinked level, a wrong owner, a 0750 dir and a..id- the contents check passes the allowed set and refuses each listed entry,
models-store.json,keybindings.json,bin/andpi-debug.logincluded, and a second directory besideauth.json.lock settings.jsonpasses when absent,{}and with the three allowed keys. It refuses each ofhttpProxy,packages,extensions,skills,prompts,themes,shellPath,shellCommandPrefix,npmCommand,externalEditorCommandandsessionDirby name, adefaultProviderother thanopenai-codex, a non-string or overlong value, an array, a file over 4 KiB and a file that doesn't parseauth.jsonat 0644 refuses, so a file that pi didn't create still meets the mode- the bump test: the sha256 of the sorted
SettingsManagersetter names in the pinned package matches the pinned value, so any change to pi's settings key set fails it - a
models.jsonin the dir refusespi-agent-dir-unsafebefore the fake pi bin runs. The fake bin records each call, and the record is empty - the preflight's fake pi bin records its cwd and
HOME, which are the same empty temporary dir - the tester's
routes.setadding a fallback to Chet's route refusesorganization-creative-human-only - a companion role with
bashrefusespi-agent-dir-shell binds()adds the entry only whenagentStoreis setenv()setsPI_CODING_AGENT_DIRonly whenagentDiris set- a host
PI_CODING_AGENT_DIRnever reaches the session - preflight maps exit 0, 1, 2 and a timeout to pass,
pi-login-missingandpi-login-invalid. A fake pi bin records its argv, which must hold--no-refreshand never--credentials - a route with no store gets no bind and no preflight change. Bean's route is unchanged
- Chet's route with a failed preflight and no fallback refuses with the preflight's code
- a mutant without the contents check fails, and so does one without the shell refusal
- every test uses
mktemp -dunder the scratch tmp, never/tmp, and no real login
229b:
pi-loginrefuses a non-codex route and a running instance- it moves an existing
auth.jsonout of the dir, never reads~/.pi/agent, and prints only the status - its fake pi bin records argv, which holds
--offlineand--no-session, and a cwd that is an empty temporary dir - after a fake login that writes
auth.json,auth.json.lock/and asettings.jsonwith the three keys, the dir holds only allowlisted entries and the command reportsready - a fake login that also writes
bin/fdorsessions/makes the command refusepi-agent-dir-unsafe
11. Open items
- Whether OpenAI revokes or caps grants per account. Section 9 answers it.
- Whether pi's request-time refresh works under the adapter's flags. I read nothing that blocks it.
--offlineaffects only the model catalog (pi:core/model-runtime.js:88). Section 9 step 4 proves it. - Whether a real
/loginon the pinned pi writes only the three settings keys. Section 1 reads it from the code. Section 9's dir listings prove it, and a fourth key there stops the row for a design change.