gosalyn: row 229 design round 2 for Webby webby-0148: the settings.json allowlist is three keys a fresh /login writes, written in Mosaic code with httpProxy and the path and command keys refused by name and a sha256 bump test on pi's setter names, models-store.json and keybindings.json now refuse, auth.json.lock is the only allowed dir, pi-login runs --offline and --no-session in an empty temporary cwd with a 229b allowlist test, the contents check runs before preflight, and creativeHumanOnly covers the fallback (R1, R2, N1-N5)

This commit is contained in:
2026-10-22 11:35:37 -05:00
parent db79b50d7b
commit aac54035f2
+108 -38
View File
@@ -1,7 +1,8 @@
# 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
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:`).
@@ -22,13 +23,15 @@ row (mos-1306). Mos asks Jason for the go when the build rows are ready.
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`.
- 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, so Mosaic gets its own fresh grant. It never copies Hermes's `auth.json`.
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
@@ -38,12 +41,27 @@ row (mos-1306). Mos asks Jason for the go when the build rows are ready.
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`
`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`).
- `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.
- `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
@@ -73,7 +91,8 @@ Mosaic side:
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`).
`--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"]`.
@@ -135,20 +154,39 @@ The session's tools:
- 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`
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
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.
`--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
@@ -158,7 +196,9 @@ The pi module's `preflight({ env, agentDir })`, after the binary check, runs whe
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
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>`"
@@ -172,9 +212,13 @@ 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. Under Q222 a
human can still add a fallback through the human-only routes setting. This row only makes none the
default, and no agent path adds one.
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)
@@ -186,11 +230,17 @@ default, and no agent path adds one.
`<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.
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.
@@ -223,7 +273,8 @@ Run after 10-27, with Jason's or Mos's go, on Chet's real ChatGPT login. A human
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.
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.
@@ -236,8 +287,21 @@ alone. No build row starts before step 6 passes.
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
- 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
@@ -252,10 +316,16 @@ alone. No build row starts before step 6 passes.
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.
- The exact `settings.json` key allowlist. The build takes it from the pinned pi version.
- 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.