Deliver named Pi tokens and /token selection runtime #51

Merged
vnprc merged 3 commits from agent/named-tokens-runtime into master 2026-08-24 02:42:44 +01:00
Member

A managed Pi credential can now hold several named bearer tokens, and Pi can switch between them with a new /token command while a request is running or queued. On a running machine with today's empty public registries nothing changes except cleanup: the next activation retires the old per-credential runtime symlink layer that only the framework itself ever pointed at, and removes any managed auth.json entries the old layout recorded. Everything an operator put in auth.json or models.json by hand stays untouched, and no bearer ever leaves the volatile Age files except through Pi's own credential pipe. What proves it is the rewritten pi-provider-lifecycle witness, which drives the real generated extension through a real Pi process, kills the reconciler at every publication boundary, and greps every artifact and scenario home for leaked bearers; the whole flake check suite is green. If this is wrong, revert the commit — the retired link layer is regenerated by the previous revision's reconciler on the next rebuild, and no persistent state outside the managed files is touched.

Two contracts in the plan were corrected against measured Pi 0.84.2 behavior rather than followed as written, and the plan doc may deserve a matching amendment. First, the plan places the startup selector command in auth.json; measured provider composition resolves a stored auth.json credential in preference to any provider or extension API key, which would have made the /token extension's supported registerProvider re-registration permanently inert. The startup command therefore lives in the managed models.json provider record (an equally supported command-backed key surface), auth.json carries no managed entries, and the witness pins the working end-to-end switch. Second, the plan has the interactive picker run at session start; awaiting a UI dialog inside session_start drains Pi's event loop in RPC mode and exits the process before any response could arrive, so the picker fires as a detached task with identical user-visible semantics, and the witness drives it through Pi's RPC extension-UI surface.

Risk

R3, per the plan's archetypes slice row: startup ordering, queued-work pinning, concurrent state writes, owned removal, and the bearer leak boundary. The residual risk after the witness is mainly interactive-TUI behavior of the picker and status output, which the sandbox cannot render; the RPC extension-UI surface it does exercise shares the same code path.

Validation

nix build path:.#checks.x86_64-linux.pi-provider-lifecycle green: selector precedence matrix; both adapters authenticating per-token against a local server through real pi; process-lifetime cache pinning; RPC-driven /token idle switch, deferral with latest-wins, cancel, interactive picker, non-interactive refusal, credential independence, concurrent preference-file merge, and per-process pinning; legacy link retirement (idempotent, absent-as-retired, foreign-occupant refusal preserved byte-for-byte, unrecorded operator symlink survival); SIGKILL injection after the auth rename, models rename, recorded unlink, and manifest rename with convergent retries; the pass-12 bounded-convergence window with its refuse-by-name repair; extension installer collisions; comparator sabotage; and leak scans over every scenario home, log, and generated artifact. nix flake check path:. exits 0 (all 15 checks).

Refs allod/strategy#36

