gosalyn: row 229 design draft 1, a per-instance pi agent dir for Chet's Codex login: the host-made 0700 store per instance, a same-path rw bind with PI_CODING_AGENT_DIR through the pi module env, a contents allowlist since pi reads models.json and settings.json there, a refusal for roles with bash, a no-refresh auth check preflight, no fallback for Chet, a fresh-grant mosaic pi-login, the live check that gates the build, and rows 229a and 229b (#428, #1730)

This commit is contained in:
jason.woltje committed 2026-10-22 11:28:45 -05:00
1 parent aca16404cf
commit e0205e52e7
1 file changed
+258
+258
View File
@@ -0,0 +1,258 @@
# Row 229 design: a per-instance pi agent dir for Chet's Codex login (#428, Gitea #1730)
Design draft 1, Gosalyn, 2026-10-22. 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. Sage put this design next
in my order (sage-2784). I read "starts after 10-27" as covering the build and the live check, not the
design text. If Sage meant the design too, this file waits unreviewed until then.
## 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. Pi reads
`models.json` and `settings.json` from the agent dir, and either one could send the token elsewhere or
load code. 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, so Mosaic gets its own fresh grant. It never copies Hermes's `auth.json`.
- I recommend Chet's route has no fallback. 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/` and `tools/` all sit under it (`pi:config.js:430-446`), and so
do `models-store.json` (`pi:core/models-store.js:30`) and `keybindings.json`
(`pi:core/keybindings.js:301`).
- `AuthStorage` keeps `auth.json` at 0600 in a 0700 dir (`pi:core/auth-storage.js:15`, `:25`). It takes a
lock beside the file before a refresh write (`:39`, `:85`) 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` and `--no-approve` (`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. `settings.json` can name
packages, extensions, themes or prompt paths. `--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
- `settings.json`, a regular file whose top-level keys are all in an allowlist the build takes from pi's
`SettingsManager` for the pinned version. An unknown key refuses
- `models-store.json` and `keybindings.json`, regular files
- pi's lock entry beside `auth.json`
Anything else refuses `pi-agent-dir-unsafe` and names the entry: `models.json`, `extensions/`, `tools/`,
`themes/`, `prompts/`, `skills/`, `sessions/`, `oauth.json`, a symlink or a device. Nothing in the session
can create those, because no session tool reaches the dir. The check guards against a human copying a
whole `~/.pi/agent` in by hand. A pi version bump re-pins the allowlist, and a test reads it from the
pinned package so a bump that adds a key fails the suite.
## 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 uses `PI_CODING_AGENT_DIR=<agentDir>`, `HOME` set to an empty temporary dir 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. I recommend Chet's route
states no fallback. 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 `pi-login-missing`.
## 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>`, an empty temporary
`HOME`, and the same `--no-*` flags the adapter uses. It 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 6 check and prints only its status.
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.
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
- the `settings.json` allowlist test reads the keys from the pinned pi package
- 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
## 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.
- The exact `settings.json` key allowlist. The build takes it from the pinned pi version.