diff --git a/agents/gosalyn/work/queue-229/DESIGN.md b/agents/gosalyn/work/queue-229/DESIGN.md new file mode 100644 index 000000000..7df8f97d6 --- /dev/null +++ b/agents/gosalyn/work/queue-229/DESIGN.md @@ -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, + `/pi-agents///`. 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 `. 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

[--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 `/threads///` 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 + +`/pi-agents///`. 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 auth check --provider openai-codex --no-refresh --json +``` + +It uses `PI_CODING_AGENT_DIR=`, `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 `" +- 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 ` 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 + `/pi-agents//.replaced/-.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=

`, 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.