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:
1 parent
aca16404cf
commit
e0205e52e7
1 file changed
+258
@@ -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.
|
||||||
Reference in new issue
Block a user