A managed Pi credential can now hold several named bearer tokens, and Pi can switch between them with a new /token command while a request is running or queued. On a running machine with today's empty public registries nothing changes except cleanup: the next activation retires the old per-credential runtime symlink layer that only the framework itself ever pointed at, and removes any managed auth.json entries the old layout recorded. Everything an operator put in auth.json or models.json by hand stays untouched, and no bearer ever leaves the volatile Age files except through Pi's own credential pipe. What proves it is the rewritten pi-provider-lifecycle witness, which drives the real generated extension through a real Pi process, kills the reconciler at every publication boundary, and greps every artifact and scenario home for leaked bearers; the whole flake check suite is green. If this is wrong, revert the commit — the retired link layer is regenerated by the previous revision's reconciler on the next rebuild, and no persistent state outside the managed files is touched. Two contracts in the plan were corrected against measured Pi 0.84.2 behavior rather than followed as written, and the plan doc may deserve a matching amendment. First, the plan places the startup selector command in auth.json; measured provider composition resolves a stored auth.json credential in preference to any provider or extension API key, which would have made the /token extension's supported registerProvider re-registration permanently inert. The startup command therefore lives in the managed models.json provider record (an equally supported command-backed key surface), auth.json carries no managed entries, and the witness pins the working end-to-end switch. Second, the plan has the interactive picker run at session start; awaiting a UI dialog inside session_start drains Pi's event loop in RPC mode and exits the process before any response could arrive, so the picker fires as a detached task with identical user-visible semantics, and the witness drives it through Pi's RPC extension-UI surface. ## Risk R3, per the plan's archetypes slice row: startup ordering, queued-work pinning, concurrent state writes, owned removal, and the bearer leak boundary. The residual risk after the witness is mainly interactive-TUI behavior of the picker and status output, which the sandbox cannot render; the RPC extension-UI surface it does exercise shares the same code path. ## Validation `nix build path:.#checks.x86_64-linux.pi-provider-lifecycle` green: selector precedence matrix; both adapters authenticating per-token against a local server through real `pi`; process-lifetime cache pinning; RPC-driven /token idle switch, deferral with latest-wins, cancel, interactive picker, non-interactive refusal, credential independence, concurrent preference-file merge, and per-process pinning; legacy link retirement (idempotent, absent-as-retired, foreign-occupant refusal preserved byte-for-byte, unrecorded operator symlink survival); SIGKILL injection after the auth rename, models rename, recorded unlink, and manifest rename with convergent retries; the pass-12 bounded-convergence window with its refuse-by-name repair; extension installer collisions; comparator sabotage; and leak scans over every scenario home, log, and generated artifact. `nix flake check path:.` exits 0 (all 15 checks). Refs allod/strategy#36
Every named token of a managed credential becomes a user-owned 0600 Age
secret at /run/agenix/pi-provider-token/<credential>/<token>; the Age
secret name is the nested runtime-relative path itself, so agenix
installs the leaf through its generation directory with no per-secret
symlink and root-owned world-traversable intermediate directories
(activation runs at umask 0022).

The two-mode pi-provider-token-select selector replaces the
per-credential helper: credential mode resolves remembered name, then
deployment default, then sole delivered token, and otherwise fails
naming the provisioned tokens; token mode resolves one explicit
provisioned name. Both validate one RFC 6750 b64token line and stream
the bearer only to Pi's stdout credential pipe.

The reconciler places the startup selector command in the managed
models.json provider record rather than a stored auth.json credential.
Measured against Pi 0.84.2 provider composition, a stored credential is
resolved in preference to any provider or extension API key, so a
managed auth.json entry would shadow both the startup command and the
/token extension's supported registerProvider re-registration; auth.json
is now only cleaned of previously recorded managed entries, which also
retires the legacy layout's helper commands. The prior strict v1
manifest is the complete and only ownership proof for the legacy
per-credential link layer: auth and models publish first as separate
atomic renames, every recorded legacy link is retired (absent recorded
paths resume as already retired, foreign occupants refuse the whole
operation and are preserved, the legacy root is never scanned or even
created), and only then does the manifest rename record credentialLinks
empty.

The generated pi-token extension exposes /token, /token <name>, and
/token cancel scoped to the current provider's credential, re-registers
every provider sharing the credential with a token-specific
command-backed key, defers switches during active or queued work until
agent_settled with no pending messages (latest wins, cancel clears),
and persists remembered choices only after activation through a locked
merge-on-write 0600 XDG state file. The startup picker fires as a
detached task because awaiting a UI dialog inside session_start drains
the event loop and exits Pi in RPC mode before the response could ever
be routed.

The lifecycle witness now drives the real generated extension through
Pi's RPC extension surface (idle switch, deferral, latest-wins, cancel,
interactive picker, non-interactive refusal, credential independence,
concurrent preference merge, per-process pinning), injects SIGKILL at
the auth, models, unlink, and manifest publication boundaries plus the
newly-desired-provider crash window with its refuse-by-name repair, and
extends the selector matrix, retirement, collision, sabotage, and leak
coverage.

Refs allod/strategy#36
Author
Member

