Compare commits

...
+331
View File
@@ -0,0 +1,331 @@
# 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.