Files

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-codex gets 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_DIR to it through the pi module's env(). 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 bash refuses at launch with pi-agent-dir-shell.
  • Before each launch the host checks the dir's contents against a short allowlist: auth.json, its auth.json.lock dir, and a settings.json holding only the three keys a fresh /login writes. Pi reads models.json, models-store.json and settings.json from the agent dir, and any of them could send the token elsewhere or run a command. Anything outside the allowlist refuses pi-agent-dir-unsafe.
  • The pi module's preflight runs pi auth check --provider openai-codex --no-refresh --json against the dir. No login refuses pi-login-missing, and a broken file refuses pi-login-invalid.
  • The human logs in with a new mosaic pi-login <business> <instance>. It runs pi's own /login with the dir set, --offline, --no-session and an empty temporary cwd, so Mosaic gets its own fresh grant and pi writes nothing else there. It never copies Hermes's auth.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() returns PI_CODING_AGENT_DIR, else ~/.pi/agent (pi:config.js:421). auth.json, settings.json, models.json, themes/, tools/, bin/, prompts/, sessions/ and pi-debug.log all sit under it (pi:config.js:430-462), and so do models-store.json (pi:core/models-store.js:30), a cached provider catalog, and keybindings.json (pi:core/keybindings.js:301).
  • checkProviderAuth builds a model runtime, and ModelRuntime.create loads models.json from the agent dir (pi:core/model-runtime.js:76). So the auth check itself reads models.json.
  • Interactive mode downloads fd and rg into <agentDir>/bin/ when they are missing (pi:modes/interactive/interactive-mode.js:699-705, pi:utils/tools-manager.js:300-330) unless PI_OFFLINE is set, which --offline does (pi:main.js:440-444, pi:utils/tools-manager.js:13). Without --no-session it makes <agentDir>/sessions/ (pi:core/session-manager.js:242-252).
  • A fresh interactive start writes lastChangelogVersion to settings.json (pi:modes/interactive/interactive-mode.js:918). A first /login then selects the provider's default model with persist: true (:4766), which writes defaultProvider and defaultModel (pi:core/settings-manager.js:470-476). enabledModels changes 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.
  • AuthStorage writes auth.json with 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 directory auth.json.lock beside the file, and writes with writeFileSync (: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-codex OAuth client has a browser flow with a callback on localhost:1455 and a device-code flow at auth.openai.com/codex/device (pi-ai:auth/oauth/openai-codex.js:26-33). A refresh posts grant_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:101 writes settings.json when 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) returns ready, not_ready or invalid. It builds its model runtime with allowModelNetwork: false (:43-49). With --no-refresh it only checks that a credential is present: a revoked refresh token still reads ready. It prints no credential unless --credentials is given. auth print-bearer-token and print-api-key print 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. sandboxArgv sets HOME to the run's own home/ (: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_ALLOW doesn't list PI_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 is harness.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 with harnessEnv: bundle.env at :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-approve and --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:

  1. If the module says the route needs a store, the launcher calls agentStore(). A refusal stops the launch with its code.
  2. The contents check in section 5 runs.
  3. The module's preflight runs with the dir (section 6).
  4. paths gains agentStore, and binds() 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.
  5. The session object passed to bundle gains agentDir, and the pi module's env() adds PI_CODING_AGENT_DIR: session.agentDir when it is set. It reaches pi through session.harnessEnv, which the runner spreads into the adapter's env. ENV_ALLOW doesn't change, so no host value of PI_CODING_AGENT_DIR ever 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:

  • read and every other path tool go through the gate, which refuses a path outside the workspace. The agent dir is outside it.
  • web sends a URL and gets text back. It has no file argument.
  • bash would reach the dir, since pi itself must open it. A role whose tool list has bash, or a route with the harness's built-in shell, refuses pi-agent-dir-shell at launch. The same check runs in validate for 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):

  • httpProxy applies to every request pi makes, the Codex bearer and the refresh POST included (pi:main.js:455, :685)
  • packages, extensions, skills, prompts and themes name code or text to load
  • shellPath, shellCommandPrefix, npmCommand, externalEditorCommand and sessionDir name 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 mode
  • auth.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, exactly openai-codex, and defaultModel, a string of at most 64 characters matching ^[A-Za-z0-9._-]+$. These are the keys a fresh /login writes (section 1). Any other key refuses, httpProxy and 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 "run mosaic 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:

  1. 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).
  2. Calls agentStore() and runs the contents check.
  3. Moves an existing auth.json out of the dir, to <dataRoot>/pi-agents/<business>/.replaced/<instance>-<UTC>.json at 0600, so every login is a fresh grant and the contents allowlist doesn't grow. It never reads or copies ~/.pi/agent or Hermes's files.
  4. Runs interactive pi on the attached terminal with PI_CODING_AGENT_DIR=<dir>, HOME and the cwd both set to one empty temporary dir under the scratch tmp, and these flags: --offline, so pi downloads no fd or rg into <dir>/bin/ and sends no telemetry, --no-session, so it makes no <dir>/sessions/, and the --no-extensions, --no-skills and --no-prompt-templates the adapter uses. --offline doesn't block /login: it sets PI_OFFLINE and 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 on localhost:1455 won't reach a browser on another machine.
  5. 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-unsafe here, 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 check
  • packages/seat/src/sandbox.mjs: the agentStore bind in binds()
  • packages/cli/src/launcher.mjs: the store, the check and the preflight argument after step 5, the pi-agent-dir-shell refusal, paths.agentStore, agentDir on the session
  • no change to packages/harness/src/bundle.mjs, which already passes { ...session, ...model } to env() (:375), so agentDir on the launcher's session reaches the module
  • packages/plugins/harness/pi/index.mjs: agentStore(), the preflight, PI_CODING_AGENT_DIR in env()
  • adapters/README.md: the agentDir preflight 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.mjs and its dispatch
  • packages/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.

  1. Hermes answers as Chet. Record the time.
  2. mosaic pi-login for Chet. The status reads ready.
  3. Launch Chet on the new route. Run one turn.
  4. Force a Mosaic refresh. The human edits only expires in Mosaic's auth.json to a past time, then runs one more turn. It succeeds, and auth.json changed. After steps 2, 3 and 4 the record lists the dir's entry names, and each passes the section 5 check.
  5. Hermes answers again. This is the check that Mosaic's refresh didn't revoke Hermes's grant.
  6. 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.
  7. 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/ and pi-debug.log included, and a second directory beside auth.json.lock
  • settings.json passes when absent, {} and with the three allowed keys. It refuses each of httpProxy, packages, extensions, skills, prompts, themes, shellPath, shellCommandPrefix, npmCommand, externalEditorCommand and sessionDir by name, a defaultProvider other than openai-codex, a non-string or overlong value, an array, a file over 4 KiB and a file that doesn't parse
  • auth.json at 0644 refuses, so a file that pi didn't create still meets the mode
  • the bump test: the sha256 of the sorted SettingsManager setter names in the pinned package matches the pinned value, so any change to pi's settings key set fails it
  • a models.json in the dir refuses pi-agent-dir-unsafe before 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.set adding a fallback to Chet's route refuses organization-creative-human-only
  • a companion role with bash refuses pi-agent-dir-shell
  • binds() adds the entry only when agentStore is set
  • env() sets PI_CODING_AGENT_DIR only when agentDir is set
  • a host PI_CODING_AGENT_DIR never reaches the session
  • preflight maps exit 0, 1, 2 and a timeout to pass, pi-login-missing and pi-login-invalid. A fake pi bin records its argv, which must hold --no-refresh and 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 -d under the scratch tmp, never /tmp, and no real login

229b:

  • pi-login refuses a non-codex route and a running instance
  • it moves an existing auth.json out of the dir, never reads ~/.pi/agent, and prints only the status
  • its fake pi bin records argv, which holds --offline and --no-session, and a cwd that is an empty temporary dir
  • after a fake login that writes auth.json, auth.json.lock/ and a settings.json with the three keys, the dir holds only allowlisted entries and the command reports ready
  • a fake login that also writes bin/fd or sessions/ makes the command refuse pi-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. --offline affects only the model catalog (pi:core/model-runtime.js:88). Section 9 step 4 proves it.
  • Whether a real /login on 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.