Review pass (required post-open review): read-only diff review by gpt-5.6-sol at reasoning xhigh. Both implementation deviations from the plan were adjudicated CONFIRMED as necessary against Pi 0.84.2 source (stored auth.json credentials win over provider keys; session_start cannot await the RPC picker), but the review found 5 blockers and 6 gaps in how they and the surrounding contract were implemented. Findings below; fixes will land as follow-up commits on this branch, and the plan will be amended afterward to record the measured Pi behavior (models.json selector placement, request-time command resolution).

  1. [BLOCKER] Deviation (b): CONFIRMED as necessary for RPC startup, but the detached implementation violates the first-request guarantee. modules/pi-token-extension.ts:117-149 starts the picker without awaiting or otherwise gating requests. Pi RPC cannot await the picker in session_start: extension binding finishes before the RPC stdin reader is installed, while ctx.ui.select() waits for an RPC response. However, once detached, RPC can accept a prompt before the selection response, and TUI initial prompts can run immediately after initialization. That request reaches authentication with no selected credential and fails. Multiple ambiguous credentials can also start concurrent TUI pickers, with the later picker replacing the first UI and potentially leaving its promise unresolved. The claimed RPC behavior is real, but detachment needed a request gate until startup selection completes.

  2. [BLOCKER] Deviation (a): CONFIRMED as necessary, but the new ownership model can delete user-created auth.json credentials. modules/pi-provider-reconcile.sh:364-366 removes every provider listed in the previous manifest from auth.json, while modules/pi-provider-reconcile.sh:383-388 continues recording providers because it owns their models.json records. Pi does prefer stored credentials over provider/extension API keys: pi-ai/dist/auth/resolve.js:40-54 and dist/core/provider-composer.js:226-239 confirm that an auth.json credential wins, making re-registration inert. Moving the startup command to models.json is therefore necessary, keeps models discoverable, and does not itself widen bearer exposure. But after activating this version, an operator can add or /login an auth.json credential under a managed provider ID; the next activation deletes it even though this version never created that entry. This contradicts exact per-file ownership and the PR claim that hand-written auth.json content remains untouched. The collision test at checks/pi-provider-lifecycle.nix:999-1009 runs after the manifest has already been removed and misses this case.

  3. [BLOCKER] Missing-secret reconciliation is not a whole-operation no-op because the extension is still published. modules/pi-provider-reconcile.sh:85-95 returns success without changing managed JSON or the manifest when any desired Age file is missing. modules/pi-provider-lifecycle.nix:180-189 then unconditionally runs the extension installer. For example, deploying a catalogue that changes the default token while its new secret is absent leaves old models/manifest state but installs the new extension; Pi can select according to the new catalogue against stale provider configuration. The witness at checks/pi-provider-lifecycle.nix:947-954 compares models only and does not install the extension in that scenario.

  4. [BLOCKER] An absent recorded legacy link is omitted from both rollback and the publication recheck. modules/pi-provider-reconcile.sh:343-353 writes links.rollback only when the recorded path currently exists, despite the later existed=0 branches at modules/pi-provider-reconcile.sh:126-148 and 401-409. If an interrupted prior retirement left the recorded path absent and another writer creates a file or wrong symlink after the initial check, this activation neither refuses it nor preserves evidence: it publishes the final manifest with credentialLinks:{}. The required absent-as-retired behavior therefore has a concurrency window that can silently convert a foreign occupant into an unrecorded path.

  5. [BLOCKER] Interactive /token can bypass active/queued deferral because idleness is sampled before awaiting the picker. modules/pi-token-extension.ts:185 captures busy = !ctx.isIdle() before ctx.ui.select() is awaited at modules/pi-token-extension.ts:221-224. In RPC mode, a client can invoke /token, enqueue a prompt while the picker is open, then answer the picker. The stale busy=false causes immediate re-registration and persistence at modules/pi-token-extension.ts:195-202, so already-active or queued work may use the new credential instead of being deferred until agent_settled.

  6. [GAP] The process-lifetime command-result cache claim and its witness exercise the wrong Pi API. README.md:133-136 and modules/pi-token-extension.ts:27-30 say Pi caches the resolved bearer for the process lifetime. Actual provider composition calls resolveConfigValueOrThrow, which uses the uncached path at dist/core/resolve-config-value.js:199-207; Pi’s models documentation also says commands are resolved at request time. The witness instead imports and directly invokes cached resolveConfigValue at checks/pi-provider-lifecycle.nix:256-267 and 492-507. Rotating the selected token’s volatile file during a Pi process therefore changes the bearer on the next request, contrary to the documented and claimed behavior.

  7. [GAP] Malformed preference state is silently ignored and later overwritten. modules/pi-token-extension.ts:35-47 converts every preference read or parse error into {}. Consequently resolveStartup() reports only well-formed stale token names, not malformed JSON, and persistSelection() at modules/pi-token-extension.ts:68-75 can replace the corrupt file with a single merged-from-empty choice. A truncated preference file can therefore cause silent fallback and later loss of remembered selections for unrelated credentials.

  8. [GAP] A crash can permanently strand the preference lock. modules/pi-token-extension.ts:55-66 implements locking with a directory and a fixed retry loop, but has no owner metadata or stale-lock recovery. A SIGKILL after mkdirSync(lock) leaves the lock directory indefinitely; subsequent selections activate in memory but fail persistence after five seconds until an operator manually removes it.

  9. [GAP] Non-interactive ambiguity does not surface the selector’s actionable diagnostic through real Pi. The selector emits the required explanation at modules/pi-provider-token-select.sh:112-114, but Pi runs command-backed keys with child stderr ignored at dist/core/resolve-config-value.js:161-169, then reports only a generic configuration-resolution failure. checks/pi-provider-lifecycle.nix:628-641 suppresses Pi output and checks only that no request/state change occurred. A headless ambiguous startup therefore does not tell the operator to use /token or configure a default.

  10. [GAP] The agenix assertion does not pin root-owned 0755 intermediate directories. The generated configuration appears correct today, but checks/pi-provider-lifecycle.nix:273-287 asserts the leaf path, owner, group, mode, and the presence of mkdir -p; it does not assert the activation script’s umask 0022, root ownership, or resulting 0755 modes for nested directories. An upstream activation-umask change to 0077 would make the secret unreachable by the configured user while this assertion still passes.

  11. [GAP] The claimed bearer leak coverage omits argv/environment, many scenario homes, and store closures. checks/pi-provider-lifecycle.nix:1054-1069 scans selected homes, logs, observations, and top-level generated artifacts. It does not inspect live /proc/*/cmdline or environ, does not include several legacy/kill/sabotage scenario homes, and does not traverse Nix store closures. A regression placing a synthetic bearer in a child process environment or an omitted scenario directory would pass this scan.

Validation: I could not rerun nix build path:.#checks.x86_64-linux.pi-provider-lifecycle; the sandbox cannot connect to /nix/var/nix/daemon-socket/socket (Operation not permitted). Static review confirmed the intended auth/models/link/manifest publication order, pass-12 refuse-by-name convergence logic, real Pi RPC extension execution, and non-vacuous comparator sabotage. The worktree was not modified.

Review pass (required post-open review): read-only diff review by gpt-5.6-sol at reasoning xhigh. Both implementation deviations from the plan were adjudicated CONFIRMED as necessary against Pi 0.84.2 source (stored auth.json credentials win over provider keys; session_start cannot await the RPC picker), but the review found 5 blockers and 6 gaps in how they and the surrounding contract were implemented. Findings below; fixes will land as follow-up commits on this branch, and the plan will be amended afterward to record the measured Pi behavior (models.json selector placement, request-time command resolution). 1. **[BLOCKER] Deviation (b): CONFIRMED as necessary for RPC startup, but the detached implementation violates the first-request guarantee.** `modules/pi-token-extension.ts:117-149` starts the picker without awaiting or otherwise gating requests. Pi RPC cannot await the picker in `session_start`: extension binding finishes before the RPC stdin reader is installed, while `ctx.ui.select()` waits for an RPC response. However, once detached, RPC can accept a prompt before the selection response, and TUI initial prompts can run immediately after initialization. That request reaches authentication with no selected credential and fails. Multiple ambiguous credentials can also start concurrent TUI pickers, with the later picker replacing the first UI and potentially leaving its promise unresolved. The claimed RPC behavior is real, but detachment needed a request gate until startup selection completes. 2. **[BLOCKER] Deviation (a): CONFIRMED as necessary, but the new ownership model can delete user-created `auth.json` credentials.** `modules/pi-provider-reconcile.sh:364-366` removes every provider listed in the previous manifest from `auth.json`, while `modules/pi-provider-reconcile.sh:383-388` continues recording providers because it owns their `models.json` records. Pi does prefer stored credentials over provider/extension API keys: `pi-ai/dist/auth/resolve.js:40-54` and `dist/core/provider-composer.js:226-239` confirm that an `auth.json` credential wins, making re-registration inert. Moving the startup command to `models.json` is therefore necessary, keeps models discoverable, and does not itself widen bearer exposure. But after activating this version, an operator can add or `/login` an `auth.json` credential under a managed provider ID; the next activation deletes it even though this version never created that entry. This contradicts exact per-file ownership and the PR claim that hand-written `auth.json` content remains untouched. The collision test at `checks/pi-provider-lifecycle.nix:999-1009` runs after the manifest has already been removed and misses this case. 3. **[BLOCKER] Missing-secret reconciliation is not a whole-operation no-op because the extension is still published.** `modules/pi-provider-reconcile.sh:85-95` returns success without changing managed JSON or the manifest when any desired Age file is missing. `modules/pi-provider-lifecycle.nix:180-189` then unconditionally runs the extension installer. For example, deploying a catalogue that changes the default token while its new secret is absent leaves old models/manifest state but installs the new extension; Pi can select according to the new catalogue against stale provider configuration. The witness at `checks/pi-provider-lifecycle.nix:947-954` compares models only and does not install the extension in that scenario. 4. **[BLOCKER] An absent recorded legacy link is omitted from both rollback and the publication recheck.** `modules/pi-provider-reconcile.sh:343-353` writes `links.rollback` only when the recorded path currently exists, despite the later `existed=0` branches at `modules/pi-provider-reconcile.sh:126-148` and `401-409`. If an interrupted prior retirement left the recorded path absent and another writer creates a file or wrong symlink after the initial check, this activation neither refuses it nor preserves evidence: it publishes the final manifest with `credentialLinks:{}`. The required absent-as-retired behavior therefore has a concurrency window that can silently convert a foreign occupant into an unrecorded path. 5. **[BLOCKER] Interactive `/token` can bypass active/queued deferral because idleness is sampled before awaiting the picker.** `modules/pi-token-extension.ts:185` captures `busy = !ctx.isIdle()` before `ctx.ui.select()` is awaited at `modules/pi-token-extension.ts:221-224`. In RPC mode, a client can invoke `/token`, enqueue a prompt while the picker is open, then answer the picker. The stale `busy=false` causes immediate re-registration and persistence at `modules/pi-token-extension.ts:195-202`, so already-active or queued work may use the new credential instead of being deferred until `agent_settled`. 6. **[GAP] The process-lifetime command-result cache claim and its witness exercise the wrong Pi API.** `README.md:133-136` and `modules/pi-token-extension.ts:27-30` say Pi caches the resolved bearer for the process lifetime. Actual provider composition calls `resolveConfigValueOrThrow`, which uses the uncached path at `dist/core/resolve-config-value.js:199-207`; Pi’s models documentation also says commands are resolved at request time. The witness instead imports and directly invokes cached `resolveConfigValue` at `checks/pi-provider-lifecycle.nix:256-267` and `492-507`. Rotating the selected token’s volatile file during a Pi process therefore changes the bearer on the next request, contrary to the documented and claimed behavior. 7. **[GAP] Malformed preference state is silently ignored and later overwritten.** `modules/pi-token-extension.ts:35-47` converts every preference read or parse error into `{}`. Consequently `resolveStartup()` reports only well-formed stale token names, not malformed JSON, and `persistSelection()` at `modules/pi-token-extension.ts:68-75` can replace the corrupt file with a single merged-from-empty choice. A truncated preference file can therefore cause silent fallback and later loss of remembered selections for unrelated credentials. 8. **[GAP] A crash can permanently strand the preference lock.** `modules/pi-token-extension.ts:55-66` implements locking with a directory and a fixed retry loop, but has no owner metadata or stale-lock recovery. A SIGKILL after `mkdirSync(lock)` leaves the lock directory indefinitely; subsequent selections activate in memory but fail persistence after five seconds until an operator manually removes it. 9. **[GAP] Non-interactive ambiguity does not surface the selector’s actionable diagnostic through real Pi.** The selector emits the required explanation at `modules/pi-provider-token-select.sh:112-114`, but Pi runs command-backed keys with child stderr ignored at `dist/core/resolve-config-value.js:161-169`, then reports only a generic configuration-resolution failure. `checks/pi-provider-lifecycle.nix:628-641` suppresses Pi output and checks only that no request/state change occurred. A headless ambiguous startup therefore does not tell the operator to use `/token` or configure a default. 10. **[GAP] The agenix assertion does not pin root-owned `0755` intermediate directories.** The generated configuration appears correct today, but `checks/pi-provider-lifecycle.nix:273-287` asserts the leaf path, owner, group, mode, and the presence of `mkdir -p`; it does not assert the activation script’s `umask 0022`, root ownership, or resulting `0755` modes for nested directories. An upstream activation-umask change to `0077` would make the secret unreachable by the configured user while this assertion still passes. 11. **[GAP] The claimed bearer leak coverage omits argv/environment, many scenario homes, and store closures.** `checks/pi-provider-lifecycle.nix:1054-1069` scans selected homes, logs, observations, and top-level generated artifacts. It does not inspect live `/proc/*/cmdline` or `environ`, does not include several legacy/kill/sabotage scenario homes, and does not traverse Nix store closures. A regression placing a synthetic bearer in a child process environment or an omitted scenario directory would pass this scan. Validation: I could not rerun `nix build path:.#checks.x86_64-linux.pi-provider-lifecycle`; the sandbox cannot connect to `/nix/var/nix/daemon-socket/socket` (`Operation not permitted`). Static review confirmed the intended auth/models/link/manifest publication order, pass-12 refuse-by-name convergence logic, real Pi RPC extension execution, and non-vacuous comparator sabotage. The worktree was not modified.
Startup pickers stay detached but are serialized and gated so no request authenticates an unselected credential. auth.json managed-entry deletion is bounded to the legacy transition recorded by the prior manifest; a later operator credential under a managed provider ID refuses by name and is preserved. The missing-secret preflight now covers extension publication, absent recorded legacy paths are rechecked at publication and rollback, and picker selection re-samples idle state through the same pending path as /token. The process-cache claim was false against Pi 0.84.2 provider composition, which resolves commands at request time; the witness now pins request-time rotation with agent-settled registration. Corrupt preference files are reported and preserved, the preference lock recovers from a dead owner, ambiguity diagnostics surface through the Pi UI, the activation assertion pins umask and leaf-only ownership, and the leak scan samples live proc state and the store closure of every generated artifact via closureInfo evaluated outside the sandbox.

