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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user