Model: gpt-5.6-sol
Author
Member

All eleven findings are addressed in 9ba5883 (authored by gpt-5.6-sol; the closureInfo repair replacing an in-sandbox nix-store query was made by the managing agent). Both confirmed deviations are now implemented safely: the detached startup picker is serialized and gated so no request authenticates an unselected credential, and auth.json ownership is bounded to the legacy transition with a later operator credential under a managed provider ID refusing by name, preserved. The false process-lifetime cache claim is replaced by the measured request-time resolution property with agent-settled registration. The plan was amended accordingly at allod/strategy 130ebcc. Validation by the managing agent from this worktree: nix build path:.#checks.x86_64-linux.pi-provider-lifecycle green, and all fourteen remaining checks built green sequentially (a parallel nix flake check was OOM-killed in this VM; per-check builds all pass).

All eleven findings are addressed in 9ba5883 (authored by gpt-5.6-sol; the closureInfo repair replacing an in-sandbox nix-store query was made by the managing agent). Both confirmed deviations are now implemented safely: the detached startup picker is serialized and gated so no request authenticates an unselected credential, and auth.json ownership is bounded to the legacy transition with a later operator credential under a managed provider ID refusing by name, preserved. The false process-lifetime cache claim is replaced by the measured request-time resolution property with agent-settled registration. The plan was amended accordingly at allod/strategy 130ebcc. Validation by the managing agent from this worktree: nix build path:.#checks.x86_64-linux.pi-provider-lifecycle green, and all fourteen remaining checks built green sequentially (a parallel nix flake check was OOM-killed in this VM; per-check builds all pass).
vnprc approved these changes 2026-08-24 02:42:39 +01:00
vnprc merged commit f92fa59d84 into master 2026-08-24 02:42:44 +01:00
vnprc deleted branch agent/named-tokens-runtime 2026-08-24 02:42:45 +01:00
Sign in to join this conversation.
No description provided.