From 9436c308c339673096291d4e458a30bbd9f72a56 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 20:29:21 +0200 Subject: [PATCH 01/26] docs(issue-62): assess AuthStorage removal in Pi 0.80.8 --- ASSESSMENT-issue-62.md | 391 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 ASSESSMENT-issue-62.md diff --git a/ASSESSMENT-issue-62.md b/ASSESSMENT-issue-62.md new file mode 100644 index 0000000..d2958c1 --- /dev/null +++ b/ASSESSMENT-issue-62.md @@ -0,0 +1,391 @@ +# Assessment — Issue #62: `AuthStorage` export removed in Pi 0.80.8 + +## 1. Summary + +Pi Web's session daemon crashes at ESM module initialization after +`@earendil-works/pi-coding-agent` is resolved at **0.80.8 or later**: + +``` +SyntaxError: The requested module '@earendil-works/pi-coding-agent' +does not provide an export named 'AuthStorage' +``` + +The crash is a hard, load-time failure (a static `import { AuthStorage } ...` +that no longer resolves), so Pi Web is completely unusable with any Pi in the +0.80.8+ line. The permissive peer/dev range `>=0.80.0 <1` lets npm resolve the +incompatible release. + +**Root cause:** Pi 0.80.8 is a **major architectural refactor** of model/auth +plumbing ("Unified model runtime and provider authentication"), explicitly +listed under **Breaking Changes** in the upstream CHANGELOG. `AuthStorage` (and +its storage backends `FileAuthStorageBackend`, `InMemoryAuthStorageBackend`, +and the credential type exports) were **removed from the package's public +exports**. The class still exists internally but is no longer exported; the new +public surface is `ModelRuntime` (async) plus a synchronous compatibility +`ModelRegistry` facade with a different shape, and `readStoredCredential()` for +one-off reads. + +**Recommendation (see §5):** Do **not** attempt a dual-API compatibility shim. +The change is a deep semantic refactor (sync → async, credential store contract +change, removal of `authStorage` from services, `ModelRegistry` constructor and +method-signature changes). A clean migration to the `ModelRuntime` API, +combined with pinning the supported Pi range to `>=0.80.8 <0.81`, is the correct +fix and warrants a Pi Web version bump via a changeset. + +--- + +## 2. Where and how `AuthStorage` / `ModelRegistry` are used in `src/` + +All usage is under `src/server/sessions/`. Production files (3) and test/support +files (5). + +### Production code + +**`authService.ts`** — the central auth wiring. +- `import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"`. +- `type ModelRegistryInstance = ReturnType`. +- `createModelRegistryForAgentDir(agentDir)`: + `AuthStorage.create(join(agentDir, "auth.json"))` then + `ModelRegistry.create(authStorage, join(agentDir, "models.json"))`. +- Constructor fallback: `ModelRegistry.create(AuthStorage.create())`. +- Reads/writes credentials through `this.modelRegistry.authStorage`: + - `.set(providerId, { type: "api_key", key })` (saveApiKey) + - `.logout(providerId)` (logoutProvider) + - `.reload()` (refreshAuthState) + - passes `this.modelRegistry.authStorage` into the OAuth login flow. +- Uses `this.modelRegistry.refresh()` (currently synchronous `void`). + +**`oauthLoginFlowService.ts`** — OAuth login orchestration for the web UI. +- `import type { AuthStorage } from "@earendil-works/pi-coding-agent"`. +- `type OAuthLoginStorage = Pick`. +- Calls `authStorage.login(providerId, callbacks)` where `callbacks` is the + old `OAuthLoginCallbacks` shape: `signal`, `onAuth`, `onDeviceCode`, + `onPrompt`, `onManualCodeInput`, `onSelect`, `onProgress`. + +**`piSessionService.ts`** — session runtime factory + warnings. +- `import { AuthStorage, ..., ModelRegistry, ... }`. +- `type ModelRegistryInstance = ReturnType`. +- `createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry, ...)` + calls `createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry })`. +- Uses `createModelRegistryForAgentDir` fallback; passes + `this.modelRegistry.authStorage` and `this.modelRegistry` into the runtime + factory (around lines 605–612). +- `anthropicSubscriptionWarning()` reads + `session.modelRegistry.authStorage.get("anthropic")` and inspects + `credential.type` / `credential.key`. +- `PiAgentSession.modelRegistry: ModelRegistryInstance` is part of the internal + session interface. + +**`authProviderOptions.ts`** — provider enumeration (no direct SDK import; uses a +structural `AuthProviderModelRegistry` interface). Depends on the current +`ModelRegistry`/`AuthStorage` shape: +- `modelRegistry.authStorage.getOAuthProviders()` → `{ id, name }[]` +- `modelRegistry.authStorage.list()` → `string[]` +- `modelRegistry.authStorage.get(provider)` → `{ type } | undefined` +- `modelRegistry.getAll()` → `{ provider }[]` +- `modelRegistry.getProviderDisplayName(provider)` +- `modelRegistry.getProviderAuthStatus(provider)` + +### Test / support code +- `authService.test.ts` — `AuthStorage.inMemory(...)`, `ModelRegistry.create(...)`, + asserts `startOptions.authStorage`. +- `piSessionService.testSupport.ts` — `ModelRegistry.inMemory(AuthStorage.inMemory())`, + `ModelRegistry.create(AuthStorage.inMemory())`. +- `piSessionService.promptQueue.test.ts` — `AuthStorage.inMemory({ anthropic: {...} })`, + `ModelRegistry.inMemory(authStorage)`. +- `piSessionService.warnings.test.ts` — `AuthStorage.inMemory()`, + `ModelRegistry.inMemory/create`, builds anthropic credentials via + `authStorage.set(...)`. +- `oauthLoginFlowService.test.ts` — `Pick` fake. +- `authProviderOptions.test.ts` — structural `AuthProviderModelRegistry` fake + (no SDK import; must track whatever `authProviderOptions.ts` requires). + +### Other pi-coding-agent imports (unaffected — still exported in 0.80.8) +`DefaultPackageManager`, `SettingsManager` (piPackageService, piWebPluginService, +piWebStatus), `createAgentSessionServices`, `createAgentSessionFromServices`, +`createAgentSessionRuntime`, `AgentSessionRuntimeDiagnostic`, `ResourceDiagnostic`. +These remain present; only the auth/model-registry construction path is broken. + +--- + +## 3. What Pi 0.80.8 actually changed (verified against real tarballs) + +Method: downloaded and extracted the real npm tarballs for +`@earendil-works/pi-coding-agent` 0.80.7, 0.80.8, 0.80.10 and +`@earendil-works/pi-ai` 0.80.7, 0.80.8 (into `/srv/dev/pi-inspect`) and diffed +the `.d.ts` surface. (Local `node_modules` was not installed in this worktree; +the last globally installed copy elsewhere is 0.80.6.) + +### 3.1 Public export diff — `pi-coding-agent` index.d.ts (0.80.7 → 0.80.8) + +Removed: +``` +export { type ApiKeyCredential, type AuthCredential, type AuthStatus, + AuthStorage, type AuthStorageBackend, FileAuthStorageBackend, + InMemoryAuthStorageBackend, type OAuthCredential } from "./core/auth-storage.ts"; +``` +Added: +``` +export { readStoredCredential } from "./core/auth-storage.ts"; +export { type CreateModelRuntimeOptions, ModelRuntime, + type ModelRuntimeAuthOverrides } from "./core/model-runtime.ts"; +``` +`ModelRegistry` is still exported, but its class shape changed (see §3.3). +0.80.10 (current `latest`) is **byte-identical** to 0.80.8 for `index.d.ts`, +`auth-storage.d.ts`, and `model-runtime.d.ts` — the new surface is stable. + +### 3.2 Upstream CHANGELOG (0.80.8) — Breaking Changes (verbatim highlights) + +- "Replaced the SDK's `CreateAgentSessionOptions.authStorage` and + `modelRegistry` options with the async `modelRuntime` option. `AuthStorage` + and its storage backends are no longer exported; use `ModelRuntime` (or a + custom pi-ai `CredentialStore`), or `readStoredCredential()` for one-off + reads of auth.json." +- "Replaced SDK request-auth assembly through + `ModelRegistry.getApiKeyAndHeaders()` with `ModelRuntime.getAuth()`." +- "Changed extension-facing `ModelRegistry.refresh()` from synchronous `void` + to `Promise` because `models.json` loading is asynchronous. Extensions + must await it before making synchronous registry reads." +- "Moved canonical dynamic catalog refresh to async `ModelRuntime.refresh()`." + +### 3.3 The new API shape + +**`ModelRuntime`** (`core/model-runtime.d.ts`, new) — the canonical async facade: +- `static create(options?: CreateModelRuntimeOptions): Promise` + where options include `credentials?: CredentialStore`, `authPath?`, + `modelsPath?`, `modelsStore?`, `allowModelNetwork?`, etc. +- Provider/model reads: `getProviders()`, `getProvider(id)`, `getModels()`, + `getModel()`, `getAvailable()` (async) / `getAvailableSnapshot()` (sync). +- Auth: `getAuth(providerId|model, overrides?)`, `checkAuth(providerId)`, + `hasConfiguredAuth(providerId)`, `isUsingOAuth(providerId)`, + `getProviderAuthStatus(providerId)`, `listCredentials()`, + `setRuntimeApiKey`, `removeRuntimeApiKey`. +- Login/logout: `login(providerId, type, interaction): Promise`, + `logout(providerId): Promise`. +- `refresh(): Promise<...>`, `registerProvider`/`unregisterProvider`. +- Implements pi-ai `Models`. + +**`ModelRegistry`** (`core/model-registry.d.ts`, changed) — now a thin sync +compatibility facade **for extensions**, constructed from a `ModelRuntime`: +- `constructor(runtime: ModelRuntime)` — **no more `ModelRegistry.create(authStorage, ...)` + and no more `ModelRegistry.inMemory(...)`**. +- **No `authStorage` property.** (This breaks `authProviderOptions.ts`, + `authService.ts`, and `anthropicSubscriptionWarning`.) +- `refresh(): Promise` (was sync `void`). +- Keeps `getAll`, `getAvailable`, `find`, `getProviderAuthStatus`, + `getProviderDisplayName`, `getApiKeyForProvider`, `isUsingOAuth`, + `hasConfiguredAuth`, `getApiKeyAndHeaders`, `registerProvider`, etc. +- **Dropped:** the whole `authStorage`-centric credential API + (`get/set/list/logout/reload/getOAuthProviders`). + +**`AuthStorage`** (`core/auth-storage.d.ts`, still exists internally, NOT +exported): now `implements CredentialStore` with an entirely different, +**async** method set — `read()`, `modify()`, `delete()`, `list()` returning +`Promise`s of pi-ai `Credential`/`CredentialInfo`. The old +`get/set/remove/has/login/logout/getApiKey/getOAuthProviders/setRuntimeApiKey` +synchronous methods are gone. `static create/inMemory/fromStorage` remain but +the class is unexported. + +**`readStoredCredential(providerId, authPath?)`** — new synchronous one-off read +returning a pi-ai `Credential | undefined` (`{ type: "api_key", key?, env? }` or +`{ type: "oauth", ... }`). Useful for `anthropicSubscriptionWarning`. + +**pi-ai 0.80.8 auth model** (`@earendil-works/pi-ai`, `auth/types.d.ts`, +`auth/credential-store.d.ts`): +- `CredentialStore` interface: `read`, `list`, `modify`, `delete` — all async. +- `Credential = ApiKeyCredential | OAuthCredential`; `CredentialInfo`. +- `InMemoryCredentialStore` class exported — the test seam that replaces + `AuthStorage.inMemory(...)`. +- `AuthInteraction` interface replaces the old `OAuthLoginCallbacks`: + `{ signal?, prompt(prompt: AuthPrompt): Promise, notify(event: AuthEvent): void }`. + `AuthPrompt` is a discriminated union (`text`/`secret`/`select`/`manual_code`); + `AuthEvent` is `info`/`auth_url`/`device_code`/`progress`. This is a **complete + reshaping** of the OAuth login callback contract used by + `oauthLoginFlowService.ts`. +- `login(providerId, type, interaction)` now lives on `ModelRuntime`, not on a + credential store, and returns a `Credential`. +- `Provider` objects (`getProviders()`) carry `{ id, name, auth: { apiKey?, oauth? } }` + — this is the new source of truth for enumerating login providers, replacing + `authStorage.getOAuthProviders()`. + +### 3.4 Session services wiring change + +`createAgentSessionServices` options and `AgentSessionServices`: +- 0.80.7: `{ cwd, agentDir?, authStorage?, settingsManager?, modelRegistry?, ... }` + → services expose `authStorage` + `modelRegistry`. +- 0.80.8: `{ cwd, agentDir?, settingsManager?, modelRuntime?, ... }` + → services expose `modelRuntime` (no `authStorage`, no `modelRegistry`). + +So `piSessionService.ts`'s `createDefaultRuntimeFactory` must pass `modelRuntime` +instead of `authStorage` + `modelRegistry`. + +--- + +## 4. Backwards-compatibility analysis (0.80.0–0.80.7 vs 0.80.8+) + +A shim would need to bridge, simultaneously: + +1. **Construction:** `ModelRegistry.create(authStorage, modelsPath)` / + `ModelRegistry.inMemory(authStorage)` (old) vs + `await ModelRuntime.create({ credentials, authPath, modelsPath })` then + `new ModelRegistry(runtime)` (new). Old is sync; new is async. This alone + forces `AuthService` / `PiSessionService` construction to become async or to + pre-resolve a runtime, changing call sites either way. +2. **Credential access:** synchronous `authStorage.get/set/list/logout/reload/ + getOAuthProviders` (old) vs async `CredentialStore.read/modify/delete/list` + + `ModelRuntime.getProviders()/login/logout/getProviderAuthStatus` (new). + Sync→async cannot be shimmed transparently. +3. **OAuth login:** `authStorage.login(providerId, OAuthLoginCallbacks)` (old, + rich callback object) vs `modelRuntime.login(providerId, type, + AuthInteraction)` (new, `prompt`/`notify` contract). The + `oauthLoginFlowService` maps SDK callbacks onto web-UI flow state; the two + callback contracts are structurally different and would each need a distinct + adapter. +4. **`refresh()`** sync vs async. +5. **Provider enumeration** (`authProviderOptions.ts`) built on + `authStorage.getOAuthProviders()/list()/get()` — none of which exist in the + new surface; must be rederived from `getProviders()` + `listCredentials()`. + +A dual shim would therefore reimplement two full auth stacks behind a lowest- +common-denominator async interface, plus runtime detection of which export +exists — high complexity, high risk, and permanently carrying dead code for the +already-broken 0.80.0–0.80.7 line. This fails the "easy/clean" bar in the task. + +**Conclusion:** backwards compatibility with 0.80.0–0.80.7 is **not easy** and +not worth it. Pi Web should target the new (0.80.8+) API and drop support for +0.80.0–0.80.7. + +--- + +## 5. Recommendation + +**Clean migration to the `ModelRuntime` API + range correction + version bump.** + +Rationale: +- 0.80.8 is an explicit upstream breaking change; the export removal is + intentional and permanent (confirmed identical in 0.80.10 `latest`). +- The old 0.80.0–0.80.7 surface and the new 0.80.8+ surface differ across + construction, sync/async, credential access, OAuth login, and session + services — there is no small adapter that spans both cleanly. +- Pinning down to a still-working old version is a dead end: users installing + Pi Web get whatever Pi they have, and `latest` is already 0.80.10. + +### Concrete migration shape (to be executed by the relay, not now) + +1. **`authService.ts`**: hold a `ModelRuntime` (created via + `ModelRuntime.create({ authPath, modelsPath })`), optionally expose a + `ModelRegistry` wrapper for extension-facing reads. Replace credential + operations: + - `saveApiKey` → `runtime` credential `modify(providerId, async () => ({ type:"api_key", key }))` + (via the runtime's credential store / `setRuntimeApiKey` is for ephemeral; + persistence uses the `CredentialStore.modify` path). + - `logoutProvider` → `runtime.logout(providerId)`. + - `startOAuthLogin` → `runtime.login(providerId, "oauth", interaction)`. + - refresh → `await runtime.refresh()`. + - Construction becomes async (factory function returning a Promise, or an + `init()` step) — propagate to `sessiond.ts`. +2. **`authProviderOptions.ts`**: rederive login/logout options from + `runtime.getProviders()` (auth.apiKey / auth.oauth presence + names) and + `runtime.listCredentials()` / `getProviderAuthStatus()`. Update the + structural `AuthProviderModelRegistry`/`AuthProviderRuntime` interface and + its test double. +3. **`oauthLoginFlowService.ts`**: reimplement against `AuthInteraction` + (`prompt(AuthPrompt)` + `notify(AuthEvent)`) instead of `OAuthLoginCallbacks`. + Map `AuthPrompt` kinds (`text`/`secret`/`manual_code`/`select`) to the web + UI prompt/select shapes, and `AuthEvent` (`auth_url`/`device_code`/`progress`) + to the existing flow-state fields. This is the largest single slice. +4. **`piSessionService.ts`**: + - `createDefaultRuntimeFactory` passes `modelRuntime` to + `createAgentSessionServices` instead of `authStorage` + `modelRegistry`. + - `PiAgentSession` internal type: carry `modelRuntime` (or an adapted + registry) instead of the old `modelRegistry.authStorage`. + - `anthropicSubscriptionWarning`: replace + `modelRegistry.authStorage.get("anthropic")` with + `readStoredCredential("anthropic", authPath)` (sync, no `authStorage` + needed) — cleanest fit for this synchronous check. +5. **`sessiond.ts`**: adapt to async auth construction (create the runtime, + `await` init, then pass into `PiSessionService`). **This is session-daemon + code → requires a manual `pi-web-web-sessiond.service` restart after the fix + lands.** +6. **Tests / testSupport**: replace `AuthStorage.inMemory(...)` with pi-ai + `InMemoryCredentialStore` (+ `await ModelRuntime.create({ credentials })`), + and `ModelRegistry.create/inMemory(...)` accordingly. Update + `authService.test.ts`, `piSessionService.testSupport.ts`, + `piSessionService.promptQueue.test.ts`, `piSessionService.warnings.test.ts`, + `oauthLoginFlowService.test.ts`, `authProviderOptions.test.ts`. Follow the + testing-guide skill (esp. async construction, no over-mocking of SDK). + +### Dependency range correction (§6) + +- Change the three `@earendil-works/*` **peerDependencies** from + `>=0.80.0 <1` to a range that excludes the unsupported line, e.g. + `>=0.80.8 <0.81` (matching the current published minor). Keep the three + `devDependencies` on a matching `^0.80.8` (or exact `0.80.8`/`0.80.10`). +- `@earendil-works/pi-agent-core` and `@earendil-works/pi-ai` are siblings + released in lockstep with `pi-coding-agent` (coding-agent depends on + `^0.80.x` of both); correct all three ranges together. +- Rationale for the upper bound `<0.81`: the auth refactor shows this line makes + breaking changes within `0.80.x` patch releases, so a permissive `<1` is + unsafe. Pin to the known-good minor window and widen deliberately after + testing new releases. + +### Release / changeset + +- Add a **patch** (or minor, maintainer's call) `.changeset/*.md` for + `@jmfederico/pi-web` describing the user-visible fix: "Fix session daemon + crash with Pi 0.80.8+ by migrating to the new `ModelRuntime` API; require Pi + `>=0.80.8`." Do **not** edit `CHANGELOG.md` directly (Changesets generates it). +- Actual npm publish is out of scope for the fix branch; the release skill + (`npm-release-via-github-actions`) is only referenced so the changeset is + release-ready. + +--- + +## 6. Dependency range facts (current state) + +`package.json`: +``` +devDependencies: + "@earendil-works/pi-agent-core": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-coding-agent": "^0.80.6", +peerDependencies: + "@earendil-works/pi-agent-core": ">=0.80.0 <1", + "@earendil-works/pi-ai": ">=0.80.0 <1", + "@earendil-works/pi-coding-agent": ">=0.80.0 <1", +``` +No `dependencies`/`optionalDependencies` entries for these packages. The +permissive peer range `>=0.80.0 <1` is what lets consumers' npm resolve the +breaking 0.80.8/0.80.9/0.80.10 against a Pi Web build that expects the old +export. + +Published versions (npm): 0.79.10, 0.80.1, 0.80.2, 0.80.3, 0.80.5, 0.80.6, +0.80.7, 0.80.8, 0.80.9, 0.80.10. `latest` = 0.80.10. The removal landed in +0.80.8 and persists through 0.80.10. + +--- + +## 7. Verification artifacts + +- Extracted SDK tarballs for inspection: `/srv/dev/pi-inspect/` (v0.80.7, + v0.80.8, v0.80.10 of pi-coding-agent; pi-ai0807, pi-ai0808). These are + scratch/inspection only and outside the repo. +- Key diffs reproduced in §3.1 (index exports), §3.3 (class shapes), §3.4 + (session services). 0.80.8 vs 0.80.10 `.d.ts` are identical for the affected + files → the target API is stable. + +## 8. Risks / call-outs for the fix + +- **Session daemon restart required:** changes touch `sessiond.ts` and the + session runtime path; a manual restart of the sessiond service is needed after + the fix (per AGENTS.md). +- **Async construction ripple:** moving from sync `AuthStorage/ModelRegistry` + construction to `await ModelRuntime.create(...)` changes `AuthService` / + `PiSessionService` init and their call sites; keep the async boundary + explicit and injected (code-quality-architecture skill). +- **OAuth flow contract change is the riskiest slice** — the web UI prompt/ + select/device-code mapping must be re-verified end to end. +- **No local `node_modules`** in this worktree; the relay's first implementation + leg must `npm install` (pin to 0.80.8+) before it can typecheck/test. Note the + `/tmp` quota issue observed during assessment — install in the worktree, not + `/tmp`. From 8225cf6fcd387aaf068af4b17cde093c8a13febe Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 20:29:21 +0200 Subject: [PATCH 02/26] docs(issue-62): add relay plan for AuthStorage->ModelRuntime migration --- relays/issue-62-authstorage/charter.md | 127 +++++++++++++++++++++++++ relays/issue-62-authstorage/log.md | 47 +++++++++ relays/issue-62-authstorage/status.md | 61 ++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 relays/issue-62-authstorage/charter.md create mode 100644 relays/issue-62-authstorage/log.md create mode 100644 relays/issue-62-authstorage/status.md diff --git a/relays/issue-62-authstorage/charter.md b/relays/issue-62-authstorage/charter.md new file mode 100644 index 0000000..961a1f1 --- /dev/null +++ b/relays/issue-62-authstorage/charter.md @@ -0,0 +1,127 @@ +# Relay charter — issue-62-authstorage + +## Relay identity +- **Name:** `issue-62-authstorage` +- **Root path:** `relays/issue-62-authstorage/` (in the repo, on branch + `fix/issue-62-authstorage`, worktree `/srv/dev/pi-web-issue-62`) +- **Packet files:** `charter.md`, `status.md`, `log.md` (this directory). + +## Background (read once, do not re-derive) +The full technical assessment lives at the worktree root: +`ASSESSMENT-issue-62.md`. Read it once at the start of your leg for the API +migration details; do not re-investigate the SDK from scratch. Short version: +Pi `@earendil-works/pi-coding-agent` 0.80.8 removed the `AuthStorage` export and +replaced the auth/model plumbing with an async `ModelRuntime` + pi-ai +`CredentialStore` model. Pi Web imports `AuthStorage` statically and crashes at +module load with any Pi 0.80.8+. The agreed fix is a **clean migration to the +new `ModelRuntime` API** (no dual-version shim) plus dependency-range +correction, tests, and a changeset. Rationale and the per-file migration shape +are in `ASSESSMENT-issue-62.md` §5. + +## Goal / finish line +Pi Web builds, typechecks, lints, and passes its full test suite against Pi +`@earendil-works/pi-coding-agent` **0.80.8+** (target the installed 0.80.8/0.80.10 +line), with: +1. No remaining import or use of the removed `AuthStorage` export (and no + reliance on `ModelRegistry.create(authStorage)` / `.inMemory(authStorage)` / + `modelRegistry.authStorage`). +2. Auth, OAuth login, API-key save/logout, provider enumeration, and the + Anthropic subscription warning all working through the new `ModelRuntime` / + `readStoredCredential` / pi-ai `CredentialStore` APIs. +3. `package.json` peerDependencies and devDependencies for the three + `@earendil-works/*` packages corrected so npm cannot resolve an unsupported + release (target range `>=0.80.8 <0.81`; devDeps on a matching `^0.80.8`). +4. A `.changeset/*.md` fragment describing the user-visible fix (no direct + `CHANGELOG.md` edit). +5. `npm run verify` (typecheck + lint + knip + test) passing. + +Finish line reached = all of the above true and committed on the branch. **Do +not open a PR** (out of scope for this relay). + +## Sizing — one leg +One leg = **one coherent slice** from the plan below that leaves the tree in a +committed, describable state. Prefer the pre-broken-out slices in `status.md`. +A leg does not have to leave `npm run verify` fully green (the migration is +interdependent), but it MUST: +- leave a clear, honest `status.md` describing what compiles/what doesn't yet, +- commit its work with a clear message, +- not expand scope beyond its slice ("just a bit more" is the main failure mode + here — the auth surfaces are interconnected; resist rewriting everything in + one leg). + +If a slice turns out bigger than expected, split it and hand off mid-plan with +an updated `status.md` — that is expected and fine. + +## Suggested slice breakdown (task selection default) +Follow `status.md`'s named next task. If none is named, pick the lowest-numbered +incomplete slice here: + +0. **Bootstrap:** `npm install` in the worktree pinning the three `@earendil-works/*` + packages to 0.80.8+ (e.g. `npm i -D @earendil-works/pi-coding-agent@0.80.8 + @earendil-works/pi-ai@0.80.8 @earendil-works/pi-agent-core@0.80.8`), and + correct the peerDependencies range to `>=0.80.8 <0.81`. Confirm the crash + reproduces / the new exports resolve. Commit. (Install in the worktree, NOT + `/tmp` — `/tmp` has a disk quota problem, see assessment §8.) +1. **`authService.ts` core migration:** move to `ModelRuntime` (async + construction via `ModelRuntime.create({ authPath, modelsPath })`), migrate + saveApiKey/logout/refresh/credential access. Propagate async construction to + `sessiond.ts`. (Session-daemon path — see restart note.) +2. **`authProviderOptions.ts` migration:** rederive login/logout provider + options from `runtime.getProviders()` + `listCredentials()` / + `getProviderAuthStatus()`; update its structural interface + test double. +3. **`oauthLoginFlowService.ts` migration:** reimplement against pi-ai + `AuthInteraction` (`prompt`/`notify`) instead of `OAuthLoginCallbacks`; wire + `runtime.login(providerId, "oauth", interaction)`. (Riskiest slice — verify + prompt/select/device-code/auth_url mapping.) +4. **`piSessionService.ts` migration:** pass `modelRuntime` to + `createAgentSessionServices`; update `PiAgentSession` type; switch + `anthropicSubscriptionWarning` to `readStoredCredential`. +5. **Tests + testSupport:** migrate all test doubles to `InMemoryCredentialStore` + + `ModelRuntime.create`; get `npm run verify` green. Follow the testing-guide + skill. +6. **Changeset + final verify + cleanup:** add `.changeset/*.md`; run full + `npm run verify`; remove scratch (`ASSESSMENT` stays, `/srv/dev/pi-inspect` + is outside the repo). Confirm goal, hand off to a final confirmation/stop. + +Slices may merge or split. 1–4 depend on 0. Slice 5 finalizes; slice 6 closes. + +## Handover +When handing off, `spawn_session` **once** with a prompt whose first line is: +`Relay "issue-62-authstorage" leg begins now.` followed by the standard +Relay handoff body pointing at: +- `relays/issue-62-authstorage/charter.md` +- `relays/issue-62-authstorage/status.md` + +Tell the next runner not to read `log.md` end-to-end. Make all work durable +(update `status.md`, append `log.md`, commit) **before** spawning. + +## Intervention signal — stop and get the human when: +- The new SDK API does not actually provide an operation the migration needs + (e.g. no viable credential persistence path for API-key save), i.e. the + assessment's assumed mapping is wrong. +- A slice would require changing the charter's goal or the agreed "clean + migration, no shim" decision. +- `npm install` / registry access fails and cannot be resolved in-leg. +- Charter churn: if you find yourself needing to edit this charter to proceed, + stop and involve the human instead. +To raise it: set a clear `## BLOCKED` section at the top of `status.md`, append a +`log.md` entry explaining the blocker and what decision is needed, do **not** +spawn the next leg, and end your run. + +## Reading discipline +Read, in order: this `charter.md`, then `status.md`, then `ASSESSMENT-issue-62.md` +(once), then only the specific `src/server/sessions/*` files your slice touches. +Do **not** read `log.md` end-to-end — only targeted entries if `status.md` points +you there. Do not re-extract SDK tarballs unless the assessment is contradicted +by reality. + +## Standing constraints (project conventions) +- Session-daemon changes (`sessiond.ts`, session runtime / auth construction + loaded by the daemon) require the human to **manually restart the sessiond + service** to take effect. Call this out in `status.md`/handoff whenever a leg + changes that path so the human knows a restart is pending. +- Follow the skills: `code-quality-architecture` (DI, async boundaries, + testable seams), `testing-guide` (test layers, no over-mocking), and + `changeset-changelog` (changeset not CHANGELOG edit). +- Keep changes scoped to the fix; do not opportunistically refactor unrelated + code. diff --git a/relays/issue-62-authstorage/log.md b/relays/issue-62-authstorage/log.md new file mode 100644 index 0000000..f65bfee --- /dev/null +++ b/relays/issue-62-authstorage/log.md @@ -0,0 +1,47 @@ +# Relay log — issue-62-authstorage + +Append-only. One concise entry per leg. Do not read end-to-end for orientation; +use `status.md`. Targeted lookups only. + +--- + +## Leg 0 — Planning (assessment + relay packet) + +**Did:** +- Read issue #62 and confirmed the crash: static `import { AuthStorage }` fails + at ESM load with Pi 0.80.8+. +- Investigated all `AuthStorage`/`ModelRegistry` usage in `src/` (3 production + files + 5 test/support files under `src/server/sessions/`; other + pi-coding-agent imports unaffected). +- Downloaded and diffed real npm tarballs (pi-coding-agent 0.80.7/0.80.8/0.80.10 + and pi-ai 0.80.7/0.80.8) into `/srv/dev/pi-inspect` (scratch, outside repo) to + establish the exact new export surface: `AuthStorage` and its backends removed + from exports; new `ModelRuntime` (async) + `readStoredCredential`; changed + `ModelRegistry` (constructed from a runtime, `refresh()` now async, no + `authStorage`); pi-ai `CredentialStore`/`InMemoryCredentialStore`/ + `AuthInteraction` model. Confirmed 0.80.8 and 0.80.10 `.d.ts` are identical + for the affected files (stable target). +- Wrote `ASSESSMENT-issue-62.md` (root). + +**Decisions:** +- **Clean migration to `ModelRuntime`, no dual-version compat shim.** Rationale: + sync→async, credential-store contract change, OAuth callback contract change, + and session-services option change span both surfaces with no small clean + adapter; 0.80.0–0.80.7 is already broken/superseded (`latest` = 0.80.10). +- **Dep range fix:** peerDeps `>=0.80.0 <1` → `>=0.80.8 <0.81` for all three + `@earendil-works/*` packages; upper bound `<0.81` because this line ships + breaking changes within `0.80.x`. +- Relay packet placed under `relays/issue-62-authstorage/` (committed; not in + `package.json` `files`, so not published; `.pi-web/` is gitignored so not used). + +**Artifacts changed:** `ASSESSMENT-issue-62.md`; +`relays/issue-62-authstorage/{charter,status,log}.md`. + +**Status update:** last completed leg 0, next leg 1 = charter slice 0 +(Bootstrap: install Pi 0.80.8+, correct dep ranges). + +**Blockers:** none. Noted `/tmp` disk-quota issue (install in worktree) and the +pending sessiond restart for later daemon-path slices. + +**Handoff:** Planning only — NOT auto-spawning the first implementation leg. +Assessment + relay plan are laid out ready to be kicked off by the user. diff --git a/relays/issue-62-authstorage/status.md b/relays/issue-62-authstorage/status.md new file mode 100644 index 0000000..4cc88d7 --- /dev/null +++ b/relays/issue-62-authstorage/status.md @@ -0,0 +1,61 @@ +# Relay status — issue-62-authstorage + +## Current position +Relay planned and packet created. Assessment complete and committed +(`ASSESSMENT-issue-62.md`). No fix work has started. The worktree has **no +`node_modules` installed yet** — the first implementation leg must install deps +(pinned to Pi 0.80.8+) before anything typechecks. + +## Leg tracking +- **Last completed leg:** 0 (planning — this packet + assessment). +- **Next leg to run:** 1. + +## Next task +Run **charter slice 0 (Bootstrap)** as leg 1: +1. In the worktree (`/srv/dev/pi-web-issue-62`), install deps pinning the three + `@earendil-works/*` packages to 0.80.8+ (0.80.8 or current 0.80.10): + `@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, + `@earendil-works/pi-agent-core`. Install in the worktree, **not `/tmp`** + (`/tmp` has a disk-quota problem; see assessment §8). +2. Correct `package.json`: peerDependencies for the three packages + `>=0.80.0 <1` → `>=0.80.8 <0.81`; devDependencies `^0.80.6` → `^0.80.8`. +3. Confirm the new export surface resolves (`readStoredCredential`, + `ModelRuntime` present; `AuthStorage` gone) — e.g. a quick node/tsx check or + just observe the typecheck errors now point at the migration sites. +4. Commit (e.g. `chore(deps): require pi 0.80.8+ and correct dep ranges (issue #62)`). +5. Update this `status.md` + append `log.md`, then hand off to leg 2 (charter + slice 1: `authService.ts` core migration). + +If slice 0 is already done when you arrive, apply the charter's task-selection +policy: pick the lowest-numbered incomplete slice (1 → 6). + +## Relevant context for the next runner +- **Plan of record:** `ASSESSMENT-issue-62.md` (root) — read once. §5 has the + per-file migration shape; §3 has the exact new API shapes; §6 the dep ranges. +- **Files to change** (all under `src/server/sessions/` unless noted): + `authService.ts`, `authProviderOptions.ts`, `oauthLoginFlowService.ts`, + `piSessionService.ts`, plus `src/server/sessiond.ts` (async auth + construction), and the test/support files listed in assessment §2. +- **New API cheat-sheet:** `ModelRuntime.create({ authPath, modelsPath, + credentials? }): Promise`; credential persistence via the + pi-ai `CredentialStore.modify` path; `runtime.login(providerId, type, + AuthInteraction)`; `runtime.logout`; `runtime.getProviders()` / + `listCredentials()` / `getProviderAuthStatus()`; `readStoredCredential( + providerId, authPath?)` for the sync anthropic warning; pi-ai + `InMemoryCredentialStore` for tests. +- **Decision already made:** clean migration, **no dual-version compat shim** + (assessment §4/§5). Do not reopen this without the intervention signal. + +## Progress documentation expectations +Every leg: update this `status.md` (current position, leg tracking, next task, +context, blockers), append a concise `log.md` entry, make work durable, and +commit before handing off. Hand off with `spawn_session` **once** per the +charter's Handover section. + +## Blockers / intervention state +None currently. Known constraints: +- **Sessiond restart pending** once slices touching `sessiond.ts` / session + runtime land — the human must manually restart the sessiond service; note it + here when it applies. +- `/tmp` disk-quota issue observed during assessment — do dependency installs in + the worktree. From 0fa9d0e4d222a840324b7e81c5c8b9ca2dbeccb7 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 20:35:16 +0200 Subject: [PATCH 03/26] chore(deps): require pi 0.80.8+ and correct dep ranges (issue #62) Bump the three @earendil-works/* devDependencies to ^0.80.8 and tighten peerDependencies from '>=0.80.0 <1' to '>=0.80.8 <0.81' so npm cannot resolve the pre-0.80.8 line that still expected the removed AuthStorage export. Installed lockfile pins 0.80.10. The new export surface (ModelRuntime, readStoredCredential, InMemoryCredentialStore) now resolves; remaining typecheck errors point at the auth/model-registry migration sites under src/server/sessions/ and are addressed in the following relay legs. Refs #62 --- package-lock.json | 410 ++++++++++++++++++++++++---------------------- package.json | 12 +- 2 files changed, 220 insertions(+), 202 deletions(-) diff --git a/package-lock.json b/package-lock.json index 446d3e8..edd9889 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,9 +42,9 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-coding-agent": "^0.80.6", + "@earendil-works/pi-agent-core": "^0.80.8", + "@earendil-works/pi-ai": "^0.80.8", + "@earendil-works/pi-coding-agent": "^0.80.8", "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "@types/ws": "^8.18.1", @@ -61,9 +61,9 @@ "node": ">=22" }, "peerDependencies": { - "@earendil-works/pi-agent-core": ">=0.80.0 <1", - "@earendil-works/pi-ai": ">=0.80.0 <1", - "@earendil-works/pi-coding-agent": ">=0.80.0 <1" + "@earendil-works/pi-agent-core": ">=0.80.8 <0.81", + "@earendil-works/pi-ai": ">=0.80.8 <0.81", + "@earendil-works/pi-coding-agent": ">=0.80.8 <0.81" } }, "node_modules/@anthropic-ai/sdk": { @@ -167,18 +167,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.975.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz", - "integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==", + "version": "3.975.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", + "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.0", - "@aws-sdk/xml-builder": "^3.972.34", + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.36", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.2", - "@smithy/signature-v4": "^5.6.3", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.4", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -187,16 +187,16 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.57", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz", - "integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==", + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz", + "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -204,18 +204,18 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.59", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz", - "integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==", + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz", + "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/fetch-http-handler": "^5.6.4", - "@smithy/node-http-handler": "^4.9.4", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -223,14 +223,14 @@ } }, "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz", - "integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==", + "version": "4.9.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", + "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -238,24 +238,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz", - "integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==", + "version": "3.973.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.4.tgz", + "integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/credential-provider-env": "^3.972.57", - "@aws-sdk/credential-provider-http": "^3.972.59", - "@aws-sdk/credential-provider-login": "^3.972.63", - "@aws-sdk/credential-provider-process": "^3.972.57", - "@aws-sdk/credential-provider-sso": "^3.973.1", - "@aws-sdk/credential-provider-web-identity": "^3.972.63", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/credential-provider-imds": "^4.4.7", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/credential-provider-env": "^3.972.59", + "@aws-sdk/credential-provider-http": "^3.972.61", + "@aws-sdk/credential-provider-login": "^3.972.66", + "@aws-sdk/credential-provider-process": "^3.972.59", + "@aws-sdk/credential-provider-sso": "^3.973.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -263,17 +263,17 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.63", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz", - "integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==", + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.66.tgz", + "integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -281,22 +281,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.66", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.66.tgz", - "integrity": "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.70.tgz", + "integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.57", - "@aws-sdk/credential-provider-http": "^3.972.59", - "@aws-sdk/credential-provider-ini": "^3.973.1", - "@aws-sdk/credential-provider-process": "^3.972.57", - "@aws-sdk/credential-provider-sso": "^3.973.1", - "@aws-sdk/credential-provider-web-identity": "^3.972.63", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/credential-provider-imds": "^4.4.7", - "@smithy/types": "^4.16.0", + "@aws-sdk/credential-provider-env": "^3.972.59", + "@aws-sdk/credential-provider-http": "^3.972.61", + "@aws-sdk/credential-provider-ini": "^3.973.4", + "@aws-sdk/credential-provider-process": "^3.972.59", + "@aws-sdk/credential-provider-sso": "^3.973.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -304,16 +304,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.57", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz", - "integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==", + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz", + "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -321,18 +321,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz", - "integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==", + "version": "3.973.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz", + "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/token-providers": "3.1083.0", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/token-providers": "3.1088.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -340,17 +340,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1083.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz", - "integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==", + "version": "3.1088.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz", + "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -358,17 +358,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.63", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz", - "integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==", + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz", + "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -376,15 +376,15 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.26.tgz", - "integrity": "sha512-RE1fu7Nn05vG0EUJM+8Sde2GFecC658WGaC/asPzLF6K4x3H5ZaDBcQtHRE67Gdgb1VZpyUUliYejHFK1qt0Uw==", + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.29.tgz", + "integrity": "sha512-t3tKQRTVXsI2QNPE3CaNjHl0wRO9Xi3acZkAyti2RQsiFmZ9Gi0kArX2ighlRJ1BtDVuul413gThAgzyTfgmWA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -392,15 +392,15 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.22.tgz", - "integrity": "sha512-jtkgmhevnpzC1WeS+Y/sgymYbaQ6qg7pVOUl5cUT/8MiLptqrtnXQlNV80m+j2WIx5MIL7kVHIZNxxcK2tfUEQ==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.24.tgz", + "integrity": "sha512-oykin4mDWxNOuYQ7SF1cHzgYeuFEkF4cdRwgvjFFbIklkx09qIFBiOgsORafG9sXZFO3TayMmQuAQYgADXhI8w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -408,18 +408,18 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.39.tgz", - "integrity": "sha512-CS1spxRSezmTmI3PD+3Xrnp6KryTSEz0EefA8u6uGd0s2I0uXseWHALDI/03Wi0IUczXNWo2QrZEaHDuJNby/Q==", + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.41.tgz", + "integrity": "sha512-LSbGvvYmjc4Br9BPYI2dTLnIclmrSiQbahkP4D6nRGVEv4qsCZ8csVuKBPVEEFCVD+EEngGh8ROls6XpumtwMg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/fetch-http-handler": "^5.6.4", - "@smithy/signature-v4": "^5.6.3", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -427,19 +427,19 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz", - "integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==", + "version": "3.997.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz", + "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/signature-v4-multi-region": "^3.996.39", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/fetch-http-handler": "^5.6.4", - "@smithy/node-http-handler": "^4.9.4", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -447,14 +447,14 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz", - "integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==", + "version": "4.9.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", + "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -462,15 +462,15 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz", - "integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==", + "version": "3.996.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz", + "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.0", - "@smithy/signature-v4": "^5.6.3", - "@smithy/types": "^4.16.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -496,13 +496,13 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -523,13 +523,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz", - "integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz", + "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -984,13 +984,13 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", - "integrity": "sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw==", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", + "integrity": "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1007,9 +1007,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", - "integrity": "sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA==", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", + "integrity": "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==", "dev": true, "license": "MIT", "dependencies": { @@ -1040,15 +1040,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.6.tgz", - "integrity": "sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g==", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz", + "integrity": "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==", "dev": true, + "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-tui": "^0.80.6", + "@earendil-works/pi-agent-core": "^0.80.10", + "@earendil-works/pi-ai": "^0.80.10", + "@earendil-works/pi-tui": "^0.80.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -1538,12 +1539,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1553,8 +1554,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1571,15 +1572,15 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -2076,6 +2077,16 @@ "node": ">=14.0.0" } }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2894,6 +2905,13 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -5327,13 +5345,13 @@ "license": "MIT" }, "node_modules/@smithy/core": { - "version": "3.29.2", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.2.tgz", - "integrity": "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz", + "integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5341,14 +5359,14 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.7.tgz", - "integrity": "sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==", + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.10.tgz", + "integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5356,14 +5374,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.4.tgz", - "integrity": "sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==", + "version": "5.6.7", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz", + "integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5399,14 +5417,14 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.3.tgz", - "integrity": "sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==", + "version": "5.6.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz", + "integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5414,9 +5432,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.16.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz", - "integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e84997d..d06b393 100644 --- a/package.json +++ b/package.json @@ -82,9 +82,9 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-coding-agent": "^0.80.6", + "@earendil-works/pi-agent-core": "^0.80.8", + "@earendil-works/pi-ai": "^0.80.8", + "@earendil-works/pi-coding-agent": "^0.80.8", "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "@types/ws": "^8.18.1", @@ -113,9 +113,9 @@ "homepage": "https://pi-web.dev/", "packageManager": "npm@11.11.0", "peerDependencies": { - "@earendil-works/pi-agent-core": ">=0.80.0 <1", - "@earendil-works/pi-ai": ">=0.80.0 <1", - "@earendil-works/pi-coding-agent": ">=0.80.0 <1" + "@earendil-works/pi-agent-core": ">=0.80.8 <0.81", + "@earendil-works/pi-ai": ">=0.80.8 <0.81", + "@earendil-works/pi-coding-agent": ">=0.80.8 <0.81" }, "keywords": [ "pi-package", From 585c3bc89d4e9e6c7eae0a597c229a155cc83c66 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 20:36:05 +0200 Subject: [PATCH 04/26] docs(issue-62): record relay leg 1 (bootstrap) in packet --- relays/issue-62-authstorage/log.md | 42 +++++++++++++++++ relays/issue-62-authstorage/status.md | 67 ++++++++++++++++----------- 2 files changed, 82 insertions(+), 27 deletions(-) diff --git a/relays/issue-62-authstorage/log.md b/relays/issue-62-authstorage/log.md index f65bfee..6effecc 100644 --- a/relays/issue-62-authstorage/log.md +++ b/relays/issue-62-authstorage/log.md @@ -45,3 +45,45 @@ pending sessiond restart for later daemon-path slices. **Handoff:** Planning only — NOT auto-spawning the first implementation leg. Assessment + relay plan are laid out ready to be kicked off by the user. + +--- + +## Leg 1 — Slice 0 Bootstrap (deps + range correction) + +**Did:** +- Corrected `package.json`: three `@earendil-works/*` devDependencies + `^0.80.6` → `^0.80.8`; peerDependencies `>=0.80.0 <1` → `>=0.80.8 <0.81`. +- `npm install` in the worktree. The default `/tmp`-based node-gyp build of + `node-pty` failed with "Disk quota exceeded" (/tmp is a 5.8G tmpfs at ~81%). + Re-ran with `TMPDIR="$PWD/.tmp-build" npm install`, which succeeded (618 + packages, 0 vulnerabilities). Removed `.tmp-build` after. +- Confirmed installed versions: all three packages resolve to **0.80.10** + (lockfile updated). +- Verified the new export surface via a node ESM check: `ModelRuntime` ✓, + `readStoredCredential` ✓, `ModelRegistry` ✓, `AuthStorage` absent ✓, pi-ai + `InMemoryCredentialStore` ✓. +- Ran `npx tsc --noEmit`: 24 errors, all in `src/server/sessions/` at the + expected migration sites (removed `AuthStorage`, `ModelRegistry.create/inMemory`, + `authStorage` on session-services options, `PiAgentSession.modelRegistry`). + This matches assessment §2/§3 — the new surface resolves; only Pi Web's old + usage is broken. + +**Decisions:** +- Committed with `git commit --no-verify`: the pre-commit hook runs a + whole-project typecheck which cannot pass until the migration lands. Charter + explicitly permits non-verify-green legs. Documented this + the `TMPDIR` + workaround in `status.md` so future legs don't rediscover them. +- Did not touch any `src/` migration code — kept strictly to slice 0 scope. + +**Artifacts changed:** `package.json`, `package-lock.json` (commit `0fa9d0e`); +`relays/issue-62-authstorage/{status,log}.md`. `node_modules/` installed +(gitignored, not committed). + +**Status update:** last completed leg 1, next leg 2 = charter slice 1 +(`authService.ts` core migration → propagate async construction to `sessiond.ts`). + +**Blockers:** none. Sessiond restart still pending (becomes relevant from leg 2 +onward once `sessiond.ts` changes land). + +**Handoff:** spawning leg 2 (slice 1). + diff --git a/relays/issue-62-authstorage/status.md b/relays/issue-62-authstorage/status.md index 4cc88d7..e04a387 100644 --- a/relays/issue-62-authstorage/status.md +++ b/relays/issue-62-authstorage/status.md @@ -1,33 +1,44 @@ # Relay status — issue-62-authstorage ## Current position -Relay planned and packet created. Assessment complete and committed -(`ASSESSMENT-issue-62.md`). No fix work has started. The worktree has **no -`node_modules` installed yet** — the first implementation leg must install deps -(pinned to Pi 0.80.8+) before anything typechecks. +Bootstrap (slice 0) complete and committed (`0fa9d0e`). Deps are installed in the +worktree at **0.80.10** (all three `@earendil-works/*`), `package.json` ranges +corrected, and the new export surface is confirmed resolvable +(`ModelRuntime`, `readStoredCredential` present; `AuthStorage` gone; pi-ai +`InMemoryCredentialStore` present). `npx tsc --noEmit` now reports **24 errors**, +all in `src/server/sessions/` at the expected migration sites (no crash — the +removed `AuthStorage` import and `ModelRegistry.create/inMemory` calls). The +migration itself has not started. ## Leg tracking -- **Last completed leg:** 0 (planning — this packet + assessment). -- **Next leg to run:** 1. +- **Last completed leg:** 1 (slice 0 Bootstrap — deps + range correction). +- **Next leg to run:** 2. ## Next task -Run **charter slice 0 (Bootstrap)** as leg 1: -1. In the worktree (`/srv/dev/pi-web-issue-62`), install deps pinning the three - `@earendil-works/*` packages to 0.80.8+ (0.80.8 or current 0.80.10): - `@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, - `@earendil-works/pi-agent-core`. Install in the worktree, **not `/tmp`** - (`/tmp` has a disk-quota problem; see assessment §8). -2. Correct `package.json`: peerDependencies for the three packages - `>=0.80.0 <1` → `>=0.80.8 <0.81`; devDependencies `^0.80.6` → `^0.80.8`. -3. Confirm the new export surface resolves (`readStoredCredential`, - `ModelRuntime` present; `AuthStorage` gone) — e.g. a quick node/tsx check or - just observe the typecheck errors now point at the migration sites. -4. Commit (e.g. `chore(deps): require pi 0.80.8+ and correct dep ranges (issue #62)`). -5. Update this `status.md` + append `log.md`, then hand off to leg 2 (charter - slice 1: `authService.ts` core migration). +Run **charter slice 1 (`authService.ts` core migration)** as leg 2: +- Move `authService.ts` to `ModelRuntime` (async construction via + `ModelRuntime.create({ authPath, modelsPath })`), migrate + `saveApiKey` / `logoutProvider` / `refreshAuthState` / credential access off + the removed `authStorage`/`ModelRegistry.create` surface (see assessment §5.1 + for the concrete mapping). +- Propagate the now-async construction to `src/server/sessiond.ts`. +- **Sessiond path:** this slice touches session-daemon code → note in + status/handoff that a manual sessiond restart will be needed once landed. +- Slice 1 depends only on slice 0 (done). The tree will still not fully + typecheck after this leg (slices 2–4 remain); that is expected — leave an + honest status. -If slice 0 is already done when you arrive, apply the charter's task-selection -policy: pick the lowest-numbered incomplete slice (1 → 6). +If slice 1 is already done when you arrive, apply the charter's task-selection +policy: pick the lowest-numbered incomplete slice (2 → 6). + +### Build/tooling note (important for every leg) +Installs and any native rebuild must set `TMPDIR` to a path inside the worktree, +e.g. `TMPDIR="$PWD/.tmp-build" npm install` (remove the dir after). `/tmp` is a +5.8G tmpfs at ~81% and node-gyp's `node-pty` build fails there with "Disk quota +exceeded". `.tmp-build` is scratch — do not commit it. The pre-commit hook runs +a whole-project typecheck; while the migration is incomplete, commit relay work +with `git commit --no-verify` (the charter permits legs that aren't verify-green). +Node: v24.18.0. ## Relevant context for the next runner - **Plan of record:** `ASSESSMENT-issue-62.md` (root) — read once. §5 has the @@ -53,9 +64,11 @@ commit before handing off. Hand off with `spawn_session` **once** per the charter's Handover section. ## Blockers / intervention state -None currently. Known constraints: +None. Known constraints: - **Sessiond restart pending** once slices touching `sessiond.ts` / session - runtime land — the human must manually restart the sessiond service; note it - here when it applies. -- `/tmp` disk-quota issue observed during assessment — do dependency installs in - the worktree. + runtime land (starts with slice 1/leg 2) — the human must manually restart the + sessiond service; keep this note current when it applies. +- `/tmp` disk-quota issue is real — see the Build/tooling note above; always set + `TMPDIR` into the worktree for installs/native rebuilds. +- node_modules is installed (gitignored) at 0.80.10; a fresh `npm install` is + only needed if node_modules is cleared. From e37148c193e8cdc5b980ac24506ae4cd12d4a1ce Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 20:53:42 +0200 Subject: [PATCH 05/26] Migrate authService to async ModelRuntime API (slice 1) Move AuthService off the removed AuthStorage / ModelRegistry.create surface onto the async ModelRuntime API: - AuthService.create({ agentDir | runtime }) async factory wrapping ModelRuntime.create({ authPath, modelsPath }); createModelRuntimeForAgentDir replaces createModelRegistryForAgentDir. - saveApiKey -> runtime.login(providerId, "api_key", nonInteractive) so the key is persisted through the runtime credential store. - logoutProvider -> runtime.logout; refreshAuthState -> await runtime.refresh(). - startOAuthLogin now passes the runtime into OAuthLoginFlowService.start. - authProviders/requireOAuthLoginProvider became async around getLogin/Logout provider options. - sessiond.ts: async createRuntime, AuthService.create, pass modelRuntime to PiSessionService; sessionDaemonStartup awaits createRuntime. Cross-slice: authProviderOptions (2), oauthLoginFlowService (3), and piSessionService (4) still expose the old ModelRegistry shape, so the tree does not fully typecheck yet. Session-daemon path changed -> manual sessiond restart needed once the migration lands. --- src/server/sessiond.ts | 6 +- src/server/sessiond/sessionDaemonStartup.ts | 4 +- src/server/sessions/authService.ts | 69 ++++++++++++--------- 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index de7e0f6..0e31a9d 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -36,15 +36,15 @@ await app.register(fastifyWebsocket); await runSessionDaemonStartup({ logger: app.log, - createRuntime() { + async createRuntime() { const eventHub = new SessionEventHub(); const workspaceActivity = new WorkspaceActivityService(eventHub); - const auth = new AuthService({ agentDir: activeAgentProfile.dir }); + const auth = await AuthService.create({ agentDir: activeAgentProfile.dir }); const spawnTargets = config.spawnSessions ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) : undefined; const sessions = new PiSessionService(eventHub, { - modelRegistry: auth.modelRegistry, + modelRuntime: auth.runtime, agentDir: activeAgentProfile.dir, workspaceActivity, logger: app.log, diff --git a/src/server/sessiond/sessionDaemonStartup.ts b/src/server/sessiond/sessionDaemonStartup.ts index b0c645d..ef3f197 100644 --- a/src/server/sessiond/sessionDaemonStartup.ts +++ b/src/server/sessiond/sessionDaemonStartup.ts @@ -12,7 +12,7 @@ export interface SessionDaemonStartupLogger { export interface SessionDaemonStartupSteps { logger: SessionDaemonStartupLogger; - createRuntime(): Runtime; + createRuntime(): Runtime | Promise; registerRoutes(runtime: Runtime): void; listen(runtime: Runtime): Promise; migrateArchive?: () => Promise; @@ -36,7 +36,7 @@ export async function runSessionDaemonStartup( ); } - const runtime = steps.createRuntime(); + const runtime = await steps.createRuntime(); steps.registerRoutes(runtime); await steps.listen(runtime); return runtime; diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index 3cb1305..3dab648 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import type { AuthInteraction } from "@earendil-works/pi-ai"; import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js"; import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; @@ -9,27 +10,31 @@ export interface AuthChange { } type AuthChangeListener = (change: AuthChange) => void; -type ModelRegistryInstance = ReturnType; export interface AuthServiceDependencies { agentDir?: string; - modelRegistry?: ModelRegistryInstance; + runtime?: ModelRuntime; authFlows?: OAuthLoginFlowService; } -export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance { - const authStorage = AuthStorage.create(join(agentDir, "auth.json")); - return ModelRegistry.create(authStorage, join(agentDir, "models.json")); +export function createModelRuntimeForAgentDir(agentDir: string): Promise { + return ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") }); } export class AuthService { - readonly modelRegistry: ModelRegistryInstance; + readonly runtime: ModelRuntime; private readonly authFlows: OAuthLoginFlowService; private readonly listeners = new Set(); - constructor(deps: AuthServiceDependencies = {}) { - this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir)); - this.authFlows = deps.authFlows ?? new OAuthLoginFlowService(); + private constructor(runtime: ModelRuntime, authFlows: OAuthLoginFlowService) { + this.runtime = runtime; + this.authFlows = authFlows; + } + + static async create(deps: AuthServiceDependencies = {}): Promise { + const runtime = deps.runtime ?? (deps.agentDir === undefined ? await ModelRuntime.create({}) : await createModelRuntimeForAgentDir(deps.agentDir)); + const authFlows = deps.authFlows ?? new OAuthLoginFlowService(); + return new AuthService(runtime, authFlows); } subscribe(listener: AuthChangeListener): () => void { @@ -44,33 +49,40 @@ export class AuthService { this.listeners.clear(); } - authProviders(mode: "login" | "logout", authType?: AuthType): AuthProvidersResponse { - this.modelRegistry.refresh(); - const providers = mode === "logout" ? getLogoutProviderOptions(this.modelRegistry) : getLoginProviderOptions(this.modelRegistry, authType); + async authProviders(mode: "login" | "logout", authType?: AuthType): Promise { + await this.runtime.refresh(); + const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : await getLoginProviderOptions(this.runtime, authType); return { providers }; } - saveApiKey(providerId: string, key: string): { accepted: true } { + async saveApiKey(providerId: string, key: string): Promise<{ accepted: true }> { if (key.trim() === "") throw new Error("API key is required"); - this.modelRegistry.authStorage.set(providerId, { type: "api_key", key }); - this.refreshAuthState(); + // The provider's api-key login prompts for the key and persists the returned + // credential through the runtime's credential store; feed the key back via a + // non-interactive AuthInteraction. + const interaction: AuthInteraction = { + prompt: async () => key, + notify: () => {}, + }; + await this.runtime.login(providerId, "api_key", interaction); + await this.refreshAuthState(); return { accepted: true }; } - logoutProvider(providerId: string): { accepted: true } { - this.modelRegistry.authStorage.logout(providerId); - this.refreshAuthState({ removedProviderId: providerId }); + async logoutProvider(providerId: string): Promise<{ accepted: true }> { + await this.runtime.logout(providerId); + await this.refreshAuthState({ removedProviderId: providerId }); return { accepted: true }; } - startOAuthLogin(providerId: string): OAuthFlowState { - const provider = this.requireOAuthLoginProvider(providerId); + async startOAuthLogin(providerId: string): Promise { + const provider = await this.requireOAuthLoginProvider(providerId); return this.authFlows.start({ providerId, providerName: provider.name, - authStorage: this.modelRegistry.authStorage, + runtime: this.runtime, onComplete: () => { - this.refreshAuthState(); + void this.refreshAuthState(); }, }); } @@ -87,9 +99,8 @@ export class AuthService { return this.authFlows.cancel(flowId); } - private refreshAuthState(change: AuthChange = {}): void { - this.modelRegistry.authStorage.reload(); - this.modelRegistry.refresh(); + private async refreshAuthState(change: AuthChange = {}): Promise { + await this.runtime.refresh(); this.emit(change); } @@ -97,9 +108,9 @@ export class AuthService { for (const listener of this.listeners) listener(change); } - private requireOAuthLoginProvider(providerId: string) { - this.modelRegistry.refresh(); - const provider = getLoginProviderOptions(this.modelRegistry, "oauth").find((option) => option.id === providerId); + private async requireOAuthLoginProvider(providerId: string) { + await this.runtime.refresh(); + const provider = (await getLoginProviderOptions(this.runtime, "oauth")).find((option) => option.id === providerId); if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`); return provider; } From 842e6516587ce3f81fdf283f050e7a73598b22cf Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 20:54:56 +0200 Subject: [PATCH 06/26] relay issue-62-authstorage: leg 2 status/log (slice 1 done) --- relays/issue-62-authstorage/log.md | 82 ++++++++++++++++++++++ relays/issue-62-authstorage/status.md | 99 +++++++++++++++++---------- 2 files changed, 145 insertions(+), 36 deletions(-) diff --git a/relays/issue-62-authstorage/log.md b/relays/issue-62-authstorage/log.md index 6effecc..001d77b 100644 --- a/relays/issue-62-authstorage/log.md +++ b/relays/issue-62-authstorage/log.md @@ -87,3 +87,85 @@ onward once `sessiond.ts` changes land). **Handoff:** spawning leg 2 (slice 1). + +--- + +## Leg 2 — slice 1: `authService.ts` core migration (commit `e37148c`) + +**What I did:** migrated `src/server/sessions/authService.ts` off the removed +`AuthStorage` / `ModelRegistry.create` surface onto the async `ModelRuntime` +API, and propagated the async construction into the session daemon. + +- `authService.ts`: + - Imports `ModelRuntime` from `@earendil-works/pi-coding-agent` and + `AuthInteraction` (type) from `@earendil-works/pi-ai`. Dropped + `AuthStorage` / `ModelRegistry`. + - `createModelRegistryForAgentDir` → `createModelRuntimeForAgentDir(agentDir)` + returning `ModelRuntime.create({ authPath: /auth.json, modelsPath: + /models.json })`. + - Construction is now async: private constructor + static + `AuthService.create({ agentDir? | runtime? | authFlows? })`. `runtime` dep + replaces the old `modelRegistry` dep; no-agentDir fallback is + `ModelRuntime.create({})`. + - Public field `readonly runtime: ModelRuntime` replaces `modelRegistry`. + - `saveApiKey` → `runtime.login(providerId, "api_key", interaction)` where + `interaction` is a non-interactive `AuthInteraction` (`prompt: async () => + key`, `notify: () => {}`). Verified against pi-ai `envApiKeyAuth().login`, + which calls `interaction.prompt({ type: "secret" })` and persists the + returned `{ type:"api_key", key }` through `credentials.modify` inside + `Models.login`. This is the credential-persistence path the assessment + (§5.1) called for. + - `logoutProvider` → `await runtime.logout(providerId)`. + - `refreshAuthState` → `await runtime.refresh()` (no more `authStorage.reload()` + — the file store is re-read by the runtime). Now async. + - `authProviders` and `requireOAuthLoginProvider` became async, awaiting + `runtime.refresh()` and the now-async `getLogin/LogoutProviderOptions`. + - `startOAuthLogin` passes `runtime: this.runtime` into + `OAuthLoginFlowService.start` (slice 3 will consume it via `runtime.login`). +- `sessiond.ts`: `createRuntime()` is now `async`; `new AuthService(...)` → + `await AuthService.create({ agentDir })`; `PiSessionService` now receives + `modelRuntime: auth.runtime` instead of `modelRegistry: auth.modelRegistry`. +- `sessiond/sessionDaemonStartup.ts`: `createRuntime` may now return + `Runtime | Promise` and `runSessionDaemonStartup` `await`s it. The + existing sync test doubles still satisfy the widened type. + +**Decisions:** +- **saveApiKey via `runtime.login("api_key", …)`** rather than reaching for a + raw `CredentialStore.modify`: the pi-ai `CredentialStore` is not exposed off + `ModelRuntime` publicly, and the provider's own api-key `login` is the + intended persistence entry point (it writes through `credentials.modify`). + Feeding the key back through a non-interactive `AuthInteraction.prompt` keeps + us on the supported public surface. This matches assessment §5.1's + "credential persistence via the pi-ai CredentialStore.modify path" without + depending on unexported internals. +- Kept `AuthService` construction async via a static factory (private ctor) + rather than an `init()` method — cleaner async boundary, single valid + construction path (code-quality-architecture skill). +- Did NOT touch `authProviderOptions.ts`, `oauthLoginFlowService.ts`, + `piSessionService.ts`, or any test/support files — strictly slice 1 scope. + The async call sites I introduced (`await getLoginProviderOptions(...)`, + `runtime:` in `authFlows.start`, `modelRuntime:` in PiSessionService deps) + deliberately point at the interfaces slices 2–4 will expose. + +**Typecheck state:** `npx tsc --noEmit` = 31 errors (was 24 at slice 0). The +increase is expected and honest: the migrated authService now calls +runtime-based interfaces that slices 2/3/4 have not migrated yet. All remaining +`authService.ts` / `sessiond.ts` errors are cross-slice (authProviderOptions +shape → slice 2; OAuthLoginFlowService.start `runtime` param → slice 3; +`PiSessionServiceDependencies.modelRuntime` → slice 4). Test/support files +(slice 5) still import the removed `AuthStorage`. + +**Artifacts changed:** `src/server/sessions/authService.ts`, +`src/server/sessiond.ts`, `src/server/sessiond/sessionDaemonStartup.ts` +(commit `e37148c`); `relays/issue-62-authstorage/{status,log}.md`. + +**Status update:** last completed leg 2, next leg 3 = charter slice 2 +(`authProviderOptions.ts` migration). + +**Blockers:** none. **Sessiond restart now ACTIVE-pending** — slice 1 changed +`sessiond.ts` and the daemon auth construction path; the human must manually +restart the sessiond service once the migration lands (noted in status.md). +Also: human confirmed `/tmp` is usable again, so the `TMPDIR` install +workaround is no longer required (status.md updated). + +**Handoff:** spawning leg 3 (slice 2). diff --git a/relays/issue-62-authstorage/status.md b/relays/issue-62-authstorage/status.md index e04a387..1b4fdf3 100644 --- a/relays/issue-62-authstorage/status.md +++ b/relays/issue-62-authstorage/status.md @@ -1,44 +1,69 @@ # Relay status — issue-62-authstorage ## Current position -Bootstrap (slice 0) complete and committed (`0fa9d0e`). Deps are installed in the -worktree at **0.80.10** (all three `@earendil-works/*`), `package.json` ranges -corrected, and the new export surface is confirmed resolvable -(`ModelRuntime`, `readStoredCredential` present; `AuthStorage` gone; pi-ai -`InMemoryCredentialStore` present). `npx tsc --noEmit` now reports **24 errors**, -all in `src/server/sessions/` at the expected migration sites (no crash — the -removed `AuthStorage` import and `ModelRegistry.create/inMemory` calls). The -migration itself has not started. +Slice 1 (`authService.ts` core migration) complete and committed (`e37148c`). +`authService.ts` now uses the async `ModelRuntime` API: `AuthService.create({ +agentDir | runtime })` factory wraps `ModelRuntime.create({ authPath, +modelsPath })`; `createModelRuntimeForAgentDir` replaces +`createModelRegistryForAgentDir`. `saveApiKey` → `runtime.login(id, "api_key", +nonInteractive)`, `logoutProvider` → `runtime.logout`, `refreshAuthState` → +`await runtime.refresh()`. `authProviders` / `requireOAuthLoginProvider` are now +async. `startOAuthLogin` passes `runtime` into `OAuthLoginFlowService.start`. +`sessiond.ts` uses async `createRuntime`, `AuthService.create`, and passes +`modelRuntime: auth.runtime` to `PiSessionService`; `sessionDaemonStartup` now +awaits `createRuntime`. + +`npx tsc --noEmit` reports **31 errors** (up from 24 — expected: the migrated +authService now calls the runtime-based interfaces that slices 2–4 haven't +exposed yet). Slice-1 files are internally consistent; every remaining error +in `authService.ts` / `sessiond.ts` is a **cross-slice** dependency: +- `authService.ts`: `getLoginProviderOptions/getLogoutProviderOptions` still + take the old `AuthProviderModelRegistry` shape (fixed in slice 2); + `OAuthLoginFlowService.start` still expects `authStorage` not `runtime` + (fixed in slice 3). +- `sessiond.ts`: `PiSessionServiceDependencies` still expects `modelRegistry` + not `modelRuntime` (fixed in slice 4). +Remaining errors otherwise live in slices 2/3/4 files and all test/support +files (slice 5). ## Leg tracking -- **Last completed leg:** 1 (slice 0 Bootstrap — deps + range correction). -- **Next leg to run:** 2. +- **Last completed leg:** 2 (slice 1 — authService.ts core migration + sessiond + async construction). +- **Next leg to run:** 3. ## Next task -Run **charter slice 1 (`authService.ts` core migration)** as leg 2: -- Move `authService.ts` to `ModelRuntime` (async construction via - `ModelRuntime.create({ authPath, modelsPath })`), migrate - `saveApiKey` / `logoutProvider` / `refreshAuthState` / credential access off - the removed `authStorage`/`ModelRegistry.create` surface (see assessment §5.1 - for the concrete mapping). -- Propagate the now-async construction to `src/server/sessiond.ts`. -- **Sessiond path:** this slice touches session-daemon code → note in - status/handoff that a manual sessiond restart will be needed once landed. -- Slice 1 depends only on slice 0 (done). The tree will still not fully - typecheck after this leg (slices 2–4 remain); that is expected — leave an - honest status. +Run **charter slice 2 (`authProviderOptions.ts` migration)** as leg 3: +- Rederive login/logout provider options from `runtime.getProviders()` + (`{ id, name, auth: { apiKey?, oauth? } }`) + `runtime.listCredentials()` + (`{ providerId, type }[]`) / `runtime.getProviderAuthStatus(id)` instead of + `authStorage.getOAuthProviders()/list()/get()` + `getAll()` + + `getProviderDisplayName()`. +- The functions are already **called as async** from `authService.ts` + (`await getLoginProviderOptions(this.runtime, authType)` etc.) — make them + async and change their parameter type from `AuthProviderModelRegistry` to a + runtime-shaped interface (e.g. `AuthProviderRuntime` = `Pick` or a + structural equivalent). Update the structural interface + `authProviderOptions.test.ts` + test double accordingly. +- See assessment §5.2 / §3.3 for the new API shapes. Provider display names come + from `Provider.name`; OAuth-capable providers are those with `auth.oauth`, + api-key providers those with `auth.apiKey` (respect the existing + `OAUTH_ONLY_PROVIDERS` / `isApiKeyLoginProvider` logic). -If slice 1 is already done when you arrive, apply the charter's task-selection -policy: pick the lowest-numbered incomplete slice (2 → 6). +If slice 2 is already done when you arrive, apply the charter's task-selection +policy: pick the lowest-numbered incomplete slice (3 → 6). Slices 3 and 4 +unblock the remaining `authService.ts` / `sessiond.ts` cross-slice errors. ### Build/tooling note (important for every leg) -Installs and any native rebuild must set `TMPDIR` to a path inside the worktree, -e.g. `TMPDIR="$PWD/.tmp-build" npm install` (remove the dir after). `/tmp` is a -5.8G tmpfs at ~81% and node-gyp's `node-pty` build fails there with "Disk quota -exceeded". `.tmp-build` is scratch — do not commit it. The pre-commit hook runs -a whole-project typecheck; while the migration is incomplete, commit relay work -with `git commit --no-verify` (the charter permits legs that aren't verify-green). -Node: v24.18.0. +**Update (leg 2):** the human reports `/tmp` is now fully usable again, so the +previous `TMPDIR` workaround is no longer required — plain `npm install` should +work. (If a disk-quota error resurfaces, fall back to +`TMPDIR="$PWD/.tmp-build" npm install` and remove `.tmp-build` after; it is +scratch, do not commit it.) node_modules is already installed at 0.80.10, so a +fresh install is only needed if node_modules is cleared. The pre-commit hook +runs a whole-project typecheck; while the migration is incomplete, commit relay +work with `git commit --no-verify` (the charter permits legs that aren't +verify-green). Node: v24.18.0. ## Relevant context for the next runner - **Plan of record:** `ASSESSMENT-issue-62.md` (root) — read once. §5 has the @@ -65,10 +90,12 @@ charter's Handover section. ## Blockers / intervention state None. Known constraints: -- **Sessiond restart pending** once slices touching `sessiond.ts` / session - runtime land (starts with slice 1/leg 2) — the human must manually restart the - sessiond service; keep this note current when it applies. -- `/tmp` disk-quota issue is real — see the Build/tooling note above; always set - `TMPDIR` into the worktree for installs/native rebuilds. +- **Sessiond restart pending (ACTIVE):** slice 1 (leg 2, commit `e37148c`) + changed `sessiond.ts` + the session-daemon auth construction path. Per + AGENTS.md the human must **manually restart the sessiond service** for these + changes to take effect once the migration lands. Keep this note until the + human confirms the restart. +- `/tmp` disk-quota issue is resolved (human confirmed usable) — see the + Build/tooling note above. - node_modules is installed (gitignored) at 0.80.10; a fresh `npm install` is only needed if node_modules is cleared. From d09d7cc1ff4d3b4bc23902f8a80d8787741fb77c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 20:58:46 +0200 Subject: [PATCH 07/26] Migrate authProviderOptions to ModelRuntime API Rederive login/logout provider options from runtime.getProviders() + listCredentials() + getProviderAuthStatus() instead of the removed authStorage.getOAuthProviders()/list()/get() + getAll()/ getProviderDisplayName() surface (Pi 0.80.8+). - Replace the AuthProviderModelRegistry structural interface with a runtime-shaped AuthProviderRuntime (getProviders/listCredentials/ getProviderAuthStatus); a real ModelRuntime satisfies it. - Make getLoginProviderOptions/getLogoutProviderOptions async to match the await call sites already in authService.ts. - OAuth-capable providers = auth.oauth present; api-key providers = auth.apiKey present, preserving OAUTH_ONLY_PROVIDERS / isApiKeyLoginProvider logic. Display names from Provider.name. - Update the test double to the new runtime shape. Slice 2 of the authStorage migration relay. tsc: 31 -> 28 errors (remaining are cross-slice: slices 3/4/5). --- .../sessions/authProviderOptions.test.ts | 41 ++++------ src/server/sessions/authProviderOptions.ts | 80 ++++++++++++------- 2 files changed, 65 insertions(+), 56 deletions(-) diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts index e6fa31e..f3e3707 100644 --- a/src/server/sessions/authProviderOptions.test.ts +++ b/src/server/sessions/authProviderOptions.test.ts @@ -1,27 +1,18 @@ import { describe, expect, it } from "vitest"; -import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderModelRegistry } from "./authProviderOptions"; +import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderRuntime } from "./authProviderOptions"; -function registry(): AuthProviderModelRegistry { - const credentials = new Map(); - credentials.set("openai", { type: "api_key" }); +function runtime(): AuthProviderRuntime { + const credentials = [{ providerId: "openai", type: "api_key" as const }]; + const providers = [ + { id: "anthropic", name: "Anthropic", auth: { oauth: {}, apiKey: {} } }, + { id: "github-copilot", name: "GitHub Copilot", auth: { oauth: {} } }, + { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", auth: { oauth: {}, apiKey: {} } }, + { id: "openai", name: "OpenAI", auth: { apiKey: {} } }, + { id: "custom", name: "Custom", auth: { apiKey: {} } }, + ]; return { - authStorage: { - getOAuthProviders: () => [ - { id: "anthropic", name: "Anthropic (Claude Pro/Max)" }, - { id: "github-copilot", name: "GitHub Copilot" }, - { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)" }, - ], - list: () => Array.from(credentials.keys()), - get: (provider: string) => credentials.get(provider), - }, - getAll: () => [ - { provider: "anthropic" }, - { provider: "openai" }, - { provider: "openai-codex" }, - { provider: "github-copilot" }, - { provider: "custom" }, - ], - getProviderDisplayName: (provider: string) => ({ anthropic: "Anthropic", openai: "OpenAI", custom: "Custom" }[provider] ?? provider), + getProviders: () => providers, + listCredentials: () => Promise.resolve(credentials), getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }), }; } @@ -33,8 +24,8 @@ describe("auth provider options", () => { expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true); }); - it("builds login options for OAuth-only, dual-auth, and API-key providers", () => { - const options = getLoginProviderOptions(registry()); + it("builds login options for OAuth-only, dual-auth, and API-key providers", async () => { + const options = await getLoginProviderOptions(runtime()); expect(options).toEqual(expect.arrayContaining([ expect.objectContaining({ id: "anthropic", authType: "oauth" }), expect.objectContaining({ id: "anthropic", authType: "api_key" }), @@ -44,8 +35,8 @@ describe("auth provider options", () => { expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })])); }); - it("returns only currently stored credentials for logout", () => { - expect(getLogoutProviderOptions(registry())).toEqual([ + it("returns only currently stored credentials for logout", async () => { + expect(await getLogoutProviderOptions(runtime())).toEqual([ expect.objectContaining({ id: "openai", authType: "api_key" }), ]); }); diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts index 58211d8..34b7cdb 100644 --- a/src/server/sessions/authProviderOptions.ts +++ b/src/server/sessions/authProviderOptions.ts @@ -2,51 +2,69 @@ import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../sha const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]); -export interface AuthProviderModelRegistry { - authStorage: { - getOAuthProviders(): { id: string; name: string }[]; - list(): string[]; - get(provider: string): { type: AuthType } | undefined; - }; - getAll(): { provider: string }[]; - getProviderDisplayName(provider: string): string; - getProviderAuthStatus(provider: string): AuthProviderStatus; +/** Minimal provider shape needed to enumerate login/logout options. */ +interface AuthProviderInfo { + id: string; + name: string; + auth: { apiKey?: unknown; oauth?: unknown }; } -export function getLoginProviderOptions(modelRegistry: AuthProviderModelRegistry, authType?: AuthType): AuthProviderOption[] { - const oauthProviders = modelRegistry.authStorage.getOAuthProviders(); - const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id)); - const options: AuthProviderOption[] = oauthProviders.map((provider) => ({ - id: provider.id, - name: provider.name, - authType: "oauth", - status: modelRegistry.getProviderAuthStatus(provider.id), - })); +/** Non-secret stored-credential metadata, keyed by provider id. */ +interface AuthProviderCredentialInfo { + providerId: string; + type: AuthType; +} - const modelProviders = new Set(modelRegistry.getAll().map((model) => model.provider)); - for (const providerId of modelProviders) { - if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) continue; +/** + * Structural slice of the SDK `ModelRuntime` used to derive auth provider + * options. Kept structural (rather than `Pick`) so tests can + * supply a lightweight double without constructing a full runtime; a real + * `ModelRuntime` satisfies it. + */ +export interface AuthProviderRuntime { + getProviders(): readonly AuthProviderInfo[]; + listCredentials(): Promise; + getProviderAuthStatus(providerId: string): AuthProviderStatus; +} + +export async function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): Promise { + const providers = runtime.getProviders(); + const oauthProviderIds = new Set(providers.filter((provider) => provider.auth.oauth !== undefined).map((provider) => provider.id)); + + const options: AuthProviderOption[] = []; + for (const provider of providers) { + if (provider.auth.oauth === undefined) continue; options.push({ - id: providerId, - name: modelRegistry.getProviderDisplayName(providerId), + id: provider.id, + name: provider.name, + authType: "oauth", + status: runtime.getProviderAuthStatus(provider.id), + }); + } + + for (const provider of providers) { + if (provider.auth.apiKey === undefined) continue; + if (!isApiKeyLoginProvider(provider.id, oauthProviderIds)) continue; + options.push({ + id: provider.id, + name: provider.name, authType: "api_key", - status: modelRegistry.getProviderAuthStatus(providerId), + status: runtime.getProviderAuthStatus(provider.id), }); } return filterAndSort(options, authType); } -export function getLogoutProviderOptions(modelRegistry: AuthProviderModelRegistry): AuthProviderOption[] { +export async function getLogoutProviderOptions(runtime: AuthProviderRuntime): Promise { + const providerNames = new Map(runtime.getProviders().map((provider) => [provider.id, provider.name])); const options: AuthProviderOption[] = []; - for (const providerId of modelRegistry.authStorage.list()) { - const credential = modelRegistry.authStorage.get(providerId); - if (credential === undefined) continue; + for (const credential of await runtime.listCredentials()) { options.push({ - id: providerId, - name: modelRegistry.getProviderDisplayName(providerId), + id: credential.providerId, + name: providerNames.get(credential.providerId) ?? credential.providerId, authType: credential.type, - status: modelRegistry.getProviderAuthStatus(providerId), + status: runtime.getProviderAuthStatus(credential.providerId), }); } return filterAndSort(options); From d273a3d8c45f134e01c85b2c27f0fc2f4d9cc72c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 20:59:39 +0200 Subject: [PATCH 08/26] relay issue-62-authstorage: leg 3 (slice 2) status + log --- relays/issue-62-authstorage/log.md | 44 +++++++++++++++ relays/issue-62-authstorage/status.md | 78 +++++++++++++++++++-------- 2 files changed, 99 insertions(+), 23 deletions(-) diff --git a/relays/issue-62-authstorage/log.md b/relays/issue-62-authstorage/log.md index 001d77b..0a0da04 100644 --- a/relays/issue-62-authstorage/log.md +++ b/relays/issue-62-authstorage/log.md @@ -169,3 +169,47 @@ Also: human confirmed `/tmp` is usable again, so the `TMPDIR` install workaround is no longer required (status.md updated). **Handoff:** spawning leg 3 (slice 2). + +## Leg 3 — slice 2: authProviderOptions.ts migration (commit d09d7cc) + +**Did:** Migrated `src/server/sessions/authProviderOptions.ts` off the removed +`authStorage`-centric surface onto the new `ModelRuntime` API. +- Replaced the `AuthProviderModelRegistry` structural interface (which required + `authStorage.getOAuthProviders()/list()/get()`, `getAll()`, + `getProviderDisplayName()`) with a runtime-shaped `AuthProviderRuntime` + interface exposing `getProviders()` (`{ id, name, auth: { apiKey?, oauth? } }`), + `listCredentials()` (`Promise<{ providerId, type }[]>`), and + `getProviderAuthStatus(id)`. Kept it structural (not `Pick`) + so the test can supply a lightweight double; verified the real `ModelRuntime` + satisfies it (call sites in `authService.ts` typecheck clean). +- Made `getLoginProviderOptions` / `getLogoutProviderOptions` `async` to match + the `await` call sites already present in `authService.ts` (leg 2). +- Login options: OAuth options from providers with `auth.oauth`; api-key options + from providers with `auth.apiKey` filtered through the unchanged + `OAUTH_ONLY_PROVIDERS` / `isApiKeyLoginProvider` logic. Display names now come + from `Provider.name` (replacing `getProviderDisplayName`). Logout options + derived from `listCredentials()`, mapping provider id -> name via + `getProviders()`. +- Rewrote the `authProviderOptions.test.ts` double to the runtime shape (a + `getProviders` array with per-provider `auth`, a `listCredentials` promise, + `getProviderAuthStatus`); made the two option-building tests async. All 3 + tests pass. + +**Decisions:** `AuthProviderInfo.auth` typed as `{ apiKey?: unknown; oauth?: +unknown }` — presence is all this module needs, and `unknown` keeps the double +trivial while remaining assignable-from the real `ProviderAuth`. Structural +interface (not `Pick`) chosen for testability per +code-quality-architecture (injectable seam, no SDK construction in unit test). + +**Verify state:** `npx tsc --noEmit` 31 -> 28 errors. No `authProviderOptions` +errors; `getLogin/LogoutProviderOptions` call sites in `authService.ts` clean. +Remaining 28 are cross-slice: `authService.ts` line-83 `OAuthLoginFlowService. +start` still expects `authStorage` (slice 3); `sessiond.ts`(1)+`piSessionService. +ts`(6) slice 4; test/support files slice 5. + +**Artifacts:** `src/server/sessions/authProviderOptions.ts`, +`src/server/sessions/authProviderOptions.test.ts`; status.md updated; committed +`d09d7cc` with `--no-verify` (migration not yet verify-green, permitted). + +**Handoff:** spawning leg 4 (slice 3, oauthLoginFlowService.ts). Sessiond +restart from leg 2 still pending — carried forward, not cleared. diff --git a/relays/issue-62-authstorage/status.md b/relays/issue-62-authstorage/status.md index 1b4fdf3..ac349b4 100644 --- a/relays/issue-62-authstorage/status.md +++ b/relays/issue-62-authstorage/status.md @@ -1,6 +1,30 @@ # Relay status — issue-62-authstorage ## Current position +Slice 2 (`authProviderOptions.ts` migration) complete and committed (`d09d7cc`). +`authProviderOptions.ts` now derives options from a runtime-shaped +`AuthProviderRuntime` interface (`getProviders()` + `listCredentials()` + +`getProviderAuthStatus()`). `getLoginProviderOptions`/`getLogoutProviderOptions` +are now `async` (matching the `await` call sites already in `authService.ts`). +The old `AuthProviderModelRegistry` interface is gone; a real `ModelRuntime` +satisfies `AuthProviderRuntime` structurally. The test double in +`authProviderOptions.test.ts` was rewritten to the runtime shape and its 3 +tests pass. OAuth-capable = `auth.oauth` present; api-key = `auth.apiKey` +present; `OAUTH_ONLY_PROVIDERS` / `isApiKeyLoginProvider` logic preserved; +display names come from `Provider.name`. + +`npx tsc --noEmit` now reports **28 errors** (down from 31). No +`authProviderOptions` errors remain and the `getLoginProviderOptions` / +`getLogoutProviderOptions` call sites in `authService.ts` typecheck cleanly. +Remaining errors are all cross-slice: `authService.ts` (1: line-83 +`OAuthLoginFlowService.start` still expects `authStorage` not `runtime` — +slice 3), `sessiond.ts` (1) + `piSessionService.ts` (6, slice 4), and the +test/support files (slice 5): `authService.test.ts` (9), +`oauthLoginFlowService.ts`/`.test.ts` (1+1, slice 3), +`piSessionService.testSupport.ts` (3), `.promptQueue.test.ts` (2), +`.warnings.test.ts` (4). + +### Prior position (slice 1, leg 2, commit `e37148c`) Slice 1 (`authService.ts` core migration) complete and committed (`e37148c`). `authService.ts` now uses the async `ModelRuntime` API: `AuthService.create({ agentDir | runtime })` factory wraps `ModelRuntime.create({ authPath, @@ -27,32 +51,40 @@ Remaining errors otherwise live in slices 2/3/4 files and all test/support files (slice 5). ## Leg tracking -- **Last completed leg:** 2 (slice 1 — authService.ts core migration + sessiond - async construction). -- **Next leg to run:** 3. +- **Last completed leg:** 3 (slice 2 — authProviderOptions.ts migration). +- **Next leg to run:** 4. ## Next task -Run **charter slice 2 (`authProviderOptions.ts` migration)** as leg 3: -- Rederive login/logout provider options from `runtime.getProviders()` - (`{ id, name, auth: { apiKey?, oauth? } }`) + `runtime.listCredentials()` - (`{ providerId, type }[]`) / `runtime.getProviderAuthStatus(id)` instead of - `authStorage.getOAuthProviders()/list()/get()` + `getAll()` + - `getProviderDisplayName()`. -- The functions are already **called as async** from `authService.ts` - (`await getLoginProviderOptions(this.runtime, authType)` etc.) — make them - async and change their parameter type from `AuthProviderModelRegistry` to a - runtime-shaped interface (e.g. `AuthProviderRuntime` = `Pick` or a - structural equivalent). Update the structural interface + `authProviderOptions.test.ts` - test double accordingly. -- See assessment §5.2 / §3.3 for the new API shapes. Provider display names come - from `Provider.name`; OAuth-capable providers are those with `auth.oauth`, - api-key providers those with `auth.apiKey` (respect the existing - `OAUTH_ONLY_PROVIDERS` / `isApiKeyLoginProvider` logic). +Run **charter slice 3 (`oauthLoginFlowService.ts` migration)** as leg 4. This +is the riskiest slice — verify the prompt/select/device-code/auth_url mapping +carefully. Concretely: +- Reimplement `oauthLoginFlowService.ts` against the pi-ai `AuthInteraction` + contract (`{ signal?, prompt(prompt: AuthPrompt): Promise, + notify(event: AuthEvent): void }`) instead of the removed + `OAuthLoginCallbacks` shape (`onAuth`/`onDeviceCode`/`onPrompt`/ + `onManualCodeInput`/`onSelect`/`onProgress`). Types live in + `node_modules/@earendil-works/pi-ai/dist/auth/types.d.ts`. +- `AuthPrompt` is a discriminated union: `text` / `secret` / `select` + (`options: { id, label, description? }[]`, returns the chosen option id) / + `manual_code`. `AuthEvent` is `info` / `auth_url` (`{ url, instructions? }`) + / `device_code` (`{ userCode, verificationUri, intervalSeconds?, + expiresInSeconds? }`) / `progress`. Map these onto the existing web-UI flow + state fields (see the current `oauthLoginFlowService.ts` prompt/select/ + device-code/auth_url handling). +- Change `OAuthLoginFlowService.start` to accept `runtime` (the + `ModelRuntime`) instead of `authStorage`, and drive login via + `runtime.login(providerId, "oauth", interaction)` where `interaction` is the + adapter you build. `authService.ts` already calls + `OAuthLoginFlowService.start({ ..., runtime: this.runtime })` (this is the + line-83 tsc error). Also update `oauthLoginFlowService.test.ts`. +- After slice 3, `authService.ts` should reach 0 errors. `sessiond.ts` + + `piSessionService.ts` (slice 4) and the remaining test/support files + (slice 5) stay until their slices. -If slice 2 is already done when you arrive, apply the charter's task-selection -policy: pick the lowest-numbered incomplete slice (3 → 6). Slices 3 and 4 -unblock the remaining `authService.ts` / `sessiond.ts` cross-slice errors. +If slice 3 is already done when you arrive, apply the charter's task-selection +policy: pick the lowest-numbered incomplete slice (4 → 6). Slice 4 unblocks the +remaining `sessiond.ts` / `piSessionService.ts` cross-slice errors; slice 5 +finalizes tests; slice 6 adds the changeset + final verify. ### Build/tooling note (important for every leg) **Update (leg 2):** the human reports `/tmp` is now fully usable again, so the From 1c3d6db1097fbfeaf6baa4fd3d52d84f3cb575a2 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 21:09:39 +0200 Subject: [PATCH 09/26] Migrate oauthLoginFlowService to pi-ai AuthInteraction contract Reimplement OAuthLoginFlowService against the pi-ai AuthInteraction ({ signal?, prompt(AuthPrompt), notify(AuthEvent) }) contract instead of the removed OAuthLoginCallbacks shape, and drive login via runtime.login(providerId, "oauth", interaction). start() now takes a ModelRuntime instead of authStorage, resolving the authService.ts line-83 error. AuthPrompt text/secret/manual_code/select map onto the existing web-UI prompt/select flow state; auth_url/device_code map onto the auth field; info/progress append to progress. Per-prompt AuthPrompt.signal now cancels just that pending request without ending the flow. Slice 3 of the issue-62 authStorage migration relay. --- .../sessions/oauthLoginFlowService.test.ts | 95 +++++++++++----- src/server/sessions/oauthLoginFlowService.ts | 104 +++++++++++++----- 2 files changed, 144 insertions(+), 55 deletions(-) diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts index 24e3ab8..6bc75bb 100644 --- a/src/server/sessions/oauthLoginFlowService.test.ts +++ b/src/server/sessions/oauthLoginFlowService.test.ts @@ -1,9 +1,9 @@ -import type { OAuthLoginCallbacks } from "@earendil-works/pi-ai"; -import type { AuthStorage } from "@earendil-works/pi-coding-agent"; +import type { AuthInteraction } from "@earendil-works/pi-ai"; +import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, it, vi } from "vitest"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; -type LoginHandler = (providerId: string, callbacks: OAuthLoginCallbacks) => Promise; +type LoginHandler = (providerId: string, interaction: AuthInteraction) => Promise; afterEach(() => { vi.useRealTimers(); @@ -17,18 +17,18 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - callbacks.onAuth({ url: "https://example.test/auth", instructions: "Open it" }); - callbacks.onProgress?.("Waiting for code"); - promptValue = await callbacks.onPrompt({ message: "Paste code", placeholder: "code" }); - callbacks.onProgress?.(`Got ${promptValue}`); + runtime: fakeRuntime(async (_providerId, interaction) => { + interaction.notify({ type: "auth_url", url: "https://example.test/auth", instructions: "Open it" }); + interaction.notify({ type: "progress", message: "Waiting for code" }); + promptValue = await interaction.prompt({ type: "text", message: "Paste code", placeholder: "code" }); + interaction.notify({ type: "progress", message: `Got ${promptValue}` }); }), onComplete, }); const prompt = state.prompt; if (prompt === undefined) throw new Error("Expected prompt"); - expect(state).toMatchObject({ auth: { url: "https://example.test/auth" }, progress: ["Waiting for code"] }); + expect(state).toMatchObject({ auth: { url: "https://example.test/auth", instructions: "Open it" }, progress: ["Waiting for code"] }); expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt" }); const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123"); @@ -41,14 +41,30 @@ describe("OAuthLoginFlowService", () => { service.dispose(); }); + it("surfaces device-code events through the auth field", () => { + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + runtime: fakeRuntime(async (_providerId, interaction) => { + interaction.notify({ type: "device_code", userCode: "WXYZ-1234", verificationUri: "https://example.test/device" }); + await interaction.prompt({ type: "text", message: "Waiting" }); + }), + }); + + expect(service.get(state.flowId)).toMatchObject({ auth: { url: "https://example.test/device", instructions: "Enter code: WXYZ-1234" } }); + service.dispose(); + }); + it("round-trips select responses", async () => { let selectedValue: string | undefined; const service = new OAuthLoginFlowService(); const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - selectedValue = await callbacks.onSelect({ + runtime: fakeRuntime(async (_providerId, interaction) => { + selectedValue = await interaction.prompt({ + type: "select", message: "Choose account", options: [{ id: "work", label: "Work" }, { id: "personal", label: "Personal" }], }); @@ -73,10 +89,8 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - const manualCodeInput = callbacks.onManualCodeInput; - if (manualCodeInput === undefined) throw new Error("Expected manual-code callback"); - manualValue = await manualCodeInput(); + runtime: fakeRuntime(async (_providerId, interaction) => { + manualValue = await interaction.prompt({ type: "manual_code", message: "Paste the callback URL or authorization code" }); }), }); @@ -92,15 +106,44 @@ describe("OAuthLoginFlowService", () => { service.dispose(); }); + it("rejects a pending prompt when its own signal aborts without ending the flow", async () => { + const promptRejected = deferred(); + const service = new OAuthLoginFlowService(); + const controller = new AbortController(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + runtime: fakeRuntime(async (_providerId, interaction) => { + try { + await interaction.prompt({ type: "manual_code", message: "Paste code", signal: controller.signal }); + } catch (error) { + promptRejected.resolve(toError(error)); + } + // The flow keeps running (e.g. the callback server resolves it) until we + // resolve the follow-up prompt below. + await interaction.prompt({ type: "text", message: "Waiting for callback" }); + }), + }); + + expect(state.prompt).toMatchObject({ kind: "manual" }); + controller.abort(); + await expect(promptRejected.promise).resolves.toMatchObject({ message: "Prompt cancelled" }); + + const afterAbort = service.get(state.flowId); + expect(afterAbort.status).toBe("running"); + expect(afterAbort.prompt).toMatchObject({ kind: "prompt", message: "Waiting for callback" }); + service.dispose(); + }); + it("rejects pending prompts when cancelled", async () => { const promptRejected = deferred(); const service = new OAuthLoginFlowService(); const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { + runtime: fakeRuntime(async (_providerId, interaction) => { try { - await callbacks.onPrompt({ message: "Paste code" }); + await interaction.prompt({ type: "text", message: "Paste code" }); } catch (error) { promptRejected.resolve(toError(error)); throw error; @@ -122,9 +165,9 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { + runtime: fakeRuntime(async (_providerId, interaction) => { try { - await callbacks.onPrompt({ message: "Paste code" }); + await interaction.prompt({ type: "text", message: "Paste code" }); } catch (error) { promptRejected.resolve(toError(error)); throw error; @@ -145,8 +188,8 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - await callbacks.onPrompt({ message: "Paste code" }); + runtime: fakeRuntime(async (_providerId, interaction) => { + await interaction.prompt({ type: "text", message: "Paste code" }); }), }); @@ -165,9 +208,9 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { + runtime: fakeRuntime(async (_providerId, interaction) => { try { - await callbacks.onPrompt({ message: "Paste code" }); + await interaction.prompt({ type: "text", message: "Paste code" }); } catch (error) { promptRejected.resolve(toError(error)); throw error; @@ -187,8 +230,10 @@ describe("OAuthLoginFlowService", () => { }); }); -function fakeAuthStorage(login: LoginHandler): Pick { - return { login }; +function fakeRuntime(login: LoginHandler): Pick { + return { + login: (providerId, _type, interaction) => login(providerId, interaction).then(() => ({ type: "oauth", refresh: "r", access: "a", expires: 0 })), + }; } async function flushAsyncLogin(): Promise { diff --git a/src/server/sessions/oauthLoginFlowService.ts b/src/server/sessions/oauthLoginFlowService.ts index 02035b5..cf150bf 100644 --- a/src/server/sessions/oauthLoginFlowService.ts +++ b/src/server/sessions/oauthLoginFlowService.ts @@ -1,15 +1,16 @@ import crypto from "node:crypto"; -import type { OAuthLoginCallbacks, OAuthSelectPrompt, OAuthPrompt } from "@earendil-works/pi-ai"; -import type { AuthStorage } from "@earendil-works/pi-coding-agent"; +import type { AuthEvent, AuthInteraction, AuthPrompt } from "@earendil-works/pi-ai"; +import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js"; -type OAuthLoginStorage = Pick; +/** The single runtime capability this service drives — narrowed for testable DI. */ +type OAuthLoginRuntime = Pick; type TimerHandle = ReturnType; interface PendingOAuthRequest { requestId: string; allowEmpty: boolean; - resolve: (value: string | undefined) => void; + resolve: (value: string) => void; reject: (error: Error) => void; } @@ -46,7 +47,7 @@ export class OAuthLoginFlowService { start(options: { providerId: string; providerName: string; - authStorage: OAuthLoginStorage; + runtime: OAuthLoginRuntime; onComplete?: () => void; }): OAuthFlowState { const flowId = crypto.randomUUID(); @@ -66,28 +67,16 @@ export class OAuthLoginFlowService { this.flows.set(flowId, record); this.scheduleRunningExpiry(record); - const callbacks: OAuthLoginCallbacks = { + // Adapt the pi-ai AuthInteraction contract onto the web-UI flow state: + // `prompt()` returns the entered/selected string; `notify()` surfaces + // out-of-band login events (auth URL, device code, progress). + const interaction: AuthInteraction = { signal: abort.signal, - onAuth: (info) => { - if (!this.isCurrentRunning(record)) return; - this.updateState(record, { ...record.state, auth: info }); - }, - // Device-code flows have no redirect URL; reuse the auth field so the web UI - // shows the verification link and user code without a dedicated API shape. - onDeviceCode: (info) => { - if (!this.isCurrentRunning(record)) return; - this.updateState(record, { ...record.state, auth: { url: info.verificationUri, instructions: `Enter code: ${info.userCode}` } }); - }, - onPrompt: (prompt) => this.waitForPrompt(record, prompt, "prompt"), - onManualCodeInput: () => this.waitForPrompt(record, { message: "Paste the callback URL or authorization code", allowEmpty: false }, "manual"), - onSelect: (prompt) => this.waitForSelect(record, prompt), - onProgress: (message) => { - if (!this.isCurrentRunning(record)) return; - this.updateState(record, { ...record.state, progress: [...record.state.progress, message] }); - }, + prompt: (prompt) => this.handlePrompt(record, prompt), + notify: (event) => { this.handleEvent(record, event); }, }; - void options.authStorage.login(options.providerId, callbacks) + void options.runtime.login(options.providerId, "oauth", interaction) .then(() => { if (!this.isCurrentRunning(record)) return; record.pending = undefined; @@ -147,14 +136,51 @@ export class OAuthLoginFlowService { this.flows.clear(); } - private waitForPrompt(record: OAuthFlowRecord, prompt: OAuthPrompt, kind: "prompt" | "manual"): Promise { + private handlePrompt(record: OAuthFlowRecord, prompt: AuthPrompt): Promise { + if (prompt.type === "select") { + return this.waitForSelect(record, prompt.message, prompt.options, prompt.signal); + } + // `manual_code` is the paste-back path for callback-server flows; text/secret + // are ordinary interactive entry. Both map to the single web-UI prompt shape. + const kind = prompt.type === "manual_code" ? "manual" : "prompt"; + return this.waitForPrompt(record, { + message: prompt.message, + ...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }), + ...(prompt.signal === undefined ? {} : { signal: prompt.signal }), + }, kind); + } + + private handleEvent(record: OAuthFlowRecord, event: AuthEvent): void { + if (!this.isCurrentRunning(record)) return; + switch (event.type) { + case "auth_url": + this.updateState(record, { ...record.state, auth: { url: event.url, ...(event.instructions === undefined ? {} : { instructions: event.instructions }) } }); + return; + // Device-code flows have no redirect URL; reuse the auth field so the web UI + // shows the verification link and user code without a dedicated API shape. + case "device_code": + this.updateState(record, { ...record.state, auth: { url: event.verificationUri, instructions: `Enter code: ${event.userCode}` } }); + return; + case "info": + case "progress": + this.updateState(record, { ...record.state, progress: [...record.state.progress, event.message] }); + return; + } + } + + private waitForPrompt(record: OAuthFlowRecord, prompt: { message: string; placeholder?: string; signal?: AbortSignal }, kind: "prompt" | "manual"): Promise { return new Promise((resolve, reject) => { if (!this.isCurrentRunning(record)) { reject(new Error("Login cancelled")); return; } + if (prompt.signal?.aborted === true) { + reject(new Error("Prompt cancelled")); + return; + } const requestId = crypto.randomUUID(); - record.pending = { requestId, allowEmpty: prompt.allowEmpty === true, resolve: (value) => { resolve(value ?? ""); }, reject }; + record.pending = { requestId, allowEmpty: false, resolve, reject }; + this.bindPromptSignal(record, requestId, prompt.signal); const base = withoutInteraction(record.state); this.updateState(record, { ...base, @@ -163,26 +189,44 @@ export class OAuthLoginFlowService { message: prompt.message, kind, ...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }), - ...(prompt.allowEmpty === true ? { allowEmpty: true } : {}), }, }); }); } - private waitForSelect(record: OAuthFlowRecord, prompt: OAuthSelectPrompt): Promise { + private waitForSelect(record: OAuthFlowRecord, message: string, promptOptions: readonly { id: string; label: string; description?: string }[], signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (!this.isCurrentRunning(record)) { reject(new Error("Login cancelled")); return; } + if (signal?.aborted === true) { + reject(new Error("Prompt cancelled")); + return; + } const requestId = crypto.randomUUID(); - const options: CommandOption[] = prompt.options.map((option) => ({ value: option.id, label: option.label })); + const options: CommandOption[] = promptOptions.map((option) => ({ value: option.id, label: option.label })); record.pending = { requestId, allowEmpty: true, resolve, reject }; + this.bindPromptSignal(record, requestId, signal); const base = withoutInteraction(record.state); - this.updateState(record, { ...base, select: { requestId, message: prompt.message, options } }); + this.updateState(record, { ...base, select: { requestId, message, options } }); }); } + // A prompt may carry its own AbortSignal (e.g. a manual_code prompt raced + // against a callback server). When it fires, drop just that pending request + // and clear the interaction from state — the overall login keeps running. + private bindPromptSignal(record: OAuthFlowRecord, requestId: string, signal?: AbortSignal): void { + if (signal === undefined) return; + signal.addEventListener("abort", () => { + const pending = record.pending; + if (pending?.requestId !== requestId) return; + record.pending = undefined; + if (this.isCurrentRunning(record)) this.updateState(record, withoutInteraction(record.state)); + pending.reject(new Error("Prompt cancelled")); + }, { once: true }); + } + private isCurrentRunning(record: OAuthFlowRecord): boolean { return this.flows.get(record.flowId) === record && record.state.status === "running"; } From 7543982e3b3eec1b72089d582a6f832a851023a8 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 21:10:32 +0200 Subject: [PATCH 10/26] Relay issue-62-authstorage: leg 4 handoff (slice 3 complete) --- relays/issue-62-authstorage/log.md | 50 +++++++++++++++++ relays/issue-62-authstorage/status.md | 81 +++++++++++++++++---------- 2 files changed, 100 insertions(+), 31 deletions(-) diff --git a/relays/issue-62-authstorage/log.md b/relays/issue-62-authstorage/log.md index 0a0da04..aa83fd6 100644 --- a/relays/issue-62-authstorage/log.md +++ b/relays/issue-62-authstorage/log.md @@ -213,3 +213,53 @@ ts`(6) slice 4; test/support files slice 5. **Handoff:** spawning leg 4 (slice 3, oauthLoginFlowService.ts). Sessiond restart from leg 2 still pending — carried forward, not cleared. + +## Leg 4 — slice 3: oauthLoginFlowService.ts migration (commit `1c3d6db`) + +**What:** Reimplemented `OAuthLoginFlowService` against the pi-ai +`AuthInteraction` contract and rewrote its test. + +- `start()` now takes `runtime: Pick` instead of + `authStorage: Pick`; login driven via + `runtime.login(providerId, "oauth", interaction)`. Resolves the + `authService.ts` line-83 tsc error (authService.ts now at 0 errors). +- Built a single `AuthInteraction` adapter (`{ signal, prompt, notify }`) + replacing the six `OAuthLoginCallbacks` (`onAuth`/`onDeviceCode`/`onPrompt`/ + `onManualCodeInput`/`onSelect`/`onProgress`). +- **Mapping decisions (verified carefully — riskiest slice):** + - `prompt(AuthPrompt)` dispatches on `type`: `select` → `waitForSelect` + (options `{id,label,description?}` → CommandOption `{value:id,label}`, + resolves chosen id); `manual_code` → web-UI prompt kind `manual`; + `text`/`secret` → web-UI prompt kind `prompt`. Old code special-cased + `onManualCodeInput` with a hardcoded message; now the provider supplies the + `manual_code` message, which is more correct. + - `notify(AuthEvent)`: `auth_url` → `auth:{url,instructions?}`; `device_code` + → reuse `auth` field (`url: verificationUri`, instructions + `"Enter code: "`) exactly as the old `onDeviceCode` did; + `info`+`progress` → append `message` to `progress` (old code only had + `onProgress`; `info` folds in naturally). + - Old `OAuthPrompt.allowEmpty`/`placeholder` handling: the new `AuthPrompt` + has no `allowEmpty`, so interactive prompts are always required + (`allowEmpty:false`); `select` keeps `allowEmpty:true`. Placeholder still + forwarded when present. +- **New behavior:** per-prompt `AuthPrompt.signal` now aborts just that pending + request (rejects `"Prompt cancelled"`, clears the interaction from state) + without ending the overall flow — the documented `manual_code`-vs-callback + race. Added `bindPromptSignal` + a dedicated test for it. +- **Test:** rewrote `oauthLoginFlowService.test.ts` with a `fakeRuntime` + `login` double (returns a stub oauth credential). Replaced the old + device-code-via-onDeviceCode coverage with an explicit `notify` device_code + test and a per-prompt-signal-abort test. 9 tests pass; both files lint clean. + +**tsc:** 28 → 26 errors. `authService.ts` = 0. Remaining: slice 4 +(`sessiond.ts` 1, `piSessionService.ts` 6) and slice 5 test/support files +(`authService.test.ts` 10, `.testSupport.ts` 3, `.promptQueue.test.ts` 2, +`.warnings.test.ts` 4). + +**Status:** updated (current position, leg tracking → last leg 4 / next leg 5, +next task = slice 4). Committed with `--no-verify` (migration not yet +verify-green, per charter). + +**Blockers:** none. Sessiond-restart-pending note still ACTIVE (unchanged; +this slice did not touch the daemon path, but slice 1 did). Handing off to +leg 5 (slice 4). diff --git a/relays/issue-62-authstorage/status.md b/relays/issue-62-authstorage/status.md index ac349b4..640ca34 100644 --- a/relays/issue-62-authstorage/status.md +++ b/relays/issue-62-authstorage/status.md @@ -1,6 +1,31 @@ # Relay status — issue-62-authstorage ## Current position +Slice 3 (`oauthLoginFlowService.ts` migration) complete and committed (`1c3d6db`). +`OAuthLoginFlowService` is reimplemented against the pi-ai `AuthInteraction` +contract (`{ signal?, prompt(AuthPrompt), notify(AuthEvent) }`); the old +`OAuthLoginCallbacks`/`AuthStorage` imports are gone. `start()` now takes a +`ModelRuntime` (narrowed to `Pick`) instead of +`authStorage`, and drives login via `runtime.login(providerId, "oauth", +interaction)`. Mapping: `AuthPrompt` `text`/`secret`/`manual_code` → web-UI +`prompt` (kind `prompt`, `manual_code` → kind `manual`); `select` → web-UI +`select` (options `{id,label}` → `{value,label}`, returns chosen id); +`AuthEvent` `auth_url` → `auth: {url, instructions?}`; `device_code` → reuse +`auth` field (`url: verificationUri`, `instructions: "Enter code: "`); +`info`/`progress` → append `message` to `progress`. Per-prompt +`AuthPrompt.signal` now aborts just that pending request (rejects +`"Prompt cancelled"`) without ending the overall flow — needed because a +`manual_code` prompt can race a callback server. `oauthLoginFlowService.test.ts` +rewritten to the new contract via a `fakeRuntime` login double; **9 tests pass**, +files lint clean. + +`npx tsc --noEmit` now reports **26 errors** (down from 28). `authService.ts` +is now at **0 errors** (as predicted). Remaining errors are all slice 4/5: +`sessiond.ts` (1) + `piSessionService.ts` (6) = slice 4; +`authService.test.ts` (10), `piSessionService.testSupport.ts` (3), +`.promptQueue.test.ts` (2), `.warnings.test.ts` (4) = slice 5. + +### Prior position (slice 2, leg 3, commit `d09d7cc`) Slice 2 (`authProviderOptions.ts` migration) complete and committed (`d09d7cc`). `authProviderOptions.ts` now derives options from a runtime-shaped `AuthProviderRuntime` interface (`getProviders()` + `listCredentials()` + @@ -51,40 +76,34 @@ Remaining errors otherwise live in slices 2/3/4 files and all test/support files (slice 5). ## Leg tracking -- **Last completed leg:** 3 (slice 2 — authProviderOptions.ts migration). -- **Next leg to run:** 4. +- **Last completed leg:** 4 (slice 3 — oauthLoginFlowService.ts migration). +- **Next leg to run:** 5. ## Next task -Run **charter slice 3 (`oauthLoginFlowService.ts` migration)** as leg 4. This -is the riskiest slice — verify the prompt/select/device-code/auth_url mapping -carefully. Concretely: -- Reimplement `oauthLoginFlowService.ts` against the pi-ai `AuthInteraction` - contract (`{ signal?, prompt(prompt: AuthPrompt): Promise, - notify(event: AuthEvent): void }`) instead of the removed - `OAuthLoginCallbacks` shape (`onAuth`/`onDeviceCode`/`onPrompt`/ - `onManualCodeInput`/`onSelect`/`onProgress`). Types live in - `node_modules/@earendil-works/pi-ai/dist/auth/types.d.ts`. -- `AuthPrompt` is a discriminated union: `text` / `secret` / `select` - (`options: { id, label, description? }[]`, returns the chosen option id) / - `manual_code`. `AuthEvent` is `info` / `auth_url` (`{ url, instructions? }`) - / `device_code` (`{ userCode, verificationUri, intervalSeconds?, - expiresInSeconds? }`) / `progress`. Map these onto the existing web-UI flow - state fields (see the current `oauthLoginFlowService.ts` prompt/select/ - device-code/auth_url handling). -- Change `OAuthLoginFlowService.start` to accept `runtime` (the - `ModelRuntime`) instead of `authStorage`, and drive login via - `runtime.login(providerId, "oauth", interaction)` where `interaction` is the - adapter you build. `authService.ts` already calls - `OAuthLoginFlowService.start({ ..., runtime: this.runtime })` (this is the - line-83 tsc error). Also update `oauthLoginFlowService.test.ts`. -- After slice 3, `authService.ts` should reach 0 errors. `sessiond.ts` + - `piSessionService.ts` (slice 4) and the remaining test/support files - (slice 5) stay until their slices. +Run **charter slice 4 (`piSessionService.ts` migration)** as leg 5. Concretely +(see assessment §5.4 and §3.3): +- Pass `modelRuntime` to `createAgentSessionServices` (instead of the old + `modelRegistry`); update the `PiAgentSession` type accordingly. +- `sessiond.ts` already passes `modelRuntime: auth.runtime` into + `PiSessionService` (from slice 1) but `PiSessionServiceDependencies` still + declares `modelRegistry` — reconcile the dependency shape so the sessiond + wiring typechecks (this is the remaining `sessiond.ts` error). +- Switch `anthropicSubscriptionWarning` to `readStoredCredential(providerId, + authPath?)` (the sync credential read replacing the old AuthStorage-based + read). +- Target: after slice 4, `sessiond.ts` and `piSessionService.ts` reach 0 + errors; only the test/support files (slice 5) remain. +- **Note:** `piSessionService.ts` is a session-daemon path — keep the + sessiond-restart-pending note current (it is already active from slice 1). -If slice 3 is already done when you arrive, apply the charter's task-selection -policy: pick the lowest-numbered incomplete slice (4 → 6). Slice 4 unblocks the -remaining `sessiond.ts` / `piSessionService.ts` cross-slice errors; slice 5 -finalizes tests; slice 6 adds the changeset + final verify. +Then slice 5 migrates all test doubles to `InMemoryCredentialStore` + +`ModelRuntime.create` and gets `npm run verify` green (currently the failing +test/support files are `authService.test.ts` (10), `piSessionService.testSupport.ts` +(3), `.promptQueue.test.ts` (2), `.warnings.test.ts` (4)); slice 6 adds the +changeset + final verify + cleanup. + +If slice 4 is already done when you arrive, apply the charter's task-selection +policy: pick the lowest-numbered incomplete slice (5 → 6). ### Build/tooling note (important for every leg) **Update (leg 2):** the human reports `/tmp` is now fully usable again, so the From 4ccd4f81fce8acd34388383a96a7d538d58e29cf Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 21:32:02 +0200 Subject: [PATCH 11/26] Migrate piSessionService to ModelRuntime (slice 4) Pass modelRuntime to createAgentSessionServices instead of authStorage + modelRegistry; carry ModelRuntime on PiAgentSession; make modelRuntime a required PiSessionService dependency (sessiond already injects auth.runtime). Switch anthropicSubscriptionWarning to readStoredCredential, and rederive model reads (availableModels/setModel/syncCurrentModelAuthWarning) via the runtime (getAvailableSnapshot/getModel/hasConfiguredAuth). sessiond.ts and piSessionService.ts now typecheck; only slice-5 test/support files remain. --- src/server/sessions/piSessionService.ts | 50 ++++++++++++------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 6b26a43..5899e3c 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -1,20 +1,21 @@ import { statSync } from "node:fs"; +import { join } from "node:path"; import { open, readFile, writeFile } from "node:fs/promises"; import type { ImageContent } from "@earendil-works/pi-ai"; import type { StreamFn } from "@earendil-works/pi-agent-core"; import { - AuthStorage, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, - ModelRegistry, + readStoredCredential, SessionManager, type AgentSessionRuntimeDiagnostic, type AgentSessionServices, type CreateAgentSessionRuntimeFactory, type EditToolDetails, + type ModelRuntime, type ResourceDiagnostic, } from "@earendil-works/pi-coding-agent"; import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionStreamSnapshot, SessionUiEvent } from "../types.js"; @@ -26,7 +27,6 @@ import { SessionCommandService } from "./sessionCommandService.js"; import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js"; import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js"; import type { ActiveSession } from "./sessionRuntimeStore.js"; -import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js"; import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; import { computeEditPreview, type EditPreviewResult } from "./editPreview.js"; import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; @@ -34,6 +34,7 @@ import { parsePromptAttachments } from "../../shared/promptAttachments.js"; import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionWarning } from "../../shared/apiTypes.js"; import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js"; +import { type AuthChange } from "./authService.js"; import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js"; import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js"; import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js"; @@ -171,7 +172,6 @@ interface BulkDeletePlanItem { } type AgentModel = NonNullable; -type ModelRegistryInstance = ReturnType; export interface PiSessionManager { getCwd(): string; @@ -208,7 +208,7 @@ interface PiExtensionBindings { } export interface PiAgentSession { - modelRegistry: ModelRegistryInstance; + modelRuntime: ModelRuntime; /** * Narrow read/write of the SDK `SettingsManager`, exposing only the warning * suppression flags consumed here (e.g. `anthropicExtraUsage`). Used to gate @@ -383,11 +383,12 @@ const ANTHROPIC_EXTRA_USAGE_DISMISS_ID = "anthropicExtraUsage"; * synchronous live status computation. */ export function anthropicSubscriptionWarning( - session: Pick, + session: Pick, + authPath?: string, ): SessionWarning | undefined { if (session.settingsManager.getWarnings().anthropicExtraUsage === false) return undefined; if (session.model?.provider !== "anthropic") return undefined; - const credential = session.modelRegistry.authStorage.get("anthropic"); + const credential = readStoredCredential("anthropic", authPath); if (credential === undefined) return undefined; const isSubscriptionAuth = credential.type === "oauth" ? true @@ -487,14 +488,13 @@ export function createPiWebCustomToolDefinitions( } function createDefaultRuntimeFactory( - authStorage: AuthStorage, - modelRegistry: ModelRegistryInstance, + modelRuntime: ModelRuntime, sessionManagers: Pick, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps, ): PiWebCreateAgentSessionRuntimeFactory { return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => { - const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry }); + const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime }); const resolvedDelegationToolsEnabled = delegationToolsEnabled ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers); const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions); @@ -539,7 +539,7 @@ export interface PiSessionServiceDependencies { archiveStore?: SessionArchiveRepository; createRuntime?: PiWebCreateAgentSessionRuntimeFactory; createAgentRuntime?: CreateAgentRuntime; - modelRegistry?: ModelRegistryInstance; + modelRuntime: ModelRuntime; heartbeatIntervalMs?: number; workspaceActivity?: Pick; /** @@ -589,7 +589,7 @@ export class PiSessionService implements SessionRouteService { private readonly sessionManager: PiSessionManagerGateway; private readonly createRuntime: PiWebCreateAgentSessionRuntimeFactory; private readonly createAgentRuntime: CreateAgentRuntime; - private readonly modelRegistry: ModelRegistryInstance; + private readonly modelRuntime: ModelRuntime; private readonly workspaceActivity: Pick | undefined; private readonly spawnTargets: SpawnTargetResolver | undefined; private readonly logger: PiSessionLogger; @@ -599,7 +599,7 @@ export class PiSessionService implements SessionRouteService { this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); this.agentDir = deps.agentDir; this.sessionManager = deps.sessionManager; - this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir); + this.modelRuntime = deps.modelRuntime; this.spawnTargets = deps.spawnTargets; this.logger = deps.logger ?? noopLogger; this.now = deps.now ?? (() => new Date()); @@ -607,8 +607,7 @@ export class PiSessionService implements SessionRouteService { // also require the spawn capability (they share its project-scope resolver). const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true; this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory( - this.modelRegistry.authStorage, - this.modelRegistry, + this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : { @@ -1159,22 +1158,22 @@ export class PiSessionService implements SessionRouteService { async availableModels(ref: PiSessionLookup): Promise { const session = await this.getOrOpen(ref); - session.modelRegistry.refresh(); + await session.modelRuntime.refresh(); const models = session.scopedModels.length > 0 ? session.scopedModels.map((scoped) => scoped.model) - : session.modelRegistry.getAvailable(); + : session.modelRuntime.getAvailableSnapshot(); return models.map(modelToClientModel); } async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise { await this.assertWritable(ref); const session = await this.getOrOpen(ref); - session.modelRegistry.refresh(); + await session.modelRuntime.refresh(); const candidates = session.scopedModels.length > 0 ? session.scopedModels.map((scoped) => scoped.model) - : session.modelRegistry.getAvailable(); + : session.modelRuntime.getAvailableSnapshot(); const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId) - ?? session.modelRegistry.find(provider, modelId); + ?? session.modelRuntime.getModel(provider, modelId); if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`); await session.setModel(model); this.publishActivity(session, `model: ${model.id}`, "idle", model.provider); @@ -2020,10 +2019,11 @@ export class PiSessionService implements SessionRouteService { } applyAuthChange(change: AuthChange = {}): void { - this.modelRegistry.refresh(); + // The shared model runtime is refreshed by AuthService before it emits the + // change (and every session shares that runtime), so no refresh is needed + // here — this keeps the subscribe callback synchronous. for (const active of this.active.values()) { const { session } = active.runtime; - session.modelRegistry.refresh(); this.syncCurrentModelAuthWarning(session, change.removedProviderId); this.publishStatus(session); } @@ -2034,9 +2034,9 @@ export class PiSessionService implements SessionRouteService { if (model === undefined) return; if (model.provider === "unknown" && model.id === "unknown") return; const warningKey = authLossWarningKey(session.sessionId, model.provider, model.id); - const registered = session.modelRegistry.find(model.provider, model.id); + const registered = session.modelRuntime.getModel(model.provider, model.id); if (registered === undefined) return; - if (session.modelRegistry.hasConfiguredAuth(registered)) { + if (session.modelRuntime.hasConfiguredAuth(model.provider)) { this.authLossWarnings.delete(warningKey); return; } @@ -2182,7 +2182,7 @@ export class PiSessionService implements SessionRouteService { private warningsForSession(session: PiAgentSession): SessionWarning[] { const runtime = this.active.get(session.sessionId)?.runtime; const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime); - const anthropic = anthropicSubscriptionWarning(session); + const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json")); if (anthropic !== undefined) warnings.push(anthropic); return warnings; } From 0706cc26a5529e789267bb296cf04ce92ad98373 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 21:33:14 +0200 Subject: [PATCH 12/26] relay(issue-62-authstorage): leg 5 status/log (slice 4 done) --- relays/issue-62-authstorage/log.md | 50 ++++++++++++ relays/issue-62-authstorage/status.md | 112 +++++++++++++++++++------- 2 files changed, 134 insertions(+), 28 deletions(-) diff --git a/relays/issue-62-authstorage/log.md b/relays/issue-62-authstorage/log.md index aa83fd6..14aa287 100644 --- a/relays/issue-62-authstorage/log.md +++ b/relays/issue-62-authstorage/log.md @@ -263,3 +263,53 @@ verify-green, per charter). **Blockers:** none. Sessiond-restart-pending note still ACTIVE (unchanged; this slice did not touch the daemon path, but slice 1 did). Handing off to leg 5 (slice 4). + +--- + +## Leg 5 — slice 4: piSessionService.ts migration (commit `4ccd4f8`) + +**What:** Migrated `src/server/sessions/piSessionService.ts` to the new +`ModelRuntime` API. +- `createDefaultRuntimeFactory` now takes a `ModelRuntime` and passes + `modelRuntime` to `createAgentSessionServices({ cwd, agentDir, modelRuntime })` + (dropped the `authStorage` + `modelRegistry` args). +- `PiAgentSession.modelRegistry: ModelRegistryInstance` → `modelRuntime: + ModelRuntime`. Removed the `ModelRegistryInstance` type alias and the + `AuthStorage`/`ModelRegistry` SDK imports; added `type ModelRuntime` + + `readStoredCredential` imports and `join` from node:path. `authService.js` + import reduced to just `AuthChange` (dropped `createModelRegistryForAgentDir`). +- `anthropicSubscriptionWarning(session, authPath?)`: reads via + `readStoredCredential("anthropic", authPath)`; param narrowed to + `Pick`. `warningsForSession` + passes `join(this.agentDir, "auth.json")`. +- Model reads rederived onto the runtime: `availableModels`/`setModel` → + `await modelRuntime.refresh()` + `getAvailableSnapshot()` + `getModel(...)`; + `syncCurrentModelAuthWarning` → `getModel(...)` + + `hasConfiguredAuth(providerId)`. +- `applyAuthChange` no longer refreshes a registry (shared runtime is refreshed + by AuthService before emit; all sessions share it), keeping the subscribe + callback synchronous. + +**Decision:** made `modelRuntime` a **required** `PiSessionServiceDependencies` +field rather than keeping an optional `modelRegistry?`-style fallback. The old +fallback built a registry synchronously in the constructor; `ModelRuntime` can +only be created by the async `ModelRuntime.create`, which cannot run in a +constructor. `sessiond.ts` already injects `modelRuntime: auth.runtime` (slice +1), so production wiring is unaffected. Consequence: the slice-5 test surface is +wider than the four files the assessment listed — every `new +PiSessionService(...)` in tests now needs `modelRuntime`, and `fakeRuntime`/the +`TestSession` type in `testSupport.ts` must expose `modelRuntime`. Documented in +status.md "Next task". + +**Result:** `npx tsc --noEmit` — `sessiond.ts` and `piSessionService.ts` at 0 +errors; all production code migrated (tsc output filtered to non-test/support +files is empty). Remaining errors are slice-5 test/support only: +authService.test (10), testSupport (4), warnings (5), promptQueue (17), +lifecycle (19), archiveCleanup (9), spawnSession (3), spawnSubsession (18), +sessionRoutes (1). `piSessionService.ts` lints clean. Committed `--no-verify` +(migration not yet verify-green, per charter). + +**Blockers:** none. **Sessiond-restart-pending note still ACTIVE** — this slice +added `piSessionService.ts` (a session-daemon path) to the pending-restart +surface; do not clear the note. Handing off to leg 6 (slice 5: tests + +testSupport). diff --git a/relays/issue-62-authstorage/status.md b/relays/issue-62-authstorage/status.md index 640ca34..1f6d385 100644 --- a/relays/issue-62-authstorage/status.md +++ b/relays/issue-62-authstorage/status.md @@ -1,6 +1,39 @@ # Relay status — issue-62-authstorage ## Current position +Slice 4 (`piSessionService.ts` migration) complete and committed (`4ccd4f8`). +`piSessionService.ts` now uses the new `ModelRuntime` API end to end: +- `createDefaultRuntimeFactory(modelRuntime, ...)` passes `modelRuntime` to + `createAgentSessionServices({ cwd, agentDir, modelRuntime })` (no more + `authStorage` + `modelRegistry`). +- `PiAgentSession.modelRegistry` → `PiAgentSession.modelRuntime: ModelRuntime`. +- `anthropicSubscriptionWarning(session, authPath?)` now reads via + `readStoredCredential("anthropic", authPath)` (sync); `warningsForSession` + passes `join(this.agentDir, "auth.json")`. Its `session` param narrowed to + `Pick` (no longer needs the + registry). +- Model reads rederived onto the runtime: `availableModels`/`setModel` use + `await modelRuntime.refresh()` + `getAvailableSnapshot()` + `getModel(...)`; + `syncCurrentModelAuthWarning` uses `getModel(...)` + + `hasConfiguredAuth(providerId)`. +- `applyAuthChange` no longer refreshes a registry (the shared runtime is + refreshed by AuthService before it emits, and all sessions share that + runtime), so the `auth.subscribe` callback stays synchronous. +- **`modelRuntime` is now a REQUIRED `PiSessionServiceDependencies` field** + (the old `modelRegistry?` fallback used a *sync* `ModelRegistry.create`; a + `ModelRuntime` can only be built by the async `ModelRuntime.create`, which + can't run inside a constructor). `sessiond.ts` already injects + `modelRuntime: auth.runtime` (slice 1), so it typechecks unchanged. +- Dropped the `AuthStorage` / `ModelRegistry` imports and the + `createModelRegistryForAgentDir` import (only `AuthChange` is still imported + from `authService.js`). + +`npx tsc --noEmit`: **`sessiond.ts` and `piSessionService.ts` are at 0 errors** +(production code is fully migrated; `grep -vE '\.test\.ts|testSupport\.ts'` on +tsc output is empty). All remaining errors are slice-5 test/support files. +`piSessionService.ts` lints clean. + +### Prior position (slice 3, leg 4, commit `1c3d6db`) Slice 3 (`oauthLoginFlowService.ts` migration) complete and committed (`1c3d6db`). `OAuthLoginFlowService` is reimplemented against the pi-ai `AuthInteraction` contract (`{ signal?, prompt(AuthPrompt), notify(AuthEvent) }`); the old @@ -76,34 +109,56 @@ Remaining errors otherwise live in slices 2/3/4 files and all test/support files (slice 5). ## Leg tracking -- **Last completed leg:** 4 (slice 3 — oauthLoginFlowService.ts migration). -- **Next leg to run:** 5. +- **Last completed leg:** 5 (slice 4 — piSessionService.ts migration). +- **Next leg to run:** 6. ## Next task -Run **charter slice 4 (`piSessionService.ts` migration)** as leg 5. Concretely -(see assessment §5.4 and §3.3): -- Pass `modelRuntime` to `createAgentSessionServices` (instead of the old - `modelRegistry`); update the `PiAgentSession` type accordingly. -- `sessiond.ts` already passes `modelRuntime: auth.runtime` into - `PiSessionService` (from slice 1) but `PiSessionServiceDependencies` still - declares `modelRegistry` — reconcile the dependency shape so the sessiond - wiring typechecks (this is the remaining `sessiond.ts` error). -- Switch `anthropicSubscriptionWarning` to `readStoredCredential(providerId, - authPath?)` (the sync credential read replacing the old AuthStorage-based - read). -- Target: after slice 4, `sessiond.ts` and `piSessionService.ts` reach 0 - errors; only the test/support files (slice 5) remain. -- **Note:** `piSessionService.ts` is a session-daemon path — keep the - sessiond-restart-pending note current (it is already active from slice 1). +Run **charter slice 5 (tests + testSupport)** as leg 6: migrate all test +doubles off `AuthStorage.inMemory(...)` / `ModelRegistry.create|inMemory(...)` +to the pi-ai `InMemoryCredentialStore` + `await ModelRuntime.create({ +credentials })`, and get `npm run verify` green. Follow the testing-guide skill +(async construction seams, no over-mocking of the SDK). -Then slice 5 migrates all test doubles to `InMemoryCredentialStore` + -`ModelRuntime.create` and gets `npm run verify` green (currently the failing -test/support files are `authService.test.ts` (10), `piSessionService.testSupport.ts` -(3), `.promptQueue.test.ts` (2), `.warnings.test.ts` (4)); slice 6 adds the -changeset + final verify + cleanup. +**Scope note (important):** slice 4 made `modelRuntime` a *required* +`PiSessionServiceDependencies` field (see Current position for why). That means +the slice-5 test surface is LARGER than the four files originally listed in the +assessment. Current `npx tsc --noEmit` failing files (all tests/support): +- `piSessionService.testSupport.ts` (4) — `fakeRuntime` builds + `modelRegistry: ModelRegistry.create(AuthStorage.inMemory())`; the + `TestSession` type still has `modelRegistry`. Give the fake a `modelRuntime` + (e.g. `await ModelRuntime.create({ credentials: new InMemoryCredentialStore() })` + — note this makes `fakeRuntime` async, which ripples into its callers) and + update `TestSession`. This is the central helper; fixing it first will clear + many downstream errors. +- `authService.test.ts` (10) — already partly slice-1/2/3 debt. +- `piSessionService.warnings.test.ts` (5) — `anthropicSubscriptionWarning` no + longer takes a registry; it now reads `readStoredCredential("anthropic", + authPath)`. Tests that build credentials via `authStorage.set(...)` must + instead write an `auth.json` (temp dir) and pass its path, OR the test seam + must be reconsidered. `SubscriptionSession` type ref to `modelRegistry` is + gone. Check whether `readStoredCredential` can be pointed at a temp authPath + cleanly; if not, consider whether the warning fn needs a small injectable + credential-read seam (raise via intervention if the API can't support the + test without contortion). +- `piSessionService.promptQueue.test.ts` (17), `.lifecycle.test.ts` (19), + `.archiveCleanup.test.ts` (9), `.spawnSession.test.ts` (3), + `.spawnSubsession.test.ts` (18), `sessionRoutes.test.ts` (1) — mostly the + new required `modelRuntime` dep on `new PiSessionService(...)` plus + `fakeRuntime`/`ModelRegistry.inMemory` usages. Many of these should clear + automatically once `testSupport.ts` provides a shared `modelRuntime` helper + and the `PiSessionService` test-construction path supplies it. -If slice 4 is already done when you arrive, apply the charter's task-selection -policy: pick the lowest-numbered incomplete slice (5 → 6). +Suggested approach: add a small shared test helper (e.g. +`await createTestModelRuntime()` wrapping `ModelRuntime.create({ credentials: +new InMemoryCredentialStore(...) })`) in `testSupport.ts`, thread it into +`fakeRuntime` and the `new PiSessionService(...)` call sites, then work file by +file until `npm run verify` (typecheck + lint + knip + test) is green. + +Then slice 6 adds the `.changeset/*.md` fragment, runs the full `npm run +verify`, and does final cleanup (ASSESSMENT stays). + +If slice 5 is already done when you arrive, apply the charter's task-selection +policy: pick the lowest-numbered incomplete slice (6). ### Build/tooling note (important for every leg) **Update (leg 2):** the human reports `/tmp` is now fully usable again, so the @@ -142,10 +197,11 @@ charter's Handover section. ## Blockers / intervention state None. Known constraints: - **Sessiond restart pending (ACTIVE):** slice 1 (leg 2, commit `e37148c`) - changed `sessiond.ts` + the session-daemon auth construction path. Per - AGENTS.md the human must **manually restart the sessiond service** for these - changes to take effect once the migration lands. Keep this note until the - human confirms the restart. + changed `sessiond.ts` + the session-daemon auth construction path; slice 4 + (leg 5, commit `4ccd4f8`) added `piSessionService.ts` (a session-daemon path) + to this surface. Per AGENTS.md the human must **manually restart the sessiond + service** for these changes to take effect once the migration lands. Keep + this note until the human confirms the restart. - `/tmp` disk-quota issue is resolved (human confirmed usable) — see the Build/tooling note above. - node_modules is installed (gitignored) at 0.80.10; a fresh `npm install` is From d0cc55cce38e51d07fa31ab8f7cbab03f711cce1 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 21:56:53 +0200 Subject: [PATCH 13/26] Migrate test doubles + testSupport to ModelRuntime/InMemoryCredentialStore (slice 5) Replace AuthStorage.inMemory / ModelRegistry.create|inMemory across all test and support code with the pi-ai InMemoryCredentialStore + async ModelRuntime.create({ credentials }). Add shared test-runtime seams (createTestModelRuntime, testModelRuntime, seedCredential) in testSupport.ts and thread modelRuntime into fakeRuntime and every PiSessionService construction (now a required dependency). Rework the anthropic subscription warning tests onto a temp auth.json seam read via readStoredCredential, and the auth-loss warning test onto a live credential store + runtime refresh. Make getLoginProviderOptions synchronous and fix associated await/lint sites. npm run verify green (typecheck + lint + knip + 1390 tests). --- .../sessions/authProviderOptions.test.ts | 4 +- src/server/sessions/authProviderOptions.ts | 2 +- src/server/sessions/authRoutes.ts | 8 +- src/server/sessions/authService.test.ts | 62 +++++----- src/server/sessions/authService.ts | 8 +- .../piSessionService.archiveCleanup.test.ts | 11 +- .../piSessionService.lifecycle.test.ts | 21 +++- .../piSessionService.promptQueue.test.ts | 42 +++++-- .../piSessionService.spawnSession.test.ts | 5 +- .../piSessionService.spawnSubsession.test.ts | 20 +++- .../sessions/piSessionService.testSupport.ts | 33 +++++- .../piSessionService.warnings.test.ts | 108 ++++++++++-------- src/server/sessions/sessionRoutes.test.ts | 3 +- 13 files changed, 220 insertions(+), 107 deletions(-) diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts index f3e3707..2180f68 100644 --- a/src/server/sessions/authProviderOptions.test.ts +++ b/src/server/sessions/authProviderOptions.test.ts @@ -24,8 +24,8 @@ describe("auth provider options", () => { expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true); }); - it("builds login options for OAuth-only, dual-auth, and API-key providers", async () => { - const options = await getLoginProviderOptions(runtime()); + it("builds login options for OAuth-only, dual-auth, and API-key providers", () => { + const options = getLoginProviderOptions(runtime()); expect(options).toEqual(expect.arrayContaining([ expect.objectContaining({ id: "anthropic", authType: "oauth" }), expect.objectContaining({ id: "anthropic", authType: "api_key" }), diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts index 34b7cdb..94cc528 100644 --- a/src/server/sessions/authProviderOptions.ts +++ b/src/server/sessions/authProviderOptions.ts @@ -27,7 +27,7 @@ export interface AuthProviderRuntime { getProviderAuthStatus(providerId: string): AuthProviderStatus; } -export async function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): Promise { +export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] { const providers = runtime.getProviders(); const oauthProviderIds = new Set(providers.filter((provider) => provider.auth.oauth !== undefined).map((provider) => provider.id)); diff --git a/src/server/sessions/authRoutes.ts b/src/server/sessions/authRoutes.ts index 1f000f3..a8f516c 100644 --- a/src/server/sessions/authRoutes.ts +++ b/src/server/sessions/authRoutes.ts @@ -4,7 +4,7 @@ import type { AuthService } from "./authService.js"; export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, prefix = ""): void { app.get<{ Querystring: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" } }>(`${prefix}/auth/providers`, async (request, reply) => { try { - return auth.authProviders(request.query.mode ?? "login", request.query.authType); + return await auth.authProviders(request.query.mode ?? "login", request.query.authType); } catch (error) { return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); } @@ -12,7 +12,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref app.post<{ Body: { providerId: string; key: string } }>(`${prefix}/auth/api-key`, async (request, reply) => { try { - return auth.saveApiKey(request.body.providerId, request.body.key); + return await auth.saveApiKey(request.body.providerId, request.body.key); } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } @@ -20,7 +20,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => { try { - return auth.logoutProvider(request.body.providerId); + return await auth.logoutProvider(request.body.providerId); } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } @@ -28,7 +28,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref app.post<{ Body: { providerId: string } }>(`${prefix}/auth/oauth`, async (request, reply) => { try { - return auth.startOAuthLogin(request.body.providerId); + return await auth.startOAuthLogin(request.body.providerId); } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 685295f..64fce77 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -1,7 +1,8 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { InMemoryCredentialStore, type Credential } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OAuthFlowState } from "../../shared/apiTypes.js"; import { AuthService, type AuthChange } from "./authService.js"; @@ -14,85 +15,84 @@ afterEach(async () => { }); describe("AuthService", () => { - it("saves API keys and emits a global auth change", () => { - const { auth, authStorage, changes } = createAuthService(); + it("saves API keys and emits a global auth change", async () => { + const { auth, credentials, changes } = await createAuthService(); - expect(auth.saveApiKey("anthropic", "sk-test")).toEqual({ accepted: true }); + await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true }); - expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-test" }); + await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" }); expect(changes).toEqual([{}]); auth.dispose(); }); - it("logs out providers and emits the removed provider id", () => { - const { auth, authStorage, changes } = createAuthService({ anthropic: { type: "api_key", key: "sk-test" } }); + it("logs out providers and emits the removed provider id", async () => { + const { auth, credentials, changes } = await createAuthService({ anthropic: { type: "api_key", key: "sk-test" } }); - expect(auth.logoutProvider("anthropic")).toEqual({ accepted: true }); + await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true }); - expect(authStorage.get("anthropic")).toBeUndefined(); + await expect(credentials.read("anthropic")).resolves.toBeUndefined(); expect(changes).toEqual([{ removedProviderId: "anthropic" }]); auth.dispose(); }); - it("rejects blank API keys", () => { - const { auth, changes } = createAuthService(); + it("rejects blank API keys", async () => { + const { auth, changes } = await createAuthService(); - expect(() => { auth.saveApiKey("anthropic", " "); }).toThrow("API key is required"); + await expect(auth.saveApiKey("anthropic", " ")).rejects.toThrow("API key is required"); expect(changes).toEqual([]); auth.dispose(); }); it("stores credentials in the configured agent directory", async () => { const agentDir = await tempAgentDir(); - const auth = new AuthService({ agentDir }); + const auth = await AuthService.create({ agentDir }); - auth.saveApiKey("anthropic", "sk-test"); + await auth.saveApiKey("anthropic", "sk-test"); await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test"); auth.dispose(); }); - it("refreshes auth state after OAuth login completes", () => { - const authStorage = AuthStorage.inMemory(); - const modelRegistry = ModelRegistry.create(authStorage); + it("refreshes auth state after OAuth login completes", async () => { + const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore() }); const authFlows = new CapturingOAuthLoginFlowService(); - const auth = new AuthService({ modelRegistry, authFlows }); + const auth = await AuthService.create({ runtime, authFlows }); const changes: AuthChange[] = []; auth.subscribe((change) => { changes.push(change); }); - const reload = vi.spyOn(authStorage, "reload"); - const refresh = vi.spyOn(modelRegistry, "refresh"); - const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic"); + const refresh = vi.spyOn(runtime, "refresh"); + const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined); if (provider === undefined) throw new Error("Expected built-in OAuth provider"); - expect(auth.startOAuthLogin(provider.id)).toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" }); + await expect(auth.startOAuthLogin(provider.id)).resolves.toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" }); const startOptions = authFlows.startCalls.at(0); if (startOptions === undefined) throw new Error("Expected OAuth flow to start"); expect(startOptions.providerId).toBe(provider.id); expect(startOptions.providerName).toBe(provider.name); - expect(startOptions.authStorage).toBe(authStorage); + expect(startOptions.runtime).toBe(runtime); expect(changes).toEqual([]); - reload.mockClear(); refresh.mockClear(); if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback"); startOptions.onComplete(); + await vi.waitFor(() => { expect(changes).toEqual([{}]); }); - expect(reload).toHaveBeenCalledOnce(); expect(refresh).toHaveBeenCalledOnce(); - expect(changes).toEqual([{}]); auth.dispose(); expect(authFlows.disposed).toBe(true); }); }); -function createAuthService(data: Parameters[0] = {}) { - const authStorage = AuthStorage.inMemory(data); - const modelRegistry = ModelRegistry.create(authStorage); - const auth = new AuthService({ modelRegistry }); +async function createAuthService(seed: Record = {}) { + const credentials = new InMemoryCredentialStore(); + for (const [providerId, credential] of Object.entries(seed)) { + await credentials.modify(providerId, () => Promise.resolve(credential)); + } + const runtime = await ModelRuntime.create({ credentials }); + const auth = await AuthService.create({ runtime }); const changes: AuthChange[] = []; auth.subscribe((change) => { changes.push(change); }); - return { auth, authStorage, changes }; + return { auth, credentials, changes }; } async function tempAgentDir(): Promise { diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index 3dab648..5d0679c 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -51,7 +51,7 @@ export class AuthService { async authProviders(mode: "login" | "logout", authType?: AuthType): Promise { await this.runtime.refresh(); - const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : await getLoginProviderOptions(this.runtime, authType); + const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : getLoginProviderOptions(this.runtime, authType); return { providers }; } @@ -61,8 +61,8 @@ export class AuthService { // credential through the runtime's credential store; feed the key back via a // non-interactive AuthInteraction. const interaction: AuthInteraction = { - prompt: async () => key, - notify: () => {}, + prompt: () => Promise.resolve(key), + notify: () => undefined, }; await this.runtime.login(providerId, "api_key", interaction); await this.refreshAuthState(); @@ -110,7 +110,7 @@ export class AuthService { private async requireOAuthLoginProvider(providerId: string) { await this.runtime.refresh(); - const provider = (await getLoginProviderOptions(this.runtime, "oauth")).find((option) => option.id === providerId); + const provider = getLoginProviderOptions(this.runtime, "oauth").find((option) => option.id === providerId); if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`); return provider; } diff --git a/src/server/sessions/piSessionService.archiveCleanup.test.ts b/src/server/sessions/piSessionService.archiveCleanup.test.ts index 969ff15..22433e8 100644 --- a/src/server/sessions/piSessionService.archiveCleanup.test.ts +++ b/src/server/sessions/piSessionService.archiveCleanup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.js"; -import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -15,6 +15,7 @@ describe("PiSessionService archive and cleanup", () => { const fake = fakeRuntime("root", { sessionFile: root.path }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), archiveStore: { list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]), @@ -49,6 +50,7 @@ describe("PiSessionService archive and cleanup", () => { const deletedSessionIds: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([]), get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) @@ -83,6 +85,7 @@ describe("PiSessionService archive and cleanup", () => { const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([]), get: () => Promise.resolve(undefined), @@ -118,6 +121,7 @@ describe("PiSessionService archive and cleanup", () => { const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { createCalls += 1; return Promise.resolve(busy.runtime); @@ -159,6 +163,7 @@ describe("PiSessionService archive and cleanup", () => { const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(busy.runtime), archiveStore: { list: () => Promise.resolve([busyRecord, idleRecord]), @@ -196,6 +201,7 @@ describe("PiSessionService archive and cleanup", () => { const listCalls: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([ { sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, @@ -239,6 +245,7 @@ describe("PiSessionService archive and cleanup", () => { const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { list: () => Promise.resolve([archived, otherArchived]), @@ -292,6 +299,7 @@ describe("PiSessionService archive and cleanup", () => { const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { list: () => Promise.resolve([ @@ -334,6 +342,7 @@ describe("PiSessionService archive and cleanup", () => { const archivedInputs: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, now: () => new Date("2026-06-25T00:00:00.000Z"), createAgentRuntime: runtimeCreator(fake.runtime), archiveStore: { diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts index 51d5b08..bf33407 100644 --- a/src/server/sessions/piSessionService.lifecycle.test.ts +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js"; -import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -31,6 +31,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }; const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -60,6 +61,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { try { service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -87,6 +89,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const open = vi.fn(() => fakeSessionManager()); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: { create: () => fakeSessionManager(), @@ -136,6 +139,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const open = vi.spyOn(gateway, "open"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: emptyArchiveStore(), createAgentRuntime, sessionManager: gateway, @@ -190,6 +194,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: emptyArchiveStore(), createAgentRuntime, sessionManager: sessionGateway([sessionRecord(sessionId)]), @@ -230,6 +235,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const fake = fakeRuntime(sessionId); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: emptyArchiveStore(), createAgentRuntime: () => { createStarted.resolve(); @@ -264,6 +270,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { fake.runtime.setRebindSession = (callback) => { rebindSession = callback; }; const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -291,6 +298,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -326,6 +334,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }); service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("idle-session")]), heartbeatIntervalMs: 1_000, @@ -362,6 +371,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("completion-session")]), heartbeatIntervalMs: 60_000, @@ -381,6 +391,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("uses injected archive and session-manager gateways for listing", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]), get: () => Promise.resolve(undefined), @@ -411,6 +422,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("lists archived records that have been moved out of the active session directory", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), get: () => Promise.resolve(undefined), @@ -442,6 +454,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const fake = fakeRuntime("runtime-reload-session"); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]), heartbeatIntervalMs: 60_000, @@ -475,6 +488,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([sessionRecord("reload-session")]), heartbeatIntervalMs: 60_000, @@ -499,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const fake = fakeRuntime("busy-session", { isStreaming: true }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("busy-session")]), heartbeatIntervalMs: 60_000, @@ -514,6 +529,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("refuses to reload an archived session", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([]), get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) @@ -536,6 +552,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const reconciliations: { cwd: string; sessionIds: string[] }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), get: () => Promise.resolve(undefined), @@ -573,6 +590,7 @@ describe("PiSessionService.streamSnapshot", () => { const fake = fakeRuntime("snap-idle"); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -601,6 +619,7 @@ describe("PiSessionService.streamSnapshot", () => { const fake = fakeRuntime("snap-live", { state: { streamingMessage } }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts index be44884..c4d4514 100644 --- a/src/server/sessions/piSessionService.promptQueue.test.ts +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -1,9 +1,8 @@ -import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai"; +import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai"; import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.js"; -import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, createTestModelRuntime, fakeRuntime, runtimeCreator, seedCredential, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -12,6 +11,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("prompt-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, @@ -30,6 +30,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const hub = new CapturingSessionEventHub(); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("echo-session")]), heartbeatIntervalMs: 60_000, @@ -60,6 +61,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, @@ -96,6 +98,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("name-session")]), heartbeatIntervalMs: 60_000, @@ -118,6 +121,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("status-session")]), heartbeatIntervalMs: 60_000, @@ -139,6 +143,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("dedupe-session")]), heartbeatIntervalMs: 60_000, @@ -155,6 +160,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("queued-session", { isStreaming: true }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("queued-session")]), heartbeatIntervalMs: 60_000, @@ -181,6 +187,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { }; const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("compacting-session")]), heartbeatIntervalMs: 60_000, @@ -245,6 +252,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { fake.session.clearQueue = clearRuntimeQueue; const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("clear-queue-session")]), heartbeatIntervalMs: 60_000, @@ -285,6 +293,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("clear-empty-queue-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]), heartbeatIntervalMs: 60_000, @@ -304,6 +313,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("abort-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("abort-session")]), heartbeatIntervalMs: 60_000, @@ -321,6 +331,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("abort-compaction-session", { isCompacting: true }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]), heartbeatIntervalMs: 60_000, @@ -338,15 +349,20 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => { const hub = new CapturingSessionEventHub(); - const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } }); - const modelRegistry = ModelRegistry.inMemory(authStorage); - const model = modelRegistry.find(TEST_MODEL_PROVIDER, TEST_MODEL_ID); + // The shared model runtime reads a live credential store; auth changes are + // simulated by mutating the store and refreshing the runtime (the same + // sequence AuthService performs before emitting an AuthChange), then + // notifying the service via applyAuthChange. + const credentials = new InMemoryCredentialStore(); + await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-test" }); + const modelRuntime = await createTestModelRuntime(credentials); + const model = modelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID); if (model === undefined) throw new Error("Expected Anthropic model fixture"); - const fake = fakeRuntime("auth-session", { model, modelRegistry }); + const fake = fakeRuntime("auth-session", { model, modelRuntime }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, - modelRegistry, + modelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("auth-session")]), heartbeatIntervalMs: 60_000, @@ -356,7 +372,8 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { hub.sessionEvents.length = 0; hub.globalEvents.length = 0; - authStorage.logout("anthropic"); + await credentials.delete("anthropic"); + await modelRuntime.refresh(); service.applyAuthChange({ removedProviderId: "anthropic" }); service.applyAuthChange({ removedProviderId: "anthropic" }); @@ -364,9 +381,11 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { expect(warningCount()).toBe(1); expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true); - authStorage.set("anthropic", { type: "api_key", key: "sk-new" }); + await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-new" }); + await modelRuntime.refresh(); service.applyAuthChange(); - authStorage.logout("anthropic"); + await credentials.delete("anthropic"); + await modelRuntime.refresh(); service.applyAuthChange({ removedProviderId: "anthropic" }); expect(warningCount()).toBe(2); @@ -377,6 +396,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("stop-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("stop-session")]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts index 29346ee..0d10106 100644 --- a/src/server/sessions/piSessionService.spawnSession.test.ts +++ b/src/server/sessions/piSessionService.spawnSession.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; -import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -12,6 +12,7 @@ describe("PiSessionService", () => { const log: { details: Record; message: string }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, @@ -45,6 +46,7 @@ describe("PiSessionService", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([]), spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, @@ -80,6 +82,7 @@ describe("PiSessionService", () => { const fake = fakeRuntime("spawned-x"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts index 1eb11b3..b28c178 100644 --- a/src/server/sessions/piSessionService.spawnSubsession.test.ts +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; -import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -40,6 +40,7 @@ describe("PiSessionService", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([]), archiveStore, @@ -81,6 +82,7 @@ describe("PiSessionService", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([]), archiveStore: emptyArchiveStore(), @@ -121,6 +123,7 @@ describe("PiSessionService", () => { let index = 0; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? child.runtime; index += 1; @@ -173,6 +176,7 @@ describe("PiSessionService", () => { const open = vi.fn(() => childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? child.runtime; index += 1; @@ -215,6 +219,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -240,6 +245,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -262,6 +268,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -283,6 +290,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -304,6 +312,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(forkedParent.runtime), sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -343,6 +352,7 @@ describe("PiSessionService", () => { const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: (_createRuntime, options) => { delegationCapabilities.push(options.delegationToolsEnabled); const runtime = runtimes[index] ?? parent.runtime; @@ -406,6 +416,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -464,6 +475,7 @@ describe("PiSessionService", () => { const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -529,6 +541,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: { create: () => parentManager, @@ -605,6 +618,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: { create: () => copiedParentManager, @@ -663,6 +677,7 @@ describe("PiSessionService", () => { const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -716,6 +731,7 @@ describe("PiSessionService", () => { const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -757,6 +773,7 @@ describe("PiSessionService", () => { const open = vi.fn(() => childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(child.runtime), sessionManager: { create: () => childManager, @@ -929,6 +946,7 @@ describe("PiSessionService", () => { const fake = fakeRuntime("nope"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts index 84a7247..e1d338d 100644 --- a/src/server/sessions/piSessionService.testSupport.ts +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -1,4 +1,5 @@ -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { InMemoryCredentialStore, type Credential, type CredentialStore } from "@earendil-works/pi-ai"; import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js"; @@ -62,8 +63,34 @@ export function sessionRef(id: string, cwd = "/workspace") { export const TEST_MODEL_PROVIDER = "anthropic"; export const TEST_MODEL_ID = "claude-sonnet-4-5-20250929"; +/** + * Seed a credential into an {@link InMemoryCredentialStore}. `modify` is the + * only write path on the pi-ai `CredentialStore` contract, so tests that need a + * pre-populated store go through it rather than mutating internals. + */ +export async function seedCredential(store: InMemoryCredentialStore, providerId: string, credential: Credential): Promise { + await store.modify(providerId, () => Promise.resolve(credential)); +} + +/** + * Build a real {@link ModelRuntime} over an in-memory credential store — the + * async test seam that replaces the removed `ModelRegistry.create(AuthStorage + * .inMemory())`. Pass a pre-seeded store to exercise credential-dependent + * behavior (e.g. auth-loss warnings). + */ +export function createTestModelRuntime(credentials: CredentialStore = new InMemoryCredentialStore()): Promise { + return ModelRuntime.create({ credentials }); +} + +/** + * Shared runtime for the common case where a test only needs model catalog + * reads and no configured auth. Built once so the many `fakeRuntime` sessions + * and `PiSessionService` constructions can inject it synchronously. + */ +export const testModelRuntime = await createTestModelRuntime(); + export function testModel(): NonNullable { - const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find(TEST_MODEL_PROVIDER, TEST_MODEL_ID); + const model = testModelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID); if (model === undefined) throw new Error("test model not found"); return model; } @@ -88,7 +115,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial pendingMessageCount: 0, sessionManager: fakeSessionManager(), settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined }, - modelRegistry: ModelRegistry.create(AuthStorage.inMemory()), + modelRuntime: testModelRuntime, scopedModels: [], extensionRunner: { getRegisteredCommands: () => [] }, promptTemplates: [], diff --git a/src/server/sessions/piSessionService.warnings.test.ts b/src/server/sessions/piSessionService.warnings.test.ts index c4ec0a7..502f810 100644 --- a/src/server/sessions/piSessionService.warnings.test.ts +++ b/src/server/sessions/piSessionService.warnings.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { AuthStorage, ModelRegistry, type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent"; import { anthropicSubscriptionWarning, collectRuntimeWarnings, dismissSessionWarning, type RuntimeWarningSources } from "./piSessionService.js"; +import { testModel } from "./piSessionService.testSupport.js"; import type { PiAgentSession } from "./piSessionService.js"; import type { SessionWarning } from "../../shared/apiTypes.js"; @@ -83,47 +87,55 @@ describe("collectRuntimeWarnings", () => { const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage."; -type SubscriptionSession = Pick; +type SubscriptionSession = Pick; function anthropicModel(provider: string): PiAgentSession["model"] { - const registry = ModelRegistry.inMemory(AuthStorage.inMemory()); - const model = registry.getAll().find((candidate) => candidate.provider === provider) ?? registry.getAll()[0]; - if (model === undefined) throw new Error("expected at least one built-in model"); - return { ...model, provider }; + // anthropicSubscriptionWarning only reads `model.provider`, so any built-in + // model re-tagged with the desired provider is a sufficient fixture. + return { ...testModel(), provider }; } function subscriptionSession(options: { provider?: string; anthropicExtraUsage?: boolean; - credential?: AuthStorage; }): SubscriptionSession { - const authStorage = options.credential ?? AuthStorage.inMemory(); return { model: options.provider === undefined ? undefined : anthropicModel(options.provider), settingsManager: { getWarnings: () => (options.anthropicExtraUsage === undefined ? {} : { anthropicExtraUsage: options.anthropicExtraUsage }), setWarnings: () => undefined, }, - modelRegistry: ModelRegistry.create(authStorage), }; } -function anthropicAuth(credential: { type: "oauth" } | { type: "api_key"; key: string }): AuthStorage { - const authStorage = AuthStorage.inMemory(); - if (credential.type === "oauth") { - authStorage.set("anthropic", { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 }); - } else { - authStorage.set("anthropic", { type: "api_key", key: credential.key }); - } - return authStorage; +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +/** + * Write an `auth.json` holding a single anthropic credential and return its + * path. `anthropicSubscriptionWarning` reads it via `readStoredCredential`, so + * the credential seam is the on-disk auth file rather than an in-memory store. + */ +async function anthropicAuthPath(credential: { type: "oauth" } | { type: "api_key"; key: string }): Promise { + const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-")); + tempDirs.push(dir); + const authPath = join(dir, "auth.json"); + const stored = credential.type === "oauth" + ? { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 } + : { type: "api_key", key: credential.key }; + await writeFile(authPath, JSON.stringify({ anthropic: stored })); + return authPath; } describe("anthropicSubscriptionWarning", () => { - it("warns with the verbatim SDK wording for a stored oauth credential", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ - provider: "anthropic", - credential: anthropicAuth({ type: "oauth" }), - }))).toEqual({ + it("warns with the verbatim SDK wording for a stored oauth credential", async () => { + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "anthropic" }), + await anthropicAuthPath({ type: "oauth" }), + )).toEqual({ severity: "warning", message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING, source: "anthropic", @@ -131,37 +143,41 @@ describe("anthropicSubscriptionWarning", () => { } satisfies SessionWarning); }); - it("warns for an sk-ant-oat subscription API key", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ - provider: "anthropic", - credential: anthropicAuth({ type: "api_key", key: "sk-ant-oat-abc123" }), - }))?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING); + it("warns for an sk-ant-oat subscription API key", async () => { + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "anthropic" }), + await anthropicAuthPath({ type: "api_key", key: "sk-ant-oat-abc123" }), + )?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING); }); - it("does not warn for a standard anthropic API key", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ - provider: "anthropic", - credential: anthropicAuth({ type: "api_key", key: "sk-ant-api-abc123" }), - }))).toBeUndefined(); + it("does not warn for a standard anthropic API key", async () => { + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "anthropic" }), + await anthropicAuthPath({ type: "api_key", key: "sk-ant-api-abc123" }), + )).toBeUndefined(); }); - it("respects the anthropicExtraUsage suppression gate", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ - provider: "anthropic", - anthropicExtraUsage: false, - credential: anthropicAuth({ type: "oauth" }), - }))).toBeUndefined(); + it("respects the anthropicExtraUsage suppression gate", async () => { + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "anthropic", anthropicExtraUsage: false }), + await anthropicAuthPath({ type: "oauth" }), + )).toBeUndefined(); }); - it("does not warn when the active provider is not anthropic", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ - provider: "openai", - credential: anthropicAuth({ type: "oauth" }), - }))).toBeUndefined(); + it("does not warn when the active provider is not anthropic", async () => { + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "openai" }), + await anthropicAuthPath({ type: "oauth" }), + )).toBeUndefined(); }); - it("does not warn when no anthropic credential is stored", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ provider: "anthropic" }))).toBeUndefined(); + it("does not warn when no anthropic credential is stored", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-")); + tempDirs.push(dir); + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "anthropic" }), + join(dir, "auth.json"), + )).toBeUndefined(); }); }); diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index 54dd4b3..2507177 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus, SessionStreamSnapshot } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js"; +import { testModelRuntime } from "./piSessionService.testSupport.js"; import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js"; import { registerSessionRoutes } from "./sessionRoutes.js"; import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; @@ -20,7 +21,7 @@ beforeEach(async () => { await app.register(fastifyWebsocket); sessionManager = new RejectingSessionManager(); const eventHub = new SessionEventHub(); - service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 }); + service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, modelRuntime: testModelRuntime, sessionManager, heartbeatIntervalMs: 60_000 }); registerSessionRoutes(app, service, eventHub); }); From dafc2d9c5f0111c66b1e684b43186508baf5279e Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 21:58:54 +0200 Subject: [PATCH 14/26] relay(issue-62-authstorage): leg 6 status/log (slice 5 done, verify green) --- relays/issue-62-authstorage/log.md | 54 ++++++++++ relays/issue-62-authstorage/status.md | 137 ++++++++++++++++---------- 2 files changed, 141 insertions(+), 50 deletions(-) diff --git a/relays/issue-62-authstorage/log.md b/relays/issue-62-authstorage/log.md index 14aa287..7e89c3b 100644 --- a/relays/issue-62-authstorage/log.md +++ b/relays/issue-62-authstorage/log.md @@ -313,3 +313,57 @@ sessionRoutes (1). `piSessionService.ts` lints clean. Committed `--no-verify` added `piSessionService.ts` (a session-daemon path) to the pending-restart surface; do not clear the note. Handing off to leg 6 (slice 5: tests + testSupport). + +## Leg 6 — slice 5 (tests + testSupport migration) — commit `d0cc55c` + +**What:** Migrated all test doubles + `piSessionService.testSupport.ts` off the +removed `AuthStorage.inMemory` / `ModelRegistry.create|inMemory` surface to the +pi-ai `InMemoryCredentialStore` + async `ModelRuntime.create({ credentials })`. +`npm run verify` is now **fully green** (typecheck + lint + knip + 1390 tests, +2 skipped) — the migration goal (charter criteria 1, 2, 5) is met; only the +changeset (criterion 4) remains for slice 6. + +**Files changed (all `src/server/sessions/`):** +- `piSessionService.testSupport.ts`: new seams `createTestModelRuntime`, + shared `testModelRuntime` (top-level await), `seedCredential`; `fakeRuntime` + and `testModel` moved onto `modelRuntime`; dropped AuthStorage/ModelRegistry. +- `archiveCleanup`/`lifecycle`/`promptQueue`/`spawnSession`/`spawnSubsession`/ + `sessionRoutes` tests: injected `modelRuntime: testModelRuntime` into every + `new PiSessionService(...)` (now required) + imported the shared runtime. +- `promptQueue.test.ts` auth-loss test: live `InMemoryCredentialStore` + + `createTestModelRuntime(credentials)`, driving changes through + `delete`/`seedCredential` + `refresh()` + `applyAuthChange(...)`. +- `warnings.test.ts`: `anthropicSubscriptionWarning` seam is now a temp + `auth.json` read via `readStoredCredential(id, authPath)`; type narrowed. +- `authService.test.ts`: reworked to async `AuthService.create` + + `InMemoryCredentialStore`; awaits async ops; OAuth-complete uses `vi.waitFor`. + +**Decisions:** +- Used a single shared `testModelRuntime` (top-level `await` in testSupport, an + allowed ESM pattern here) for the no-auth catalog case so the many + `PiSessionService` constructions and `fakeRuntime` sessions inject it + synchronously — avoided making `fakeRuntime` itself async (which would have + rippled through ~90 call sites). Auth-dependent tests build a dedicated + per-test runtime via `createTestModelRuntime(credentials)`. +- `anthropicSubscriptionWarning` seam: chose the on-disk temp `auth.json` + + `readStoredCredential` path (matches production exactly) rather than adding a + new injectable credential-read seam. Clean; no intervention needed. +- Made `getLoginProviderOptions` **synchronous** (it did no async work) to + satisfy `require-await`; de-awaited its 2 call sites in `authService.ts` and + the test. Also fixed pre-existing lint debt from earlier slices surfaced now + that lint ran green for the first time: `authRoutes.ts` return-await, + `authService.ts` api-key interaction (`() => Promise.resolve(key)` / + `notify: () => undefined`). +- Verified SDK behavior empirically with throwaway probe scripts (removed) + before writing doubles: providers/models catalog, api-key login persistence, + `getModel`, `hasConfiguredAuth` across store mutations + refresh, and + `readStoredCredential` against a temp auth.json. + +**Verification:** `npm run verify` green. Pre-commit hook (whole-project +typecheck + knip + eslint + related vitest) passed — committed normally +(no `--no-verify` needed since the tree is verify-green). + +**Blockers:** none. **Sessiond-restart-pending note still ACTIVE** — unchanged +this leg but slices 1 + 4 touched session-daemon paths; only the human clears +it after restarting the sessiond service. Handing off to leg 7 (slice 6: +changeset + final verify + cleanup; the finish line — no PR). diff --git a/relays/issue-62-authstorage/status.md b/relays/issue-62-authstorage/status.md index 1f6d385..ed4ec98 100644 --- a/relays/issue-62-authstorage/status.md +++ b/relays/issue-62-authstorage/status.md @@ -1,6 +1,59 @@ # Relay status — issue-62-authstorage ## Current position +Slice 5 (tests + testSupport) complete and committed (`d0cc55c`). +**`npm run verify` is fully GREEN** — typecheck + lint + knip + 1390 tests +pass (2 skipped). All production code and all test/support code are now off the +removed `AuthStorage` / `ModelRegistry.create|inMemory` surface. Goal criteria +1, 2, 5 are met; criteria 3 (dep ranges) was done in slice 0/1; only criterion +4 (changeset) remains — that is slice 6. + +What slice 5 changed (all under `src/server/sessions/`): +- **`piSessionService.testSupport.ts`** (central helper): dropped + `AuthStorage`/`ModelRegistry` imports; added pi-ai `InMemoryCredentialStore`. + New seams: `createTestModelRuntime(credentials?)` (wraps + `ModelRuntime.create({ credentials })`), a shared `testModelRuntime` + (top-level `await createTestModelRuntime()` — the common no-auth catalog + runtime), and `seedCredential(store, providerId, credential)` (writes via the + `CredentialStore.modify` path). `fakeRuntime` session now carries + `modelRuntime: testModelRuntime`; `testModel()` reads + `testModelRuntime.getModel(...)`. +- Threaded `modelRuntime: testModelRuntime` into every `new PiSessionService(...)` + (now a required dep) across `archiveCleanup`/`lifecycle`/`promptQueue`/ + `spawnSession`/`spawnSubsession`/`sessionRoutes` tests, importing + `testModelRuntime` in each. +- **`piSessionService.promptQueue.test.ts`** auth-loss test rewritten: builds a + live `InMemoryCredentialStore` + `createTestModelRuntime(credentials)`, and + simulates auth changes via `credentials.delete/seedCredential` + + `modelRuntime.refresh()` + `applyAuthChange(...)` (matching AuthService's + real refresh-then-emit sequence). Removed the `modelRegistry` dep line. +- **`piSessionService.warnings.test.ts`**: `anthropicSubscriptionWarning` now + reads `readStoredCredential("anthropic", authPath)`, so the test seam is a + temp `auth.json` written per case (helper `anthropicAuthPath(...)`), passed + as the 2nd arg. `SubscriptionSession` type narrowed to + `Pick`. The "no credential" + case points at a temp dir with no auth.json (deterministic). +- **`authService.test.ts`** fully reworked to the async `AuthService.create({ + runtime | agentDir })` + `InMemoryCredentialStore` model. `saveApiKey`/ + `logoutProvider`/`startOAuthLogin` are awaited; OAuth-complete test asserts + `startOptions.runtime === runtime` and uses `vi.waitFor` for the async + refresh; credential assertions go through `credentials.read(...)`. +- Lint fixes surfaced by running lint green for the first time this relay: + `getLoginProviderOptions` made **synchronous** (it did no async work) and its + call sites in `authService.ts` + `authProviderOptions.test.ts` de-awaited; + `authRoutes.ts` handlers now `return await ...` (return-await rule); + `authService.ts` api-key interaction uses `() => Promise.resolve(key)` / + `notify: () => undefined`; test `modify` arrows use `() => Promise.resolve(...)`. + +SDK behavior verified empirically before writing doubles (throwaway probe +scripts, since removed): `ModelRuntime.create({ credentials })` exposes 36 +providers / 1072 models; `login(id, "api_key", interaction)` persists to the +store; `getModel("anthropic", "claude-sonnet-4-5-20250929")` resolves; +`hasConfiguredAuth` flips correctly across `delete`/`modify` + `refresh`; +`readStoredCredential(id, authPath)` reads a temp auth.json and returns +`undefined` for a missing file. + +### Prior position (slice 4, leg 5, commit `4ccd4f8`) Slice 4 (`piSessionService.ts` migration) complete and committed (`4ccd4f8`). `piSessionService.ts` now uses the new `ModelRuntime` API end to end: - `createDefaultRuntimeFactory(modelRuntime, ...)` passes `modelRuntime` to @@ -109,56 +162,39 @@ Remaining errors otherwise live in slices 2/3/4 files and all test/support files (slice 5). ## Leg tracking -- **Last completed leg:** 5 (slice 4 — piSessionService.ts migration). -- **Next leg to run:** 6. +- **Last completed leg:** 6 (slice 5 — tests + testSupport migration). +- **Next leg to run:** 7. ## Next task -Run **charter slice 5 (tests + testSupport)** as leg 6: migrate all test -doubles off `AuthStorage.inMemory(...)` / `ModelRegistry.create|inMemory(...)` -to the pi-ai `InMemoryCredentialStore` + `await ModelRuntime.create({ -credentials })`, and get `npm run verify` green. Follow the testing-guide skill -(async construction seams, no over-mocking of the SDK). +Run **charter slice 6 (changeset + final verify + cleanup)** as leg 7. This is +the closing leg: +1. Add a `.changeset/*.md` fragment for `@jmfederico/pi-web` describing the + user-visible fix (session daemon crash with Pi 0.80.8+ fixed by migrating + to the new `ModelRuntime` API; requires Pi `>=0.80.8`). Use the + `changeset-changelog` skill. Do **not** edit `CHANGELOG.md` directly. The + maintainer's call on patch vs minor — the assessment §5 suggests patch or + minor; a **minor** is defensible since the supported Pi range narrows + (`>=0.80.8 <0.81`, dropping 0.80.0–0.80.7), but follow the changeset skill + and keep it a single fragment. +2. Re-run the full `npm run verify` to confirm still green. +3. Confirm the goal criteria in `charter.md` are all met (1 no AuthStorage/ + registry use — done; 2 auth surfaces on new APIs — done; 3 dep ranges — + already corrected in slice 0/1, double-check `package.json` peer/dev ranges; + 4 changeset — this leg; 5 verify green — confirm). +4. Cleanup: `ASSESSMENT-issue-62.md` stays (it's the plan of record). + `/srv/dev/pi-inspect` is outside the repo, not ours to touch. Confirm no + scratch files were left in the repo (e.g. no stray `probe*.mjs`, + `.tmp-build/`). +5. **Do NOT open a PR** (explicitly out of scope for this relay). -**Scope note (important):** slice 4 made `modelRuntime` a *required* -`PiSessionServiceDependencies` field (see Current position for why). That means -the slice-5 test surface is LARGER than the four files originally listed in the -assessment. Current `npx tsc --noEmit` failing files (all tests/support): -- `piSessionService.testSupport.ts` (4) — `fakeRuntime` builds - `modelRegistry: ModelRegistry.create(AuthStorage.inMemory())`; the - `TestSession` type still has `modelRegistry`. Give the fake a `modelRuntime` - (e.g. `await ModelRuntime.create({ credentials: new InMemoryCredentialStore() })` - — note this makes `fakeRuntime` async, which ripples into its callers) and - update `TestSession`. This is the central helper; fixing it first will clear - many downstream errors. -- `authService.test.ts` (10) — already partly slice-1/2/3 debt. -- `piSessionService.warnings.test.ts` (5) — `anthropicSubscriptionWarning` no - longer takes a registry; it now reads `readStoredCredential("anthropic", - authPath)`. Tests that build credentials via `authStorage.set(...)` must - instead write an `auth.json` (temp dir) and pass its path, OR the test seam - must be reconsidered. `SubscriptionSession` type ref to `modelRegistry` is - gone. Check whether `readStoredCredential` can be pointed at a temp authPath - cleanly; if not, consider whether the warning fn needs a small injectable - credential-read seam (raise via intervention if the API can't support the - test without contortion). -- `piSessionService.promptQueue.test.ts` (17), `.lifecycle.test.ts` (19), - `.archiveCleanup.test.ts` (9), `.spawnSession.test.ts` (3), - `.spawnSubsession.test.ts` (18), `sessionRoutes.test.ts` (1) — mostly the - new required `modelRuntime` dep on `new PiSessionService(...)` plus - `fakeRuntime`/`ModelRegistry.inMemory` usages. Many of these should clear - automatically once `testSupport.ts` provides a shared `modelRuntime` helper - and the `PiSessionService` test-construction path supplies it. +This is the finish line. After confirming everything, the goal is reached: +update `status.md`/`log.md`, commit, and per the charter **stop** (the goal is +reached) — or if you prefer, hand off a final "relay complete" confirmation +leg. Either way, surface to the human that the **sessiond restart is still +pending** (see Blockers) and a PR was intentionally not opened. -Suggested approach: add a small shared test helper (e.g. -`await createTestModelRuntime()` wrapping `ModelRuntime.create({ credentials: -new InMemoryCredentialStore(...) })`) in `testSupport.ts`, thread it into -`fakeRuntime` and the `new PiSessionService(...)` call sites, then work file by -file until `npm run verify` (typecheck + lint + knip + test) is green. - -Then slice 6 adds the `.changeset/*.md` fragment, runs the full `npm run -verify`, and does final cleanup (ASSESSMENT stays). - -If slice 5 is already done when you arrive, apply the charter's task-selection -policy: pick the lowest-numbered incomplete slice (6). +Verification already passing as of slice 5 (leg 6): `npm run verify` green +(typecheck + lint + knip + 1390 tests, 2 skipped). ### Build/tooling note (important for every leg) **Update (leg 2):** the human reports `/tmp` is now fully usable again, so the @@ -174,10 +210,11 @@ verify-green). Node: v24.18.0. ## Relevant context for the next runner - **Plan of record:** `ASSESSMENT-issue-62.md` (root) — read once. §5 has the per-file migration shape; §3 has the exact new API shapes; §6 the dep ranges. -- **Files to change** (all under `src/server/sessions/` unless noted): - `authService.ts`, `authProviderOptions.ts`, `oauthLoginFlowService.ts`, - `piSessionService.ts`, plus `src/server/sessiond.ts` (async auth - construction), and the test/support files listed in assessment §2. +- **Changeset skill:** `.agents/skills/changeset-changelog/SKILL.md` (and + `changeset-changelog` in the skills list). Follow it for the fragment format. +- **The migration is done** — slice 6 is docs/changeset + confirmation only. + No further source changes are expected; if you find yourself editing + `src/`, re-check whether that's really in scope. - **New API cheat-sheet:** `ModelRuntime.create({ authPath, modelsPath, credentials? }): Promise`; credential persistence via the pi-ai `CredentialStore.modify` path; `runtime.login(providerId, type, From f539193c3d54177c372ba300e7075f318a335f1e Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 22:01:36 +0200 Subject: [PATCH 15/26] docs(changeset): add Pi 0.80.8+ ModelRuntime auth migration changeset (slice 6) Relay issue-62-authstorage leg 7 (final): add patch changeset for the session-daemon crash fix on Pi >=0.80.8, re-verify green, confirm goal criteria and cleanup. Relay complete. --- .changeset/fix-pi-0-80-8-modelruntime-auth.md | 5 ++ relays/issue-62-authstorage/log.md | 28 +++++++ relays/issue-62-authstorage/status.md | 80 ++++++++++--------- 3 files changed, 75 insertions(+), 38 deletions(-) create mode 100644 .changeset/fix-pi-0-80-8-modelruntime-auth.md diff --git a/.changeset/fix-pi-0-80-8-modelruntime-auth.md b/.changeset/fix-pi-0-80-8-modelruntime-auth.md new file mode 100644 index 0000000..751ff1d --- /dev/null +++ b/.changeset/fix-pi-0-80-8-modelruntime-auth.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Fix the session daemon crashing on startup with Pi (`@earendil-works/pi-coding-agent`) 0.80.8 and newer. Pi removed the `AuthStorage` API in 0.80.8, which caused Pi Web to fail at module load. Authentication, OAuth login, API-key save/logout, provider listing, and the Anthropic subscription warning now run on Pi's new `ModelRuntime` credential APIs. Pi Web now requires Pi `>=0.80.8`. diff --git a/relays/issue-62-authstorage/log.md b/relays/issue-62-authstorage/log.md index 7e89c3b..5092fff 100644 --- a/relays/issue-62-authstorage/log.md +++ b/relays/issue-62-authstorage/log.md @@ -367,3 +367,31 @@ typecheck + knip + eslint + related vitest) passed — committed normally this leg but slices 1 + 4 touched session-daemon paths; only the human clears it after restarting the sessiond service. Handing off to leg 7 (slice 6: changeset + final verify + cleanup; the finish line — no PR). + +## Leg 7 (slice 6 — changeset + final verify + cleanup) — RELAY COMPLETE +- **Added changeset** `.changeset/fix-pi-0-80-8-modelruntime-auth.md`: single + `patch` fragment for `@jmfederico/pi-web` describing the user-visible fix + (session daemon crash on Pi 0.80.8+ fixed by migrating auth to the new + `ModelRuntime` credential APIs; Pi Web now requires Pi `>=0.80.8`). Did NOT + edit `CHANGELOG.md`. +- **Bump type decision:** chose `patch`, not `minor`. status.md floated that a + minor was "defensible" (narrowed Pi range), but the `changeset-changelog` + skill is explicit for this CalVer repo: `patch` for all non-breaking changes, + never `minor` (the minor slot = release month), `major` only on explicit user + request. The fix is non-breaking to Pi Web consumers, so `patch` is correct. +- **Re-ran `npm run verify`:** fully GREEN — typecheck + lint + knip + 188 test + files, 1390 passed / 2 skipped. +- **Goal criteria confirmed:** (1) no live `AuthStorage`/`ModelRegistry.create| + inMemory`/`.authStorage`/`modelRegistry` use in `src/` — only remaining match + is an explanatory comment in `piSessionService.testSupport.ts`; (2) auth + surfaces all on new APIs (slices 1–5); (3) `package.json` peerDeps for the + three `@earendil-works/*` = `>=0.80.8 <0.81`, devDeps = `^0.80.8` (verified); + (4) changeset added this leg; (5) verify green. +- **Cleanup:** confirmed no scratch files in the repo (no `probe*.mjs`, + `.tmp-build/`). `ASSESSMENT-issue-62.md` intentionally kept (plan of record). +- **No PR opened** (explicitly out of scope for this relay). +- **STOP per charter:** goal reached, so no next leg was spawned. Surfaced to + the human: (a) verify green, (b) sessiond restart STILL PENDING (slices 1+4 + touched session-daemon paths; only the human clears that note after + restarting the sessiond service), (c) no PR by design. +- Committed status/log/changeset. diff --git a/relays/issue-62-authstorage/status.md b/relays/issue-62-authstorage/status.md index ed4ec98..affc3f6 100644 --- a/relays/issue-62-authstorage/status.md +++ b/relays/issue-62-authstorage/status.md @@ -1,12 +1,43 @@ # Relay status — issue-62-authstorage -## Current position +## RELAY COMPLETE — goal reached (leg 7, slice 6) +All charter goal criteria (1–5) are met and committed on branch +`fix/issue-62-authstorage`. **No PR was opened, by design (out of scope).** +The relay is finished; no further leg was spawned. + +**Surface to the human:** +- (a) `npm run verify` is fully GREEN (typecheck + lint + knip + 1390 tests, + 2 skipped). +- (b) **Sessiond restart is still PENDING** — slices 1 + 4 changed + session-daemon paths (`sessiond.ts`, `piSessionService.ts`). The human must + manually restart the sessiond service for the migration to take effect. Only + the human clears this note. +- (c) No PR opened (explicitly out of scope for this relay). + +Leg 7 (slice 6) added the changeset, re-verified green, confirmed goal +criteria, and confirmed no scratch files remain: +- **`.changeset/fix-pi-0-80-8-modelruntime-auth.md`** — a single `patch` + fragment for `@jmfederico/pi-web` describing the user-visible fix (session + daemon crash with Pi 0.80.8+ fixed by migrating to the new `ModelRuntime` + auth APIs; Pi Web now requires Pi `>=0.80.8`). Per the `changeset-changelog` + skill this repo uses **patch** for all non-breaking changes (CalVer: the + `minor` slot is the release month, not feature size; `major` only on explicit + request), so `patch` was chosen over the "minor is defensible" note. Commit + ``. +- Re-ran full `npm run verify`: GREEN. +- Double-checked `package.json`: peerDeps for the three `@earendil-works/*` + packages are `>=0.80.8 <0.81`, devDeps are `^0.80.8` — correct. +- Confirmed no scratch files in the repo (no `probe*.mjs`, `.tmp-build/`, + etc.). `ASSESSMENT-issue-62.md` intentionally stays (plan of record). +- Only `src` mention of the old API is an explanatory comment in + `piSessionService.testSupport.ts` (documents what the seam replaced) — no + live import/use. + +## Prior position (slice 5, leg 6, commit `d0cc55c`) Slice 5 (tests + testSupport) complete and committed (`d0cc55c`). -**`npm run verify` is fully GREEN** — typecheck + lint + knip + 1390 tests -pass (2 skipped). All production code and all test/support code are now off the -removed `AuthStorage` / `ModelRegistry.create|inMemory` surface. Goal criteria -1, 2, 5 are met; criteria 3 (dep ranges) was done in slice 0/1; only criterion -4 (changeset) remains — that is slice 6. +**`npm run verify` was fully GREEN** — typecheck + lint + knip + 1390 tests +pass (2 skipped). All production code and all test/support code are off the +removed `AuthStorage` / `ModelRegistry.create|inMemory` surface. What slice 5 changed (all under `src/server/sessions/`): - **`piSessionService.testSupport.ts`** (central helper): dropped @@ -162,39 +193,12 @@ Remaining errors otherwise live in slices 2/3/4 files and all test/support files (slice 5). ## Leg tracking -- **Last completed leg:** 6 (slice 5 — tests + testSupport migration). -- **Next leg to run:** 7. +- **Last completed leg:** 7 (slice 6 — changeset + final verify + cleanup). **FINAL LEG.** +- **Next leg to run:** none — relay complete, no handoff spawned. ## Next task -Run **charter slice 6 (changeset + final verify + cleanup)** as leg 7. This is -the closing leg: -1. Add a `.changeset/*.md` fragment for `@jmfederico/pi-web` describing the - user-visible fix (session daemon crash with Pi 0.80.8+ fixed by migrating - to the new `ModelRuntime` API; requires Pi `>=0.80.8`). Use the - `changeset-changelog` skill. Do **not** edit `CHANGELOG.md` directly. The - maintainer's call on patch vs minor — the assessment §5 suggests patch or - minor; a **minor** is defensible since the supported Pi range narrows - (`>=0.80.8 <0.81`, dropping 0.80.0–0.80.7), but follow the changeset skill - and keep it a single fragment. -2. Re-run the full `npm run verify` to confirm still green. -3. Confirm the goal criteria in `charter.md` are all met (1 no AuthStorage/ - registry use — done; 2 auth surfaces on new APIs — done; 3 dep ranges — - already corrected in slice 0/1, double-check `package.json` peer/dev ranges; - 4 changeset — this leg; 5 verify green — confirm). -4. Cleanup: `ASSESSMENT-issue-62.md` stays (it's the plan of record). - `/srv/dev/pi-inspect` is outside the repo, not ours to touch. Confirm no - scratch files were left in the repo (e.g. no stray `probe*.mjs`, - `.tmp-build/`). -5. **Do NOT open a PR** (explicitly out of scope for this relay). - -This is the finish line. After confirming everything, the goal is reached: -update `status.md`/`log.md`, commit, and per the charter **stop** (the goal is -reached) — or if you prefer, hand off a final "relay complete" confirmation -leg. Either way, surface to the human that the **sessiond restart is still -pending** (see Blockers) and a PR was intentionally not opened. - -Verification already passing as of slice 5 (leg 6): `npm run verify` green -(typecheck + lint + knip + 1390 tests, 2 skipped). +None — the relay goal is reached. If new work is needed (e.g. opening a PR), +that is a separate task outside this relay's charter. ### Build/tooling note (important for every leg) **Update (leg 2):** the human reports `/tmp` is now fully usable again, so the @@ -232,7 +236,7 @@ commit before handing off. Hand off with `spawn_session` **once** per the charter's Handover section. ## Blockers / intervention state -None. Known constraints: +None blocking. Relay complete. Known constraints: - **Sessiond restart pending (ACTIVE):** slice 1 (leg 2, commit `e37148c`) changed `sessiond.ts` + the session-daemon auth construction path; slice 4 (leg 5, commit `4ccd4f8`) added `piSessionService.ts` (a session-daemon path) From 910c6b5ae0912510683ee732518104d2d09411d0 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 22:38:36 +0200 Subject: [PATCH 16/26] refactor: derive auth-provider login options from SDK data instead of hardcoded provider lists --- .../data-driven-auth-provider-options.md | 5 ++++ .../sessions/authProviderOptions.test.ts | 25 +++++++++++-------- src/server/sessions/authProviderOptions.ts | 11 -------- 3 files changed, 19 insertions(+), 22 deletions(-) create mode 100644 .changeset/data-driven-auth-provider-options.md diff --git a/.changeset/data-driven-auth-provider-options.md b/.changeset/data-driven-auth-provider-options.md new file mode 100644 index 0000000..ac49865 --- /dev/null +++ b/.changeset/data-driven-auth-provider-options.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Offer API-key and OAuth login options for every provider the agent backend supports each method for, instead of a curated hardcoded list. Providers that support both methods (such as Anthropic and GitHub Copilot) now surface both an API-key and an OAuth login option, driven purely by what the backend reports. diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts index 2180f68..7ed820f 100644 --- a/src/server/sessions/authProviderOptions.test.ts +++ b/src/server/sessions/authProviderOptions.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; -import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderRuntime } from "./authProviderOptions"; +import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRuntime } from "./authProviderOptions"; function runtime(): AuthProviderRuntime { const credentials = [{ providerId: "openai", type: "api_key" as const }]; + // Auth shapes mirror what the Pi SDK actually reports for these providers: + // github-copilot supports both methods, openai-codex is oauth-only. const providers = [ { id: "anthropic", name: "Anthropic", auth: { oauth: {}, apiKey: {} } }, - { id: "github-copilot", name: "GitHub Copilot", auth: { oauth: {} } }, - { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", auth: { oauth: {}, apiKey: {} } }, + { id: "github-copilot", name: "GitHub Copilot", auth: { oauth: {}, apiKey: {} } }, + { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", auth: { oauth: {} } }, { id: "openai", name: "OpenAI", auth: { apiKey: {} } }, { id: "custom", name: "Custom", auth: { apiKey: {} } }, ]; @@ -18,21 +20,22 @@ function runtime(): AuthProviderRuntime { } describe("auth provider options", () => { - it("keeps OAuth-only providers out of API key login options", () => { - expect(isApiKeyLoginProvider("openai-codex", new Set(["openai-codex"]))).toBe(false); - expect(isApiKeyLoginProvider("github-copilot", new Set(["github-copilot"]))).toBe(false); - expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true); - }); - - it("builds login options for OAuth-only, dual-auth, and API-key providers", () => { + it("offers both api-key and oauth login options for every provider the backend supports each method for", () => { const options = getLoginProviderOptions(runtime()); expect(options).toEqual(expect.arrayContaining([ + // Dual-capable providers surface both login methods, driven purely by SDK data. expect.objectContaining({ id: "anthropic", authType: "oauth" }), expect.objectContaining({ id: "anthropic", authType: "api_key" }), - expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }), + expect.objectContaining({ id: "github-copilot", authType: "oauth" }), + expect.objectContaining({ id: "github-copilot", authType: "api_key" }), + // OAuth-only provider surfaces only oauth. expect.objectContaining({ id: "openai-codex", authType: "oauth" }), + // API-key-only providers surface only api_key. + expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }), + expect.objectContaining({ id: "custom", authType: "api_key" }), ])); expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })])); + expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai", authType: "oauth" })])); }); it("returns only currently stored credentials for logout", async () => { diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts index 94cc528..58409c6 100644 --- a/src/server/sessions/authProviderOptions.ts +++ b/src/server/sessions/authProviderOptions.ts @@ -1,7 +1,5 @@ import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js"; -const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]); - /** Minimal provider shape needed to enumerate login/logout options. */ interface AuthProviderInfo { id: string; @@ -29,7 +27,6 @@ export interface AuthProviderRuntime { export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] { const providers = runtime.getProviders(); - const oauthProviderIds = new Set(providers.filter((provider) => provider.auth.oauth !== undefined).map((provider) => provider.id)); const options: AuthProviderOption[] = []; for (const provider of providers) { @@ -44,7 +41,6 @@ export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: for (const provider of providers) { if (provider.auth.apiKey === undefined) continue; - if (!isApiKeyLoginProvider(provider.id, oauthProviderIds)) continue; options.push({ id: provider.id, name: provider.name, @@ -70,13 +66,6 @@ export async function getLogoutProviderOptions(runtime: AuthProviderRuntime): Pr return filterAndSort(options); } -export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet): boolean { - if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false; - if (providerId === "anthropic") return true; - if (oauthProviderIds.has(providerId)) return false; - return true; -} - function filterAndSort(options: AuthProviderOption[], authType?: AuthType): AuthProviderOption[] { const filtered = authType === undefined ? options : options.filter((option) => option.authType === authType); return filtered.sort((a, b) => a.name.localeCompare(b.name) || a.authType.localeCompare(b.authType) || a.id.localeCompare(b.id)); From a39cf49f3a0d2ed9a3b7c4b125e8d99275d3c643 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 23:18:58 +0200 Subject: [PATCH 17/26] fix(auth): prevent API key reuse across login prompts --- .../sessions/authProviderOptions.test.ts | 15 +- src/server/sessions/authProviderOptions.ts | 4 +- src/server/sessions/authService.test.ts | 148 +++++++++++++++++- src/server/sessions/authService.ts | 29 +++- 4 files changed, 180 insertions(+), 16 deletions(-) diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts index 7ed820f..b7d5112 100644 --- a/src/server/sessions/authProviderOptions.test.ts +++ b/src/server/sessions/authProviderOptions.test.ts @@ -4,13 +4,15 @@ import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRun function runtime(): AuthProviderRuntime { const credentials = [{ providerId: "openai", type: "api_key" as const }]; // Auth shapes mirror what the Pi SDK actually reports for these providers: - // github-copilot supports both methods, openai-codex is oauth-only. + // github-copilot supports both methods, openai-codex is OAuth-only, and + // ambient providers resolve credentials without offering interactive login. const providers = [ - { id: "anthropic", name: "Anthropic", auth: { oauth: {}, apiKey: {} } }, - { id: "github-copilot", name: "GitHub Copilot", auth: { oauth: {}, apiKey: {} } }, + { id: "anthropic", name: "Anthropic", auth: { oauth: {}, apiKey: { login: () => undefined } } }, + { id: "github-copilot", name: "GitHub Copilot", auth: { oauth: {}, apiKey: { login: () => undefined } } }, { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", auth: { oauth: {} } }, - { id: "openai", name: "OpenAI", auth: { apiKey: {} } }, - { id: "custom", name: "Custom", auth: { apiKey: {} } }, + { id: "openai", name: "OpenAI", auth: { apiKey: { login: () => undefined } } }, + { id: "custom", name: "Custom", auth: { apiKey: { login: () => undefined } } }, + { id: "ambient", name: "Ambient credentials", auth: { apiKey: {} } }, ]; return { getProviders: () => providers, @@ -20,7 +22,7 @@ function runtime(): AuthProviderRuntime { } describe("auth provider options", () => { - it("offers both api-key and oauth login options for every provider the backend supports each method for", () => { + it("offers each interactive login method reported by the backend", () => { const options = getLoginProviderOptions(runtime()); expect(options).toEqual(expect.arrayContaining([ // Dual-capable providers surface both login methods, driven purely by SDK data. @@ -36,6 +38,7 @@ describe("auth provider options", () => { ])); expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })])); expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai", authType: "oauth" })])); + expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "ambient", authType: "api_key" })])); }); it("returns only currently stored credentials for logout", async () => { diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts index 58409c6..529ae90 100644 --- a/src/server/sessions/authProviderOptions.ts +++ b/src/server/sessions/authProviderOptions.ts @@ -4,7 +4,7 @@ import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../sha interface AuthProviderInfo { id: string; name: string; - auth: { apiKey?: unknown; oauth?: unknown }; + auth: { apiKey?: { login?: unknown }; oauth?: unknown }; } /** Non-secret stored-credential metadata, keyed by provider id. */ @@ -40,7 +40,7 @@ export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: } for (const provider of providers) { - if (provider.auth.apiKey === undefined) continue; + if (provider.auth.apiKey?.login === undefined) continue; options.push({ id: provider.id, name: provider.name, diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 64fce77..0e49ece 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ModelRuntime } from "@earendil-works/pi-coding-agent"; -import { InMemoryCredentialStore, type Credential } from "@earendil-works/pi-ai"; +import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OAuthFlowState } from "../../shared/apiTypes.js"; import { AuthService, type AuthChange } from "./authService.js"; @@ -43,6 +43,127 @@ describe("AuthService", () => { auth.dispose(); }); + it("rejects Cloudflare multi-field setup without storing the secret as provider metadata", async () => { + const { auth, credentials, changes } = await createAuthService(); + + await expect(auth.saveApiKey("cloudflare-ai-gateway", "cf-secret")).rejects.toThrow( + "Cloudflare AI Gateway requires interactive setup; use Pi's generic /login flow", + ); + + await expect(credentials.read("cloudflare-ai-gateway")).resolves.toBeUndefined(); + expect(changes).toEqual([]); + auth.dispose(); + }); + + it.each([ + { providerId: "amazon-bedrock", providerName: "Amazon Bedrock" }, + { providerId: "google-vertex", providerName: "Google Vertex AI" }, + ])("rejects $providerName select-first setup without storing the secret", async ({ providerId, providerName }) => { + const { auth, credentials, changes } = await createAuthService(); + + await expect(auth.saveApiKey(providerId, "submitted-secret")).rejects.toThrow( + `${providerName} requires interactive setup; use Pi's generic /login flow`, + ); + + await expect(credentials.read(providerId)).resolves.toBeUndefined(); + expect(changes).toEqual([]); + auth.dispose(); + }); + + it.each([ + { label: "text", prompt: { type: "text", message: "Account" } satisfies AuthPrompt }, + { + label: "select", + prompt: { type: "select", message: "Region", options: [{ id: "us", label: "US" }] } satisfies AuthPrompt, + }, + { label: "manual-code", prompt: { type: "manual_code", message: "Code" } satisfies AuthPrompt }, + ])("rejects a first $label prompt before credential persistence", async ({ prompt }) => { + const { auth, runtime, credentials, changes } = await createAuthService(); + const login = mockLoginPromptsBeforePersistence(runtime, credentials, [prompt]); + + await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow( + "Anthropic requires interactive setup; use Pi's generic /login flow", + ); + + expect(login).toHaveBeenCalledOnce(); + await expect(credentials.read("anthropic")).resolves.toBeUndefined(); + expect(changes).toEqual([]); + auth.dispose(); + }); + + it("rejects a repeated secret prompt before credential persistence", async () => { + const { auth, runtime, credentials, changes } = await createAuthService(); + const login = mockLoginPromptsBeforePersistence(runtime, credentials, [ + { type: "secret", message: "API key" }, + { type: "secret", message: "API key again" }, + ]); + + await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow( + "Anthropic requires interactive setup; use Pi's generic /login flow", + ); + + expect(login).toHaveBeenCalledOnce(); + await expect(credentials.read("anthropic")).resolves.toBeUndefined(); + expect(changes).toEqual([]); + auth.dispose(); + }); + + it("rejects an aborted secret prompt before credential persistence", async () => { + const { auth, runtime, credentials, changes } = await createAuthService(); + const abort = new AbortController(); + abort.abort(); + const login = mockLoginPromptsBeforePersistence(runtime, credentials, [ + { type: "secret", message: "API key", signal: abort.signal }, + ]); + + await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow("Login cancelled"); + + expect(login).toHaveBeenCalledOnce(); + await expect(credentials.read("anthropic")).resolves.toBeUndefined(); + expect(changes).toEqual([]); + auth.dispose(); + }); + + it("rejects unknown providers before starting API-key login", async () => { + const { auth, runtime, credentials, changes } = await createAuthService(); + const login = vi.spyOn(runtime, "login"); + + await expect(auth.saveApiKey("unknown-provider", "sk-test")).rejects.toThrow( + "API key provider not found: unknown-provider", + ); + + expect(login).not.toHaveBeenCalled(); + await expect(credentials.read("unknown-provider")).resolves.toBeUndefined(); + expect(changes).toEqual([]); + auth.dispose(); + }); + + it("rejects ambient-only providers before starting API-key login", async () => { + const { auth, runtime, credentials, changes } = await createAuthService(); + const providers = [...runtime.getProviders()]; + const interactiveProvider = providers.find((provider) => provider.auth.apiKey?.login !== undefined); + if (interactiveProvider?.auth.apiKey === undefined) throw new Error("Expected an interactive API-key provider"); + const ambientApiKey = { ...interactiveProvider.auth.apiKey }; + delete ambientApiKey.login; + const ambientProvider = { + ...interactiveProvider, + id: "ambient-only", + name: "Ambient Only", + auth: { apiKey: ambientApiKey }, + }; + vi.spyOn(runtime, "getProviders").mockReturnValue([...providers, ambientProvider]); + const login = vi.spyOn(runtime, "login"); + + await expect(auth.saveApiKey("ambient-only", "sk-test")).rejects.toThrow( + "Ambient Only does not support interactive API-key setup", + ); + + expect(login).not.toHaveBeenCalled(); + await expect(credentials.read("ambient-only")).resolves.toBeUndefined(); + expect(changes).toEqual([]); + auth.dispose(); + }); + it("stores credentials in the configured agent directory", async () => { const agentDir = await tempAgentDir(); const auth = await AuthService.create({ agentDir }); @@ -54,7 +175,11 @@ describe("AuthService", () => { }); it("refreshes auth state after OAuth login completes", async () => { - const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore() }); + const runtime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath: null, + allowModelNetwork: false, + }); const authFlows = new CapturingOAuthLoginFlowService(); const auth = await AuthService.create({ runtime, authFlows }); const changes: AuthChange[] = []; @@ -88,11 +213,26 @@ async function createAuthService(seed: Record = {}) { for (const [providerId, credential] of Object.entries(seed)) { await credentials.modify(providerId, () => Promise.resolve(credential)); } - const runtime = await ModelRuntime.create({ credentials }); + const runtime = await ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false }); const auth = await AuthService.create({ runtime }); const changes: AuthChange[] = []; auth.subscribe((change) => { changes.push(change); }); - return { auth, credentials, changes }; + return { auth, runtime, credentials, changes }; +} + +function mockLoginPromptsBeforePersistence( + runtime: ModelRuntime, + credentials: InMemoryCredentialStore, + prompts: readonly AuthPrompt[], +) { + return vi.spyOn(runtime, "login").mockImplementation(async (providerId, _authType, interaction) => { + let key: string | undefined; + for (const prompt of prompts) key = await interaction.prompt(prompt); + if (key === undefined) throw new Error("Expected at least one login prompt"); + const credential: Credential = { type: "api_key", key }; + await credentials.modify(providerId, () => Promise.resolve(credential)); + return credential; + }); } async function tempAgentDir(): Promise { diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index 5d0679c..386e320 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -57,11 +57,20 @@ export class AuthService { async saveApiKey(providerId: string, key: string): Promise<{ accepted: true }> { if (key.trim() === "") throw new Error("API key is required"); - // The provider's api-key login prompts for the key and persists the returned - // credential through the runtime's credential store; feed the key back via a - // non-interactive AuthInteraction. + const provider = await this.requireApiKeyLoginProvider(providerId); + let promptAttempted = false; const interaction: AuthInteraction = { - prompt: () => Promise.resolve(key), + prompt: (prompt) => { + if (promptAttempted) { + throw new Error(`${provider.name} requires interactive setup; use Pi's generic /login flow`); + } + promptAttempted = true; + if (prompt.signal?.aborted === true) throw new Error("Login cancelled"); + if (prompt.type !== "secret") { + throw new Error(`${provider.name} requires interactive setup; use Pi's generic /login flow`); + } + return Promise.resolve(key); + }, notify: () => undefined, }; await this.runtime.login(providerId, "api_key", interaction); @@ -108,6 +117,18 @@ export class AuthService { for (const listener of this.listeners) listener(change); } + private async requireApiKeyLoginProvider(providerId: string) { + await this.runtime.refresh(); + const provider = getLoginProviderOptions(this.runtime, "api_key").find((option) => option.id === providerId); + if (provider !== undefined) return provider; + + const knownProvider = this.runtime.getProviders().find((option) => option.id === providerId); + if (knownProvider !== undefined) { + throw new Error(`${knownProvider.name} does not support interactive API-key setup`); + } + throw new Error(`API key provider not found: ${providerId}`); + } + private async requireOAuthLoginProvider(providerId: string) { await this.runtime.refresh(); const provider = getLoginProviderOptions(this.runtime, "oauth").find((option) => option.id === providerId); From 3a208e648efc3f88a35f0fb3067fefb07d1d3f30 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 23:34:58 +0200 Subject: [PATCH 18/26] fix(auth): preserve OAuth interaction semantics --- src/client/src/api/parsers.test.ts | 44 ++++- src/client/src/api/parsers.ts | 48 +++++- src/client/src/components/AuthDialog.test.ts | 11 ++ src/client/src/components/AuthDialog.ts | 28 +++- .../src/controllers/authController.test.ts | 20 +++ .../sessions/oauthLoginFlowService.test.ts | 133 +++++++++++++-- src/server/sessions/oauthLoginFlowService.ts | 152 ++++++++++++------ src/shared/apiTypes.ts | 17 +- 8 files changed, 387 insertions(+), 66 deletions(-) create mode 100644 src/client/src/components/AuthDialog.test.ts diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index e661852..6020eb5 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,8 +1,50 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { + it("preserves additive OAuth interaction semantics", () => { + expect(parseOAuthFlowState({ + flowId: "flow-1", + providerId: "provider", + providerName: "Provider", + status: "running", + auth: { + url: "https://example.test/device", + instructions: "Enter code", + deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 }, + }, + prompt: { requestId: "prompt-1", message: "Secret", kind: "prompt", promptType: "secret", allowEmpty: false, placeholder: "token" }, + select: { requestId: "select-1", message: "Choose", options: [{ value: "work", label: "Work", description: "Company account" }] }, + progress: ["Read the guide"], + info: [{ message: "Read the guide", links: [{ url: "https://example.test/docs", label: "Guide" }] }], + })).toMatchObject({ + auth: { deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 } }, + prompt: { kind: "prompt", promptType: "secret", allowEmpty: false }, + select: { options: [{ value: "work", description: "Company account" }] }, + info: [{ links: [{ url: "https://example.test/docs", label: "Guide" }] }], + }); + }); + + it("defaults semantic prompt types from legacy OAuth wire kinds", () => { + const flow = { + flowId: "flow-1", + providerId: "provider", + providerName: "Provider", + status: "running", + progress: [], + }; + + expect(parseOAuthFlowState({ ...flow, prompt: { requestId: "text", message: "Value", kind: "prompt" } }).prompt).toMatchObject({ + kind: "prompt", + promptType: "text", + }); + expect(parseOAuthFlowState({ ...flow, prompt: { requestId: "manual", message: "Code", kind: "manual" } }).prompt).toMatchObject({ + kind: "manual", + promptType: "manual_code", + }); + }); + it("parses PI WEB config responses", () => { expect(parsePiWebConfigResponse({ path: "/tmp/config.json", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 2a22399..d97aa17 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -392,6 +392,7 @@ export function parseOAuthFlowState(value: unknown): OAuthFlowState { ...optionalField("auth", optionalOAuthAuth(record["auth"])), ...optionalField("prompt", optionalOAuthPrompt(record["prompt"])), ...optionalField("select", optionalOAuthSelect(record["select"])), + ...optionalField("info", optionalOAuthInfo(record["info"])), }; return flow; } @@ -404,7 +405,21 @@ function parseOAuthFlowStatus(value: unknown): OAuthFlowState["status"] { function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined { if (value === undefined) return undefined; const record = requireRecord(value); - return { url: requireString(record, "url"), ...optionalField("instructions", optionalString(record, "instructions")) }; + return { + url: requireString(record, "url"), + ...optionalField("instructions", optionalString(record, "instructions")), + ...optionalField("deviceCode", optionalOAuthDeviceCode(record["deviceCode"])), + }; +} + +function optionalOAuthDeviceCode(value: unknown): NonNullable["deviceCode"] | undefined { + if (value === undefined) return undefined; + const record = requireRecord(value); + return { + userCode: requireString(record, "userCode"), + ...optionalField("intervalSeconds", optionalNumber(record, "intervalSeconds")), + ...optionalField("expiresInSeconds", optionalNumber(record, "expiresInSeconds")), + }; } function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefined { @@ -412,7 +427,20 @@ function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefin const record = requireRecord(value); const kind = requireString(record, "kind"); if (kind !== "prompt" && kind !== "manual") throw new Error("Invalid OAuth prompt kind"); - return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), kind, ...optionalField("placeholder", optionalString(record, "placeholder")), ...(record["allowEmpty"] === true ? { allowEmpty: true } : {}) }; + const promptType = record["promptType"] === undefined ? (kind === "manual" ? "manual_code" : "text") : parseOAuthPromptType(record["promptType"]); + return { + requestId: requireString(record, "requestId"), + message: requireString(record, "message"), + kind, + promptType, + ...optionalField("placeholder", optionalString(record, "placeholder")), + ...optionalField("allowEmpty", optionalBoolean(record, "allowEmpty")), + }; +} + +function parseOAuthPromptType(value: unknown): "text" | "secret" | "manual_code" { + if (value !== "text" && value !== "secret" && value !== "manual_code") throw new Error("Invalid OAuth prompt type"); + return value; } function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefined { @@ -421,6 +449,22 @@ function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefin return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), options: arrayOf(parseCommandOption)(record["options"]) }; } +function optionalOAuthInfo(value: unknown): OAuthFlowState["info"] | undefined { + if (value === undefined) return undefined; + return arrayOf((item) => { + const record = requireRecord(item); + return { + message: requireString(record, "message"), + ...optionalField("links", record["links"] === undefined ? undefined : arrayOf(parseOAuthInfoLink)(record["links"])), + }; + })(value); +} + +function parseOAuthInfoLink(value: unknown): NonNullable[number]["links"]>[number] { + const record = requireRecord(value); + return { url: requireString(record, "url"), ...optionalField("label", optionalString(record, "label")) }; +} + function optionalContextUsage(value: unknown): Pick | object { if (value === undefined) return {}; const record = requireRecord(value); diff --git a/src/client/src/components/AuthDialog.test.ts b/src/client/src/components/AuthDialog.test.ts new file mode 100644 index 0000000..788ccc9 --- /dev/null +++ b/src/client/src/components/AuthDialog.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { oauthPromptInputType } from "./AuthDialog"; + +describe("oauthPromptInputType", () => { + it("renders additive secret prompts as password inputs and defaults legacy prompts to text", () => { + expect(oauthPromptInputType("secret")).toBe("password"); + expect(oauthPromptInputType("text")).toBe("text"); + expect(oauthPromptInputType("manual_code")).toBe("text"); + expect(oauthPromptInputType(undefined)).toBe("text"); + }); +}); diff --git a/src/client/src/components/AuthDialog.ts b/src/client/src/components/AuthDialog.ts index 7225ee2..7663a55 100644 --- a/src/client/src/components/AuthDialog.ts +++ b/src/client/src/components/AuthDialog.ts @@ -1,7 +1,7 @@ import { LitElement, css, html } from "lit"; import { customElement, property, query } from "lit/decorators.js"; import type { AuthDialogState } from "../appState"; -import type { AuthProviderOption } from "../api"; +import type { AuthProviderOption, OAuthFlowState } from "../api"; import { commandPickerStyles } from "./shared"; @customElement("auth-dialog") @@ -86,22 +86,35 @@ export class AuthDialog extends LitElement { const flow = state.flow; const prompt = flow.prompt; const select = flow.select; + const promptInputType = oauthPromptInputType(prompt?.promptType); return html`
${flow.auth !== undefined ? html`

Open this authorization link:

${flow.auth.url}

- ${flow.auth.instructions !== undefined ? html`

${flow.auth.instructions}

` : null} + ${flow.auth.deviceCode !== undefined ? html` +

Enter code: ${flow.auth.deviceCode.userCode}

+ ` : flow.auth.instructions !== undefined ? html`

${flow.auth.instructions}

` : null} ` : html`

Starting login flow…

`} ${flow.progress.length > 0 ? html`
    ${flow.progress.map((line) => html`
  • ${line}
  • `)}
` : null} + ${flow.info?.map((item) => item.links === undefined || item.links.length === 0 ? null : html` + + `) ?? null} ${prompt !== undefined ? html` - { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}> + { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
` : null} ${select !== undefined ? html`

${select.message}

-
${select.options.map((option) => html``)}
+
${select.options.map((option) => html` + + `)}
` : null} ${state.error !== undefined && state.error !== "" ? html`
${state.error}
` : null} ${flow.status === "error" || flow.status === "cancelled" ? html`
${flow.error ?? flow.status}
` : null} @@ -157,11 +170,18 @@ export class AuthDialog extends LitElement { .warning { color: var(--pi-warning); } .error-text { color: var(--pi-danger); } .progress { margin: 0; padding-left: 18px; color: var(--pi-muted); } + .info-links { display: flex; flex-wrap: wrap; gap: 8px 12px; } .inline-options { display: grid; gap: 8px; } + .inline-options button { display: grid; gap: 2px; text-align: left; } + .inline-options small { color: var(--pi-muted); } em { color: var(--pi-success); font-style: normal; font-size: 12px; } `]; } +export function oauthPromptInputType(promptType: NonNullable["promptType"]): "text" | "password" { + return promptType === "secret" ? "password" : "text"; +} + function authTypeLabel(authType: "oauth" | "api_key"): string { return authType === "oauth" ? "subscription" : "API key"; } diff --git a/src/client/src/controllers/authController.test.ts b/src/client/src/controllers/authController.test.ts index e344174..1e9481b 100644 --- a/src/client/src/controllers/authController.test.ts +++ b/src/client/src/controllers/authController.test.ts @@ -43,6 +43,26 @@ describe("AuthController", () => { expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true }); }); + it("submits an allowed blank OAuth text response without client-side rejection", async () => { + const flow = oauthFlow({ + prompt: { requestId: "request-1", message: "GitHub Enterprise URL/domain (blank for github.com)", kind: "prompt", promptType: "text", allowEmpty: true }, + }); + const respondCalls: string[] = []; + const { controller } = createController( + { authDialog: { step: "oauth", flow, inputValue: "" } }, + { + respondOAuthFlow: (_flowId, _requestId, value) => { + respondCalls.push(value); + return Promise.resolve(oauthFlow({ status: "complete" })); + }, + }, + ); + + await controller.respondOAuth(); + + expect(respondCalls).toEqual([""]); + }); + it("resets OAuth prompt input and submit state when the request id changes", async () => { const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); const { controller, getState } = createController( diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts index 6bc75bb..6349b38 100644 --- a/src/server/sessions/oauthLoginFlowService.test.ts +++ b/src/server/sessions/oauthLoginFlowService.test.ts @@ -29,7 +29,7 @@ describe("OAuthLoginFlowService", () => { const prompt = state.prompt; if (prompt === undefined) throw new Error("Expected prompt"); expect(state).toMatchObject({ auth: { url: "https://example.test/auth", instructions: "Open it" }, progress: ["Waiting for code"] }); - expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt" }); + expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt", promptType: "text", allowEmpty: true }); const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123"); expect(afterRespond.prompt).toBeUndefined(); @@ -41,18 +41,103 @@ describe("OAuthLoginFlowService", () => { service.dispose(); }); + it("allows blank text responses for providers that use blank as a default", async () => { + let domain: string | undefined; + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "github-copilot", + providerName: "GitHub Copilot", + runtime: fakeRuntime(async (_providerId, interaction) => { + domain = await interaction.prompt({ + type: "text", + message: "GitHub Enterprise URL/domain (blank for github.com)", + }); + }), + }); + + const prompt = state.prompt; + if (prompt === undefined) throw new Error("Expected text prompt"); + expect(prompt).toMatchObject({ kind: "prompt", promptType: "text", allowEmpty: true }); + + service.respond(state.flowId, prompt.requestId, ""); + await flushAsyncLogin(); + + expect(domain).toBe(""); + expect(service.get(state.flowId).status).toBe("complete"); + service.dispose(); + }); + + it("preserves secret prompt semantics behind the legacy prompt kind", () => { + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + runtime: fakeRuntime(async (_providerId, interaction) => { + await interaction.prompt({ type: "secret", message: "Enter secret", placeholder: "token" }); + }), + }); + + const prompt = state.prompt; + if (prompt === undefined) throw new Error("Expected secret prompt"); + expect(prompt).toMatchObject({ + kind: "prompt", + promptType: "secret", + message: "Enter secret", + placeholder: "token", + }); + expect(prompt).not.toHaveProperty("allowEmpty"); + expect(() => { service.respond(state.flowId, prompt.requestId, ""); }).toThrow("A value is required"); + service.dispose(); + }); + + it("preserves info-event links without replacing the authorization URL", () => { + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + runtime: fakeRuntime(async (_providerId, interaction) => { + interaction.notify({ type: "auth_url", url: "https://example.test/login" }); + interaction.notify({ + type: "info", + message: "Review the provider setup guide", + links: [{ url: "https://example.test/docs", label: "Setup guide" }], + }); + await interaction.prompt({ type: "text", message: "Continue" }); + }), + }); + + expect(state).toMatchObject({ + auth: { url: "https://example.test/login" }, + progress: ["Review the provider setup guide"], + info: [{ message: "Review the provider setup guide", links: [{ url: "https://example.test/docs", label: "Setup guide" }] }], + }); + service.dispose(); + }); + it("surfaces device-code events through the auth field", () => { const service = new OAuthLoginFlowService(); const state = service.start({ providerId: "test-provider", providerName: "Test Provider", runtime: fakeRuntime(async (_providerId, interaction) => { - interaction.notify({ type: "device_code", userCode: "WXYZ-1234", verificationUri: "https://example.test/device" }); + interaction.notify({ + type: "device_code", + userCode: "WXYZ-1234", + verificationUri: "https://example.test/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }); await interaction.prompt({ type: "text", message: "Waiting" }); }), }); - expect(service.get(state.flowId)).toMatchObject({ auth: { url: "https://example.test/device", instructions: "Enter code: WXYZ-1234" } }); + expect(service.get(state.flowId)).toMatchObject({ + auth: { + url: "https://example.test/device", + instructions: "Enter code: WXYZ-1234", + deviceCode: { userCode: "WXYZ-1234", intervalSeconds: 5, expiresInSeconds: 900 }, + }, + }); service.dispose(); }); @@ -66,14 +151,14 @@ describe("OAuthLoginFlowService", () => { selectedValue = await interaction.prompt({ type: "select", message: "Choose account", - options: [{ id: "work", label: "Work" }, { id: "personal", label: "Personal" }], + options: [{ id: "work", label: "Work", description: "Company account" }, { id: "personal", label: "Personal" }], }); }), }); const select = state.select; if (select === undefined) throw new Error("Expected select prompt"); - expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work" }, { value: "personal", label: "Personal" }] }); + expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work", description: "Company account" }, { value: "personal", label: "Personal" }] }); service.respond(state.flowId, select.requestId, "personal"); await flushAsyncLogin(); @@ -83,25 +168,53 @@ describe("OAuthLoginFlowService", () => { service.dispose(); }); - it("uses a manual-code prompt for callback-server flows", async () => { - let manualValue: string | undefined; + it("rejects responses outside the pending select options", () => { const service = new OAuthLoginFlowService(); const state = service.start({ providerId: "test-provider", providerName: "Test Provider", runtime: fakeRuntime(async (_providerId, interaction) => { - manualValue = await interaction.prompt({ type: "manual_code", message: "Paste the callback URL or authorization code" }); + await interaction.prompt({ + type: "select", + message: "Choose account", + options: [{ id: "work", label: "Work" }], + }); + }), + }); + + const select = state.select; + if (select === undefined) throw new Error("Expected select prompt"); + expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid OAuth selection"); + expect(service.get(state.flowId).select).toEqual(select); + service.dispose(); + }); + + it("uses a manual-code prompt for callback-server flows and cleans up its abort listener", async () => { + let manualValue: string | undefined; + const service = new OAuthLoginFlowService(); + const controller = new AbortController(); + const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener"); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + runtime: fakeRuntime(async (_providerId, interaction) => { + manualValue = await interaction.prompt({ + type: "manual_code", + message: "Paste the callback URL or authorization code", + signal: controller.signal, + }); }), }); const prompt = state.prompt; if (prompt === undefined) throw new Error("Expected manual prompt"); - expect(prompt).toMatchObject({ kind: "manual", message: "Paste the callback URL or authorization code" }); + expect(prompt).toMatchObject({ kind: "manual", promptType: "manual_code", message: "Paste the callback URL or authorization code" }); service.respond(state.flowId, prompt.requestId, "https://localhost/callback?code=abc"); await flushAsyncLogin(); expect(manualValue).toBe("https://localhost/callback?code=abc"); + expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function)); expect(service.get(state.flowId).status).toBe("complete"); service.dispose(); }); @@ -110,6 +223,7 @@ describe("OAuthLoginFlowService", () => { const promptRejected = deferred(); const service = new OAuthLoginFlowService(); const controller = new AbortController(); + const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener"); const state = service.start({ providerId: "test-provider", providerName: "Test Provider", @@ -128,6 +242,7 @@ describe("OAuthLoginFlowService", () => { expect(state.prompt).toMatchObject({ kind: "manual" }); controller.abort(); await expect(promptRejected.promise).resolves.toMatchObject({ message: "Prompt cancelled" }); + expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function)); const afterAbort = service.get(state.flowId); expect(afterAbort.status).toBe("running"); diff --git a/src/server/sessions/oauthLoginFlowService.ts b/src/server/sessions/oauthLoginFlowService.ts index cf150bf..e5d9786 100644 --- a/src/server/sessions/oauthLoginFlowService.ts +++ b/src/server/sessions/oauthLoginFlowService.ts @@ -6,12 +6,16 @@ import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js"; /** The single runtime capability this service drives — narrowed for testable DI. */ type OAuthLoginRuntime = Pick; type TimerHandle = ReturnType; +type SelectPrompt = Extract; +type ValuePrompt = Exclude; interface PendingOAuthRequest { requestId: string; allowEmpty: boolean; resolve: (value: string) => void; reject: (error: Error) => void; + allowedValues?: ReadonlySet; + cleanup?: () => void; } interface OAuthFlowRecord { @@ -79,13 +83,13 @@ export class OAuthLoginFlowService { void options.runtime.login(options.providerId, "oauth", interaction) .then(() => { if (!this.isCurrentRunning(record)) return; - record.pending = undefined; + this.clearPending(record); this.markTerminal(record, { ...withoutInteraction(record.state), status: "complete", progress: [...record.state.progress, "Login complete"] }); options.onComplete?.(); }) .catch((error: unknown) => { if (this.flows.get(record.flowId) !== record) return; - record.pending = undefined; + this.clearPending(record); if (record.state.status !== "running") return; this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: error instanceof Error ? error.message : String(error) }); }); @@ -106,7 +110,8 @@ export class OAuthLoginFlowService { const pending = record.pending; if (pending?.requestId !== requestId) throw new Error("OAuth login request expired"); if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required"); - record.pending = undefined; + if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid OAuth selection"); + this.clearPending(record); this.updateState(record, withoutInteraction(record.state)); pending.resolve(value); return cloneState(record.state); @@ -117,8 +122,7 @@ export class OAuthLoginFlowService { if (record === undefined) throw new Error("OAuth login flow not found"); if (record.state.status === "running") { record.abort.abort(); - const pending = record.pending; - record.pending = undefined; + const pending = this.clearPending(record); this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" }); pending?.reject(new Error("Login cancelled")); } @@ -129,25 +133,15 @@ export class OAuthLoginFlowService { for (const record of this.flows.values()) { this.clearTimer(record); record.abort.abort(); - const pending = record.pending; - record.pending = undefined; + const pending = this.clearPending(record); pending?.reject(new Error("Login cancelled")); } this.flows.clear(); } private handlePrompt(record: OAuthFlowRecord, prompt: AuthPrompt): Promise { - if (prompt.type === "select") { - return this.waitForSelect(record, prompt.message, prompt.options, prompt.signal); - } - // `manual_code` is the paste-back path for callback-server flows; text/secret - // are ordinary interactive entry. Both map to the single web-UI prompt shape. - const kind = prompt.type === "manual_code" ? "manual" : "prompt"; - return this.waitForPrompt(record, { - message: prompt.message, - ...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }), - ...(prompt.signal === undefined ? {} : { signal: prompt.signal }), - }, kind); + if (prompt.type === "select") return this.waitForSelect(record, prompt); + return this.waitForPrompt(record, prompt); } private handleEvent(record: OAuthFlowRecord, event: AuthEvent): void { @@ -156,75 +150,127 @@ export class OAuthLoginFlowService { case "auth_url": this.updateState(record, { ...record.state, auth: { url: event.url, ...(event.instructions === undefined ? {} : { instructions: event.instructions }) } }); return; - // Device-code flows have no redirect URL; reuse the auth field so the web UI - // shows the verification link and user code without a dedicated API shape. + // Keep the legacy auth URL/instructions while adding structured metadata + // that newer browsers can use during rolling sessiond upgrades. case "device_code": - this.updateState(record, { ...record.state, auth: { url: event.verificationUri, instructions: `Enter code: ${event.userCode}` } }); + this.updateState(record, { + ...record.state, + auth: { + url: event.verificationUri, + instructions: `Enter code: ${event.userCode}`, + deviceCode: { + userCode: event.userCode, + ...(event.intervalSeconds === undefined ? {} : { intervalSeconds: event.intervalSeconds }), + ...(event.expiresInSeconds === undefined ? {} : { expiresInSeconds: event.expiresInSeconds }), + }, + }, + }); return; case "info": + this.updateState(record, { + ...record.state, + progress: [...record.state.progress, event.message], + info: [ + ...(record.state.info ?? []), + { + message: event.message, + ...(event.links === undefined ? {} : { + links: event.links.map((link) => ({ + url: link.url, + ...(link.label === undefined ? {} : { label: link.label }), + })), + }), + }, + ], + }); + return; case "progress": this.updateState(record, { ...record.state, progress: [...record.state.progress, event.message] }); return; } } - private waitForPrompt(record: OAuthFlowRecord, prompt: { message: string; placeholder?: string; signal?: AbortSignal }, kind: "prompt" | "manual"): Promise { + private waitForPrompt(record: OAuthFlowRecord, prompt: ValuePrompt): Promise { return new Promise((resolve, reject) => { if (!this.isCurrentRunning(record)) { reject(new Error("Login cancelled")); return; } - if (prompt.signal?.aborted === true) { - reject(new Error("Prompt cancelled")); - return; - } const requestId = crypto.randomUUID(); - record.pending = { requestId, allowEmpty: false, resolve, reject }; - this.bindPromptSignal(record, requestId, prompt.signal); + const pending: PendingOAuthRequest = { + requestId, + allowEmpty: prompt.type === "text", + resolve, + reject, + }; + record.pending = pending; + if (!this.bindPromptSignal(record, pending, prompt.signal)) return; const base = withoutInteraction(record.state); this.updateState(record, { ...base, prompt: { requestId, message: prompt.message, - kind, + kind: prompt.type === "manual_code" ? "manual" : "prompt", + promptType: prompt.type, + ...(prompt.type === "text" ? { allowEmpty: true } : {}), ...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }), }, }); }); } - private waitForSelect(record: OAuthFlowRecord, message: string, promptOptions: readonly { id: string; label: string; description?: string }[], signal?: AbortSignal): Promise { + private waitForSelect(record: OAuthFlowRecord, prompt: SelectPrompt): Promise { return new Promise((resolve, reject) => { if (!this.isCurrentRunning(record)) { reject(new Error("Login cancelled")); return; } - if (signal?.aborted === true) { - reject(new Error("Prompt cancelled")); - return; - } const requestId = crypto.randomUUID(); - const options: CommandOption[] = promptOptions.map((option) => ({ value: option.id, label: option.label })); - record.pending = { requestId, allowEmpty: true, resolve, reject }; - this.bindPromptSignal(record, requestId, signal); + const options: CommandOption[] = prompt.options.map((option) => ({ + value: option.id, + label: option.label, + ...(option.description === undefined ? {} : { description: option.description }), + })); + const pending: PendingOAuthRequest = { + requestId, + allowEmpty: false, + resolve, + reject, + allowedValues: new Set(options.map((option) => option.value)), + }; + record.pending = pending; + if (!this.bindPromptSignal(record, pending, prompt.signal)) return; const base = withoutInteraction(record.state); - this.updateState(record, { ...base, select: { requestId, message, options } }); + this.updateState(record, { ...base, select: { requestId, message: prompt.message, options } }); }); } // A prompt may carry its own AbortSignal (e.g. a manual_code prompt raced // against a callback server). When it fires, drop just that pending request // and clear the interaction from state — the overall login keeps running. - private bindPromptSignal(record: OAuthFlowRecord, requestId: string, signal?: AbortSignal): void { - if (signal === undefined) return; - signal.addEventListener("abort", () => { - const pending = record.pending; - if (pending?.requestId !== requestId) return; - record.pending = undefined; + private bindPromptSignal(record: OAuthFlowRecord, pending: PendingOAuthRequest, signal?: AbortSignal): boolean { + if (signal === undefined) return true; + const onAbort = () => { + if (record.pending !== pending) return; + this.clearPending(record); if (this.isCurrentRunning(record)) this.updateState(record, withoutInteraction(record.state)); pending.reject(new Error("Prompt cancelled")); - }, { once: true }); + }; + pending.cleanup = () => { signal.removeEventListener("abort", onAbort); }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + return false; + } + return true; + } + + private clearPending(record: OAuthFlowRecord): PendingOAuthRequest | undefined { + const pending = record.pending; + record.pending = undefined; + pending?.cleanup?.(); + return pending; } private isCurrentRunning(record: OAuthFlowRecord): boolean { @@ -270,8 +316,7 @@ export class OAuthLoginFlowService { private expireRunningFlow(record: OAuthFlowRecord): void { if (!this.isCurrentRunning(record)) return; record.abort.abort(); - const pending = record.pending; - record.pending = undefined; + const pending = this.clearPending(record); this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" }); pending?.reject(new Error("OAuth login flow expired")); } @@ -300,9 +345,20 @@ function cloneState(state: OAuthFlowState): OAuthFlowState { return { ...state, progress: [...state.progress], - ...(state.auth === undefined ? {} : { auth: { ...state.auth } }), + ...(state.auth === undefined ? {} : { + auth: { + ...state.auth, + ...(state.auth.deviceCode === undefined ? {} : { deviceCode: { ...state.auth.deviceCode } }), + }, + }), ...(state.prompt === undefined ? {} : { prompt: { ...state.prompt } }), ...(state.select === undefined ? {} : { select: { ...state.select, options: state.select.options.map((option) => ({ ...option })) } }), + ...(state.info === undefined ? {} : { + info: state.info.map((item) => ({ + ...item, + ...(item.links === undefined ? {} : { links: item.links.map((link) => ({ ...link })) }), + })), + }), }; } diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 24ffc97..78666d4 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -384,10 +384,23 @@ export interface OAuthFlowState { providerId: string; providerName: string; status: "running" | "complete" | "error" | "cancelled"; - auth?: { url: string; instructions?: string }; - prompt?: { requestId: string; message: string; placeholder?: string; allowEmpty?: boolean; kind: "prompt" | "manual" }; + auth?: { + url: string; + instructions?: string; + deviceCode?: { userCode: string; intervalSeconds?: number; expiresInSeconds?: number }; + }; + prompt?: { + requestId: string; + message: string; + placeholder?: string; + allowEmpty?: boolean; + /** Additive semantic detail; legacy peers continue to use `kind`. */ + promptType?: "text" | "secret" | "manual_code"; + kind: "prompt" | "manual"; + }; select?: { requestId: string; message: string; options: CommandOption[] }; progress: string[]; + info?: { message: string; links?: { url: string; label?: string }[] }[]; error?: string; } From 45f068ef05695c9b783e143dae7ed18de0769ff9 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 23:48:32 +0200 Subject: [PATCH 19/26] fix(runtime): reload model config at service boundaries --- src/server/sessions/authService.test.ts | 63 +++++++++++++-- src/server/sessions/authService.ts | 17 ++-- .../piSessionService.promptQueue.test.ts | 78 ++++++++++++++++++- src/server/sessions/piSessionService.ts | 10 +-- 4 files changed, 141 insertions(+), 27 deletions(-) diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 0e49ece..1751677 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ModelRuntime } from "@earendil-works/pi-coding-agent"; @@ -15,22 +15,28 @@ afterEach(async () => { }); describe("AuthService", () => { - it("saves API keys and emits a global auth change", async () => { - const { auth, credentials, changes } = await createAuthService(); + it("saves API keys and emits a global auth change after the runtime refreshes", async () => { + const { auth, runtime, credentials, changes } = await createAuthService(); + const reloadConfig = vi.spyOn(runtime, "reloadConfig").mockResolvedValue(undefined); + const refresh = vi.spyOn(runtime, "refresh"); await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true }); await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" }); + expect(reloadConfig).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledOnce(); expect(changes).toEqual([{}]); auth.dispose(); }); - it("logs out providers and emits the removed provider id", async () => { - const { auth, credentials, changes } = await createAuthService({ anthropic: { type: "api_key", key: "sk-test" } }); + it("logs out providers and emits the removed provider id after the runtime refreshes", async () => { + const { auth, runtime, credentials, changes } = await createAuthService({ anthropic: { type: "api_key", key: "sk-test" } }); + const refresh = vi.spyOn(runtime, "refresh"); await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true }); await expect(credentials.read("anthropic")).resolves.toBeUndefined(); + expect(refresh).toHaveBeenCalledOnce(); expect(changes).toEqual([{ removedProviderId: "anthropic" }]); auth.dispose(); }); @@ -164,6 +170,37 @@ describe("AuthService", () => { auth.dispose(); }); + it("reloads models.json before enumerating and validating OAuth providers", async () => { + const agentDir = await tempAgentDir(); + const modelsPath = join(agentDir, "models.json"); + const runtime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath, + allowModelNetwork: false, + }); + const authFlows = new CapturingOAuthLoginFlowService(); + const auth = await AuthService.create({ runtime, authFlows }); + + await writeFile(modelsPath, radiusModelsConfig("First Radius")); + const response = await auth.authProviders("login", "oauth"); + expect(response.providers).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "test-radius", name: "First Radius", authType: "oauth" }), + ])); + + await writeFile(modelsPath, radiusModelsConfig("Updated Radius")); + await expect(auth.startOAuthLogin("test-radius")).resolves.toMatchObject({ + providerId: "test-radius", + providerName: "Updated Radius", + status: "running", + }); + expect(authFlows.startCalls.at(0)).toMatchObject({ + providerId: "test-radius", + providerName: "Updated Radius", + runtime, + }); + auth.dispose(); + }); + it("stores credentials in the configured agent directory", async () => { const agentDir = await tempAgentDir(); const auth = await AuthService.create({ agentDir }); @@ -174,7 +211,7 @@ describe("AuthService", () => { auth.dispose(); }); - it("refreshes auth state after OAuth login completes", async () => { + it("emits an auth change after OAuth login completes without refreshing twice", async () => { const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), modelsPath: null, @@ -202,7 +239,7 @@ describe("AuthService", () => { startOptions.onComplete(); await vi.waitFor(() => { expect(changes).toEqual([{}]); }); - expect(refresh).toHaveBeenCalledOnce(); + expect(refresh).not.toHaveBeenCalled(); auth.dispose(); expect(authFlows.disposed).toBe(true); }); @@ -241,6 +278,18 @@ async function tempAgentDir(): Promise { return dir; } +function radiusModelsConfig(name: string): string { + return JSON.stringify({ + providers: { + "test-radius": { + name, + baseUrl: "https://radius.example.test/v1", + oauth: "radius", + }, + }, + }); +} + class CapturingOAuthLoginFlowService extends OAuthLoginFlowService { readonly startCalls: Parameters[0][] = []; disposed = false; diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index 386e320..ad7d650 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -50,7 +50,7 @@ export class AuthService { } async authProviders(mode: "login" | "logout", authType?: AuthType): Promise { - await this.runtime.refresh(); + await this.runtime.reloadConfig(); const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : getLoginProviderOptions(this.runtime, authType); return { providers }; } @@ -74,13 +74,13 @@ export class AuthService { notify: () => undefined, }; await this.runtime.login(providerId, "api_key", interaction); - await this.refreshAuthState(); + this.emit({}); return { accepted: true }; } async logoutProvider(providerId: string): Promise<{ accepted: true }> { await this.runtime.logout(providerId); - await this.refreshAuthState({ removedProviderId: providerId }); + this.emit({ removedProviderId: providerId }); return { accepted: true }; } @@ -91,7 +91,7 @@ export class AuthService { providerName: provider.name, runtime: this.runtime, onComplete: () => { - void this.refreshAuthState(); + this.emit({}); }, }); } @@ -108,17 +108,12 @@ export class AuthService { return this.authFlows.cancel(flowId); } - private async refreshAuthState(change: AuthChange = {}): Promise { - await this.runtime.refresh(); - this.emit(change); - } - private emit(change: AuthChange): void { for (const listener of this.listeners) listener(change); } private async requireApiKeyLoginProvider(providerId: string) { - await this.runtime.refresh(); + await this.runtime.reloadConfig(); const provider = getLoginProviderOptions(this.runtime, "api_key").find((option) => option.id === providerId); if (provider !== undefined) return provider; @@ -130,7 +125,7 @@ export class AuthService { } private async requireOAuthLoginProvider(providerId: string) { - await this.runtime.refresh(); + await this.runtime.reloadConfig(); const provider = getLoginProviderOptions(this.runtime, "oauth").find((option) => option.id === providerId); if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`); return provider; diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts index c4d4514..e9781cc 100644 --- a/src/server/sessions/piSessionService.promptQueue.test.ts +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -1,5 +1,9 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai"; import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.js"; import { CapturingSessionEventHub, createTestModelRuntime, fakeRuntime, runtimeCreator, seedCredential, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; @@ -347,12 +351,56 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { await service.dispose(); }); + it("reloads models.json before listing and selecting models", async () => { + const agentDir = await mkdtemp(join(tmpdir(), "pi-web-model-runtime-")); + try { + const modelsPath = join(agentDir, "models.json"); + await writeLocalModelsConfig(modelsPath, "initial-model"); + const modelRuntime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath, + allowModelNetwork: false, + }); + const setSessionModel = vi.fn(() => Promise.resolve()); + const fake = fakeRuntime("models-session", { modelRuntime, setModel: setSessionModel }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir, + modelRuntime, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("models-session")]), + heartbeatIntervalMs: 60_000, + }); + + try { + await writeLocalModelsConfig(modelsPath, "listed-model"); + const listed = await service.availableModels(sessionRef("models-session")); + expect(listed).toEqual(expect.arrayContaining([ + expect.objectContaining({ provider: "test-local", id: "listed-model" }), + ])); + expect(listed).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ provider: "test-local", id: "initial-model" }), + ])); + + await writeLocalModelsConfig(modelsPath, "selected-model"); + await expect(service.setModel(sessionRef("models-session"), "test-local", "selected-model")).resolves.toBeDefined(); + expect(setSessionModel).toHaveBeenCalledWith(expect.objectContaining({ + provider: "test-local", + id: "selected-model", + })); + } finally { + await service.dispose(); + } + } finally { + await rm(agentDir, { recursive: true, force: true }); + } + }); + it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => { const hub = new CapturingSessionEventHub(); - // The shared model runtime reads a live credential store; auth changes are - // simulated by mutating the store and refreshing the runtime (the same - // sequence AuthService performs before emitting an AuthChange), then - // notifying the service via applyAuthChange. + // The shared model runtime reads a live credential store. Mutating the store + // and refreshing here simulates the committed snapshot that + // ModelRuntime.login()/logout() establishes before AuthService emits. + // applyAuthChange then only needs to notify active sessions. const credentials = new InMemoryCredentialStore(); await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-test" }); const modelRuntime = await createTestModelRuntime(credentials); @@ -409,3 +457,25 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { await service.dispose(); }); }); + +async function writeLocalModelsConfig(path: string, modelId: string): Promise { + await writeFile(path, JSON.stringify({ + providers: { + "test-local": { + name: "Test Local", + baseUrl: "http://127.0.0.1:1234/v1", + apiKey: "offline-test-key", + api: "openai-completions", + models: [{ + id: modelId, + name: modelId, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000, + maxTokens: 100, + }], + }, + }, + })); +} diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 5899e3c..1e8d9cd 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -1158,7 +1158,7 @@ export class PiSessionService implements SessionRouteService { async availableModels(ref: PiSessionLookup): Promise { const session = await this.getOrOpen(ref); - await session.modelRuntime.refresh(); + await session.modelRuntime.reloadConfig(); const models = session.scopedModels.length > 0 ? session.scopedModels.map((scoped) => scoped.model) : session.modelRuntime.getAvailableSnapshot(); @@ -1168,7 +1168,7 @@ export class PiSessionService implements SessionRouteService { async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise { await this.assertWritable(ref); const session = await this.getOrOpen(ref); - await session.modelRuntime.refresh(); + await session.modelRuntime.reloadConfig(); const candidates = session.scopedModels.length > 0 ? session.scopedModels.map((scoped) => scoped.model) : session.modelRuntime.getAvailableSnapshot(); @@ -2019,9 +2019,9 @@ export class PiSessionService implements SessionRouteService { } applyAuthChange(change: AuthChange = {}): void { - // The shared model runtime is refreshed by AuthService before it emits the - // change (and every session shares that runtime), so no refresh is needed - // here — this keeps the subscribe callback synchronous. + // ModelRuntime.login()/logout() refresh the shared runtime before AuthService + // emits the change, so no refresh is needed here. Keeping this synchronous + // also lets every active session observe the same committed auth snapshot. for (const active of this.active.values()) { const { session } = active.runtime; this.syncCurrentModelAuthWarning(session, change.removedProviderId); From 1f13bab58adb1265d5174cbcb95629f70cc1a982 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 23:58:56 +0200 Subject: [PATCH 20/26] fix(realtime): isolate notification failures --- src/server/realtime/sessionEventHub.test.ts | 42 ++++++++++ src/server/realtime/sessionEventHub.ts | 18 ++-- src/server/sessiond.ts | 2 +- src/server/sessions/authService.test.ts | 84 +++++++++++++++++-- src/server/sessions/authService.ts | 39 ++++++--- .../sessions/oauthLoginFlowService.test.ts | 19 +++++ src/server/sessions/oauthLoginFlowService.ts | 7 +- 7 files changed, 187 insertions(+), 24 deletions(-) diff --git a/src/server/realtime/sessionEventHub.test.ts b/src/server/realtime/sessionEventHub.test.ts index bf190e5..08641b9 100644 --- a/src/server/realtime/sessionEventHub.test.ts +++ b/src/server/realtime/sessionEventHub.test.ts @@ -54,6 +54,28 @@ describe("SessionEventHub", () => { expect(removed.send).not.toHaveBeenCalled(); }); + it("continues publishing session events when one socket send fails", () => { + const hub = new SessionEventHub(); + const failed = new FakeSocket(); + const healthy = new FakeSocket(); + failed.send.mockImplementation(() => { throw new Error("socket closed"); }); + hub.add("s1", failed); + hub.add("s1", healthy); + + hub.publish("s1", { type: "assistant.delta", text: "hello" }); + + expect(failed.send).toHaveBeenCalledOnce(); + expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "assistant.delta", text: "hello", seq: 1 })); + expect(hub.currentSeq("s1")).toBe(1); + + failed.send.mockClear(); + hub.publish("s1", { type: "assistant.delta", text: "again" }); + + expect(failed.send).not.toHaveBeenCalled(); + expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "assistant.delta", text: "again", seq: 2 })); + expect(hub.currentSeq("s1")).toBe(2); + }); + it("publishes global events only to global sockets", () => { const hub = new SessionEventHub(); const globalSocket = new FakeSocket(); @@ -78,6 +100,26 @@ describe("SessionEventHub", () => { expect(sessionSocket.send).not.toHaveBeenCalled(); }); + it("continues publishing unstamped global events when one socket send fails", () => { + const hub = new SessionEventHub(); + const failed = new FakeSocket(); + const healthy = new FakeSocket(); + failed.send.mockImplementation(() => { throw new Error("socket closed"); }); + hub.addGlobal(failed); + hub.addGlobal(healthy); + + hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed" }); + + expect(failed.send).toHaveBeenCalledOnce(); + expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed" })); + + failed.send.mockClear(); + hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed again" }); + + expect(failed.send).not.toHaveBeenCalled(); + expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed again" })); + }); + it("stamps a monotonically increasing per-session seq on published events", () => { const hub = new SessionEventHub(); const socket = new FakeSocket(); diff --git a/src/server/realtime/sessionEventHub.ts b/src/server/realtime/sessionEventHub.ts index b57e6d1..9df8a1a 100644 --- a/src/server/realtime/sessionEventHub.ts +++ b/src/server/realtime/sessionEventHub.ts @@ -34,9 +34,7 @@ export class SessionEventHub { const seq = (this.seqBySession.get(sessionId) ?? 0) + 1; this.seqBySession.set(sessionId, seq); const payload = JSON.stringify({ ...projectBrowserSessionEvent(event), seq }); - for (const socket of this.socketsBySession.get(sessionId) ?? []) { - if (socket.readyState === socket.OPEN) socket.send(payload); - } + this.sendToSockets(this.socketsBySession.get(sessionId), payload); } /** @@ -55,8 +53,18 @@ export class SessionEventHub { publishRealtime(event: RealtimeEvent): void { const payload = JSON.stringify(event); - for (const socket of this.globalSockets) { - if (socket.readyState === socket.OPEN) socket.send(payload); + this.sendToSockets(this.globalSockets, payload); + } + + private sendToSockets(sockets: Set | undefined, payload: string): void { + if (sockets === undefined) return; + for (const socket of sockets) { + if (socket.readyState !== socket.OPEN) continue; + try { + socket.send(payload); + } catch { + sockets.delete(socket); + } } } } diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 0e31a9d..e35fde1 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -39,7 +39,7 @@ await runSessionDaemonStartup({ async createRuntime() { const eventHub = new SessionEventHub(); const workspaceActivity = new WorkspaceActivityService(eventHub); - const auth = await AuthService.create({ agentDir: activeAgentProfile.dir }); + const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log }); const spawnTargets = config.spawnSessions ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) : undefined; diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 1751677..599faae 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -5,7 +5,7 @@ import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OAuthFlowState } from "../../shared/apiTypes.js"; -import { AuthService, type AuthChange } from "./authService.js"; +import { AuthService, type AuthChange, type AuthServiceLogger } from "./authService.js"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; const tempDirs: string[] = []; @@ -41,6 +41,54 @@ describe("AuthService", () => { auth.dispose(); }); + it("persists an API key and attempts every listener when propagation fails", async () => { + const error = vi.fn(); + const logger: AuthServiceLogger = { error }; + const { auth, credentials, changes } = await createAuthService({}, logger); + const failure = new Error("session auth refresh failed"); + const attempts: string[] = []; + auth.subscribe(() => { + attempts.push("throwing"); + throw failure; + }); + auth.subscribe(async () => { + await Promise.resolve(); + attempts.push("healthy"); + }); + + await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true }); + + await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" }); + expect(changes).toEqual([{}]); + expect(attempts).toEqual(["throwing", "healthy"]); + expect(error).toHaveBeenCalledWith( + { err: failure, operation: "login", providerId: "anthropic", authType: "api_key" }, + "auth-change listener failed", + ); + auth.dispose(); + }); + + it("removes a credential when auth-change propagation rejects", async () => { + const error = vi.fn(); + const logger: AuthServiceLogger = { error }; + const { auth, credentials, changes } = await createAuthService( + { anthropic: { type: "api_key", key: "sk-test" } }, + logger, + ); + const failure = new Error("session logout refresh failed"); + auth.subscribe(() => Promise.reject(failure)); + + await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true }); + + await expect(credentials.read("anthropic")).resolves.toBeUndefined(); + expect(changes).toEqual([{ removedProviderId: "anthropic" }]); + expect(error).toHaveBeenCalledWith( + { err: failure, operation: "logout", providerId: "anthropic" }, + "auth-change listener failed", + ); + auth.dispose(); + }); + it("rejects blank API keys", async () => { const { auth, changes } = await createAuthService(); @@ -236,22 +284,48 @@ describe("AuthService", () => { refresh.mockClear(); if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback"); - startOptions.onComplete(); - await vi.waitFor(() => { expect(changes).toEqual([{}]); }); + await startOptions.onComplete(); + expect(changes).toEqual([{}]); expect(refresh).not.toHaveBeenCalled(); auth.dispose(); expect(authFlows.disposed).toBe(true); }); + + it("completes OAuth when an auth-change listener rejects", async () => { + const error = vi.fn(); + const logger: AuthServiceLogger = { error }; + const { auth, runtime, changes } = await createAuthService({}, logger); + const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined); + if (provider === undefined) throw new Error("Expected built-in OAuth provider"); + vi.spyOn(runtime, "login").mockResolvedValue({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 60_000, + }); + const failure = new Error("session OAuth refresh failed"); + auth.subscribe(() => Promise.reject(failure)); + + const state = await auth.startOAuthLogin(provider.id); + await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); }); + + expect(changes).toEqual([{}]); + expect(error).toHaveBeenCalledWith( + { err: failure, operation: "login", providerId: provider.id, authType: "oauth" }, + "auth-change listener failed", + ); + auth.dispose(); + }); }); -async function createAuthService(seed: Record = {}) { +async function createAuthService(seed: Record = {}, logger?: AuthServiceLogger) { const credentials = new InMemoryCredentialStore(); for (const [providerId, credential] of Object.entries(seed)) { await credentials.modify(providerId, () => Promise.resolve(credential)); } const runtime = await ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false }); - const auth = await AuthService.create({ runtime }); + const auth = await AuthService.create({ runtime, ...(logger === undefined ? {} : { logger }) }); const changes: AuthChange[] = []; auth.subscribe((change) => { changes.push(change); }); return { auth, runtime, credentials, changes }; diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index ad7d650..6f91d85 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -9,14 +9,28 @@ export interface AuthChange { removedProviderId?: string; } -type AuthChangeListener = (change: AuthChange) => void; +type AuthChangeListener = (change: AuthChange) => void | Promise; export interface AuthServiceDependencies { agentDir?: string; runtime?: ModelRuntime; authFlows?: OAuthLoginFlowService; + logger?: AuthServiceLogger; } +/** Minimal structured-logging seam for non-fatal auth propagation failures. */ +export interface AuthServiceLogger { + error(details: Record, message: string): void; +} + +interface AuthChangeContext { + operation: "login" | "logout"; + providerId: string; + authType?: AuthType; +} + +const noopLogger: AuthServiceLogger = { error() { /* no-op */ } }; + export function createModelRuntimeForAgentDir(agentDir: string): Promise { return ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") }); } @@ -24,17 +38,19 @@ export function createModelRuntimeForAgentDir(agentDir: string): Promise(); - private constructor(runtime: ModelRuntime, authFlows: OAuthLoginFlowService) { + private constructor(runtime: ModelRuntime, authFlows: OAuthLoginFlowService, logger: AuthServiceLogger) { this.runtime = runtime; this.authFlows = authFlows; + this.logger = logger; } static async create(deps: AuthServiceDependencies = {}): Promise { const runtime = deps.runtime ?? (deps.agentDir === undefined ? await ModelRuntime.create({}) : await createModelRuntimeForAgentDir(deps.agentDir)); const authFlows = deps.authFlows ?? new OAuthLoginFlowService(); - return new AuthService(runtime, authFlows); + return new AuthService(runtime, authFlows, deps.logger ?? noopLogger); } subscribe(listener: AuthChangeListener): () => void { @@ -74,13 +90,13 @@ export class AuthService { notify: () => undefined, }; await this.runtime.login(providerId, "api_key", interaction); - this.emit({}); + await this.emit({}, { operation: "login", providerId, authType: "api_key" }); return { accepted: true }; } async logoutProvider(providerId: string): Promise<{ accepted: true }> { await this.runtime.logout(providerId); - this.emit({ removedProviderId: providerId }); + await this.emit({ removedProviderId: providerId }, { operation: "logout", providerId }); return { accepted: true }; } @@ -90,9 +106,7 @@ export class AuthService { providerId, providerName: provider.name, runtime: this.runtime, - onComplete: () => { - this.emit({}); - }, + onComplete: () => this.emit({}, { operation: "login", providerId, authType: "oauth" }), }); } @@ -108,8 +122,13 @@ export class AuthService { return this.authFlows.cancel(flowId); } - private emit(change: AuthChange): void { - for (const listener of this.listeners) listener(change); + private async emit(change: AuthChange, context: AuthChangeContext): Promise { + const results = await Promise.allSettled([...this.listeners].map(async (listener) => listener(change))); + for (const result of results) { + if (result.status === "rejected") { + this.logger.error({ err: result.reason, ...context }, "auth-change listener failed"); + } + } } private async requireApiKeyLoginProvider(providerId: string) { diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts index 6349b38..d42fbe8 100644 --- a/src/server/sessions/oauthLoginFlowService.test.ts +++ b/src/server/sessions/oauthLoginFlowService.test.ts @@ -41,6 +41,25 @@ describe("OAuthLoginFlowService", () => { service.dispose(); }); + it("awaits async completion propagation before marking the flow complete", async () => { + const completion = deferred(); + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + runtime: fakeRuntime(() => Promise.resolve()), + onComplete: () => completion.promise, + }); + + await flushAsyncLogin(); + expect(service.get(state.flowId).status).toBe("running"); + + completion.resolve(undefined); + await flushAsyncLogin(); + expect(service.get(state.flowId).status).toBe("complete"); + service.dispose(); + }); + it("allows blank text responses for providers that use blank as a default", async () => { let domain: string | undefined; const service = new OAuthLoginFlowService(); diff --git a/src/server/sessions/oauthLoginFlowService.ts b/src/server/sessions/oauthLoginFlowService.ts index e5d9786..214f694 100644 --- a/src/server/sessions/oauthLoginFlowService.ts +++ b/src/server/sessions/oauthLoginFlowService.ts @@ -52,7 +52,7 @@ export class OAuthLoginFlowService { providerId: string; providerName: string; runtime: OAuthLoginRuntime; - onComplete?: () => void; + onComplete?: () => void | Promise; }): OAuthFlowState { const flowId = crypto.randomUUID(); const abort = new AbortController(); @@ -81,11 +81,12 @@ export class OAuthLoginFlowService { }; void options.runtime.login(options.providerId, "oauth", interaction) - .then(() => { + .then(async () => { if (!this.isCurrentRunning(record)) return; this.clearPending(record); + await options.onComplete?.(); + if (!this.isCurrentRunning(record)) return; this.markTerminal(record, { ...withoutInteraction(record.state), status: "complete", progress: [...record.state.progress, "Login complete"] }); - options.onComplete?.(); }) .catch((error: unknown) => { if (this.flows.get(record.flowId) !== record) return; From aca168a31117bcf6784c45d2b7f53d8623dca9bb Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 18 Jul 2026 00:10:47 +0200 Subject: [PATCH 21/26] fix(runtime): align supported requirements and release hygiene --- .../data-driven-auth-provider-options.md | 5 - .changeset/fix-pi-0-80-8-modelruntime-auth.md | 2 +- ASSESSMENT-issue-62.md | 391 ----------------- README.md | 2 +- docs/faq.html | 4 +- docs/index.html | 4 +- docs/install.html | 2 +- package-lock.json | 2 +- package.json | 2 +- relays/issue-62-authstorage/charter.md | 127 ------ relays/issue-62-authstorage/log.md | 397 ------------------ relays/issue-62-authstorage/status.md | 249 ----------- src/cli.test.ts | 13 + src/cli.ts | 16 +- src/nativeServices/servicePlan.test.ts | 10 +- src/nativeServices/servicePlan.ts | 8 +- src/nativeServices/serviceProbe.test.ts | 19 +- src/nativeServices/serviceProbe.ts | 13 +- src/server/sessions/authService.test.ts | 5 +- src/server/sessions/authService.ts | 8 +- .../sessions/piSessionService.testSupport.ts | 2 +- 21 files changed, 77 insertions(+), 1204 deletions(-) delete mode 100644 .changeset/data-driven-auth-provider-options.md delete mode 100644 ASSESSMENT-issue-62.md delete mode 100644 relays/issue-62-authstorage/charter.md delete mode 100644 relays/issue-62-authstorage/log.md delete mode 100644 relays/issue-62-authstorage/status.md diff --git a/.changeset/data-driven-auth-provider-options.md b/.changeset/data-driven-auth-provider-options.md deleted file mode 100644 index ac49865..0000000 --- a/.changeset/data-driven-auth-provider-options.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Offer API-key and OAuth login options for every provider the agent backend supports each method for, instead of a curated hardcoded list. Providers that support both methods (such as Anthropic and GitHub Copilot) now surface both an API-key and an OAuth login option, driven purely by what the backend reports. diff --git a/.changeset/fix-pi-0-80-8-modelruntime-auth.md b/.changeset/fix-pi-0-80-8-modelruntime-auth.md index 751ff1d..0e25b42 100644 --- a/.changeset/fix-pi-0-80-8-modelruntime-auth.md +++ b/.changeset/fix-pi-0-80-8-modelruntime-auth.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Fix the session daemon crashing on startup with Pi (`@earendil-works/pi-coding-agent`) 0.80.8 and newer. Pi removed the `AuthStorage` API in 0.80.8, which caused Pi Web to fail at module load. Authentication, OAuth login, API-key save/logout, provider listing, and the Anthropic subscription warning now run on Pi's new `ModelRuntime` credential APIs. Pi Web now requires Pi `>=0.80.8`. +Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Login options now follow each provider's interactive API-key and OAuth capabilities, OAuth prompts retain their input, selection, and device-code semantics, and unsupported multi-step API-key setup fails safely instead of storing malformed credentials. PI WEB now requires Node.js `>=22.19.0`. diff --git a/ASSESSMENT-issue-62.md b/ASSESSMENT-issue-62.md deleted file mode 100644 index d2958c1..0000000 --- a/ASSESSMENT-issue-62.md +++ /dev/null @@ -1,391 +0,0 @@ -# Assessment — Issue #62: `AuthStorage` export removed in Pi 0.80.8 - -## 1. Summary - -Pi Web's session daemon crashes at ESM module initialization after -`@earendil-works/pi-coding-agent` is resolved at **0.80.8 or later**: - -``` -SyntaxError: The requested module '@earendil-works/pi-coding-agent' -does not provide an export named 'AuthStorage' -``` - -The crash is a hard, load-time failure (a static `import { AuthStorage } ...` -that no longer resolves), so Pi Web is completely unusable with any Pi in the -0.80.8+ line. The permissive peer/dev range `>=0.80.0 <1` lets npm resolve the -incompatible release. - -**Root cause:** Pi 0.80.8 is a **major architectural refactor** of model/auth -plumbing ("Unified model runtime and provider authentication"), explicitly -listed under **Breaking Changes** in the upstream CHANGELOG. `AuthStorage` (and -its storage backends `FileAuthStorageBackend`, `InMemoryAuthStorageBackend`, -and the credential type exports) were **removed from the package's public -exports**. The class still exists internally but is no longer exported; the new -public surface is `ModelRuntime` (async) plus a synchronous compatibility -`ModelRegistry` facade with a different shape, and `readStoredCredential()` for -one-off reads. - -**Recommendation (see §5):** Do **not** attempt a dual-API compatibility shim. -The change is a deep semantic refactor (sync → async, credential store contract -change, removal of `authStorage` from services, `ModelRegistry` constructor and -method-signature changes). A clean migration to the `ModelRuntime` API, -combined with pinning the supported Pi range to `>=0.80.8 <0.81`, is the correct -fix and warrants a Pi Web version bump via a changeset. - ---- - -## 2. Where and how `AuthStorage` / `ModelRegistry` are used in `src/` - -All usage is under `src/server/sessions/`. Production files (3) and test/support -files (5). - -### Production code - -**`authService.ts`** — the central auth wiring. -- `import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"`. -- `type ModelRegistryInstance = ReturnType`. -- `createModelRegistryForAgentDir(agentDir)`: - `AuthStorage.create(join(agentDir, "auth.json"))` then - `ModelRegistry.create(authStorage, join(agentDir, "models.json"))`. -- Constructor fallback: `ModelRegistry.create(AuthStorage.create())`. -- Reads/writes credentials through `this.modelRegistry.authStorage`: - - `.set(providerId, { type: "api_key", key })` (saveApiKey) - - `.logout(providerId)` (logoutProvider) - - `.reload()` (refreshAuthState) - - passes `this.modelRegistry.authStorage` into the OAuth login flow. -- Uses `this.modelRegistry.refresh()` (currently synchronous `void`). - -**`oauthLoginFlowService.ts`** — OAuth login orchestration for the web UI. -- `import type { AuthStorage } from "@earendil-works/pi-coding-agent"`. -- `type OAuthLoginStorage = Pick`. -- Calls `authStorage.login(providerId, callbacks)` where `callbacks` is the - old `OAuthLoginCallbacks` shape: `signal`, `onAuth`, `onDeviceCode`, - `onPrompt`, `onManualCodeInput`, `onSelect`, `onProgress`. - -**`piSessionService.ts`** — session runtime factory + warnings. -- `import { AuthStorage, ..., ModelRegistry, ... }`. -- `type ModelRegistryInstance = ReturnType`. -- `createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry, ...)` - calls `createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry })`. -- Uses `createModelRegistryForAgentDir` fallback; passes - `this.modelRegistry.authStorage` and `this.modelRegistry` into the runtime - factory (around lines 605–612). -- `anthropicSubscriptionWarning()` reads - `session.modelRegistry.authStorage.get("anthropic")` and inspects - `credential.type` / `credential.key`. -- `PiAgentSession.modelRegistry: ModelRegistryInstance` is part of the internal - session interface. - -**`authProviderOptions.ts`** — provider enumeration (no direct SDK import; uses a -structural `AuthProviderModelRegistry` interface). Depends on the current -`ModelRegistry`/`AuthStorage` shape: -- `modelRegistry.authStorage.getOAuthProviders()` → `{ id, name }[]` -- `modelRegistry.authStorage.list()` → `string[]` -- `modelRegistry.authStorage.get(provider)` → `{ type } | undefined` -- `modelRegistry.getAll()` → `{ provider }[]` -- `modelRegistry.getProviderDisplayName(provider)` -- `modelRegistry.getProviderAuthStatus(provider)` - -### Test / support code -- `authService.test.ts` — `AuthStorage.inMemory(...)`, `ModelRegistry.create(...)`, - asserts `startOptions.authStorage`. -- `piSessionService.testSupport.ts` — `ModelRegistry.inMemory(AuthStorage.inMemory())`, - `ModelRegistry.create(AuthStorage.inMemory())`. -- `piSessionService.promptQueue.test.ts` — `AuthStorage.inMemory({ anthropic: {...} })`, - `ModelRegistry.inMemory(authStorage)`. -- `piSessionService.warnings.test.ts` — `AuthStorage.inMemory()`, - `ModelRegistry.inMemory/create`, builds anthropic credentials via - `authStorage.set(...)`. -- `oauthLoginFlowService.test.ts` — `Pick` fake. -- `authProviderOptions.test.ts` — structural `AuthProviderModelRegistry` fake - (no SDK import; must track whatever `authProviderOptions.ts` requires). - -### Other pi-coding-agent imports (unaffected — still exported in 0.80.8) -`DefaultPackageManager`, `SettingsManager` (piPackageService, piWebPluginService, -piWebStatus), `createAgentSessionServices`, `createAgentSessionFromServices`, -`createAgentSessionRuntime`, `AgentSessionRuntimeDiagnostic`, `ResourceDiagnostic`. -These remain present; only the auth/model-registry construction path is broken. - ---- - -## 3. What Pi 0.80.8 actually changed (verified against real tarballs) - -Method: downloaded and extracted the real npm tarballs for -`@earendil-works/pi-coding-agent` 0.80.7, 0.80.8, 0.80.10 and -`@earendil-works/pi-ai` 0.80.7, 0.80.8 (into `/srv/dev/pi-inspect`) and diffed -the `.d.ts` surface. (Local `node_modules` was not installed in this worktree; -the last globally installed copy elsewhere is 0.80.6.) - -### 3.1 Public export diff — `pi-coding-agent` index.d.ts (0.80.7 → 0.80.8) - -Removed: -``` -export { type ApiKeyCredential, type AuthCredential, type AuthStatus, - AuthStorage, type AuthStorageBackend, FileAuthStorageBackend, - InMemoryAuthStorageBackend, type OAuthCredential } from "./core/auth-storage.ts"; -``` -Added: -``` -export { readStoredCredential } from "./core/auth-storage.ts"; -export { type CreateModelRuntimeOptions, ModelRuntime, - type ModelRuntimeAuthOverrides } from "./core/model-runtime.ts"; -``` -`ModelRegistry` is still exported, but its class shape changed (see §3.3). -0.80.10 (current `latest`) is **byte-identical** to 0.80.8 for `index.d.ts`, -`auth-storage.d.ts`, and `model-runtime.d.ts` — the new surface is stable. - -### 3.2 Upstream CHANGELOG (0.80.8) — Breaking Changes (verbatim highlights) - -- "Replaced the SDK's `CreateAgentSessionOptions.authStorage` and - `modelRegistry` options with the async `modelRuntime` option. `AuthStorage` - and its storage backends are no longer exported; use `ModelRuntime` (or a - custom pi-ai `CredentialStore`), or `readStoredCredential()` for one-off - reads of auth.json." -- "Replaced SDK request-auth assembly through - `ModelRegistry.getApiKeyAndHeaders()` with `ModelRuntime.getAuth()`." -- "Changed extension-facing `ModelRegistry.refresh()` from synchronous `void` - to `Promise` because `models.json` loading is asynchronous. Extensions - must await it before making synchronous registry reads." -- "Moved canonical dynamic catalog refresh to async `ModelRuntime.refresh()`." - -### 3.3 The new API shape - -**`ModelRuntime`** (`core/model-runtime.d.ts`, new) — the canonical async facade: -- `static create(options?: CreateModelRuntimeOptions): Promise` - where options include `credentials?: CredentialStore`, `authPath?`, - `modelsPath?`, `modelsStore?`, `allowModelNetwork?`, etc. -- Provider/model reads: `getProviders()`, `getProvider(id)`, `getModels()`, - `getModel()`, `getAvailable()` (async) / `getAvailableSnapshot()` (sync). -- Auth: `getAuth(providerId|model, overrides?)`, `checkAuth(providerId)`, - `hasConfiguredAuth(providerId)`, `isUsingOAuth(providerId)`, - `getProviderAuthStatus(providerId)`, `listCredentials()`, - `setRuntimeApiKey`, `removeRuntimeApiKey`. -- Login/logout: `login(providerId, type, interaction): Promise`, - `logout(providerId): Promise`. -- `refresh(): Promise<...>`, `registerProvider`/`unregisterProvider`. -- Implements pi-ai `Models`. - -**`ModelRegistry`** (`core/model-registry.d.ts`, changed) — now a thin sync -compatibility facade **for extensions**, constructed from a `ModelRuntime`: -- `constructor(runtime: ModelRuntime)` — **no more `ModelRegistry.create(authStorage, ...)` - and no more `ModelRegistry.inMemory(...)`**. -- **No `authStorage` property.** (This breaks `authProviderOptions.ts`, - `authService.ts`, and `anthropicSubscriptionWarning`.) -- `refresh(): Promise` (was sync `void`). -- Keeps `getAll`, `getAvailable`, `find`, `getProviderAuthStatus`, - `getProviderDisplayName`, `getApiKeyForProvider`, `isUsingOAuth`, - `hasConfiguredAuth`, `getApiKeyAndHeaders`, `registerProvider`, etc. -- **Dropped:** the whole `authStorage`-centric credential API - (`get/set/list/logout/reload/getOAuthProviders`). - -**`AuthStorage`** (`core/auth-storage.d.ts`, still exists internally, NOT -exported): now `implements CredentialStore` with an entirely different, -**async** method set — `read()`, `modify()`, `delete()`, `list()` returning -`Promise`s of pi-ai `Credential`/`CredentialInfo`. The old -`get/set/remove/has/login/logout/getApiKey/getOAuthProviders/setRuntimeApiKey` -synchronous methods are gone. `static create/inMemory/fromStorage` remain but -the class is unexported. - -**`readStoredCredential(providerId, authPath?)`** — new synchronous one-off read -returning a pi-ai `Credential | undefined` (`{ type: "api_key", key?, env? }` or -`{ type: "oauth", ... }`). Useful for `anthropicSubscriptionWarning`. - -**pi-ai 0.80.8 auth model** (`@earendil-works/pi-ai`, `auth/types.d.ts`, -`auth/credential-store.d.ts`): -- `CredentialStore` interface: `read`, `list`, `modify`, `delete` — all async. -- `Credential = ApiKeyCredential | OAuthCredential`; `CredentialInfo`. -- `InMemoryCredentialStore` class exported — the test seam that replaces - `AuthStorage.inMemory(...)`. -- `AuthInteraction` interface replaces the old `OAuthLoginCallbacks`: - `{ signal?, prompt(prompt: AuthPrompt): Promise, notify(event: AuthEvent): void }`. - `AuthPrompt` is a discriminated union (`text`/`secret`/`select`/`manual_code`); - `AuthEvent` is `info`/`auth_url`/`device_code`/`progress`. This is a **complete - reshaping** of the OAuth login callback contract used by - `oauthLoginFlowService.ts`. -- `login(providerId, type, interaction)` now lives on `ModelRuntime`, not on a - credential store, and returns a `Credential`. -- `Provider` objects (`getProviders()`) carry `{ id, name, auth: { apiKey?, oauth? } }` - — this is the new source of truth for enumerating login providers, replacing - `authStorage.getOAuthProviders()`. - -### 3.4 Session services wiring change - -`createAgentSessionServices` options and `AgentSessionServices`: -- 0.80.7: `{ cwd, agentDir?, authStorage?, settingsManager?, modelRegistry?, ... }` - → services expose `authStorage` + `modelRegistry`. -- 0.80.8: `{ cwd, agentDir?, settingsManager?, modelRuntime?, ... }` - → services expose `modelRuntime` (no `authStorage`, no `modelRegistry`). - -So `piSessionService.ts`'s `createDefaultRuntimeFactory` must pass `modelRuntime` -instead of `authStorage` + `modelRegistry`. - ---- - -## 4. Backwards-compatibility analysis (0.80.0–0.80.7 vs 0.80.8+) - -A shim would need to bridge, simultaneously: - -1. **Construction:** `ModelRegistry.create(authStorage, modelsPath)` / - `ModelRegistry.inMemory(authStorage)` (old) vs - `await ModelRuntime.create({ credentials, authPath, modelsPath })` then - `new ModelRegistry(runtime)` (new). Old is sync; new is async. This alone - forces `AuthService` / `PiSessionService` construction to become async or to - pre-resolve a runtime, changing call sites either way. -2. **Credential access:** synchronous `authStorage.get/set/list/logout/reload/ - getOAuthProviders` (old) vs async `CredentialStore.read/modify/delete/list` - + `ModelRuntime.getProviders()/login/logout/getProviderAuthStatus` (new). - Sync→async cannot be shimmed transparently. -3. **OAuth login:** `authStorage.login(providerId, OAuthLoginCallbacks)` (old, - rich callback object) vs `modelRuntime.login(providerId, type, - AuthInteraction)` (new, `prompt`/`notify` contract). The - `oauthLoginFlowService` maps SDK callbacks onto web-UI flow state; the two - callback contracts are structurally different and would each need a distinct - adapter. -4. **`refresh()`** sync vs async. -5. **Provider enumeration** (`authProviderOptions.ts`) built on - `authStorage.getOAuthProviders()/list()/get()` — none of which exist in the - new surface; must be rederived from `getProviders()` + `listCredentials()`. - -A dual shim would therefore reimplement two full auth stacks behind a lowest- -common-denominator async interface, plus runtime detection of which export -exists — high complexity, high risk, and permanently carrying dead code for the -already-broken 0.80.0–0.80.7 line. This fails the "easy/clean" bar in the task. - -**Conclusion:** backwards compatibility with 0.80.0–0.80.7 is **not easy** and -not worth it. Pi Web should target the new (0.80.8+) API and drop support for -0.80.0–0.80.7. - ---- - -## 5. Recommendation - -**Clean migration to the `ModelRuntime` API + range correction + version bump.** - -Rationale: -- 0.80.8 is an explicit upstream breaking change; the export removal is - intentional and permanent (confirmed identical in 0.80.10 `latest`). -- The old 0.80.0–0.80.7 surface and the new 0.80.8+ surface differ across - construction, sync/async, credential access, OAuth login, and session - services — there is no small adapter that spans both cleanly. -- Pinning down to a still-working old version is a dead end: users installing - Pi Web get whatever Pi they have, and `latest` is already 0.80.10. - -### Concrete migration shape (to be executed by the relay, not now) - -1. **`authService.ts`**: hold a `ModelRuntime` (created via - `ModelRuntime.create({ authPath, modelsPath })`), optionally expose a - `ModelRegistry` wrapper for extension-facing reads. Replace credential - operations: - - `saveApiKey` → `runtime` credential `modify(providerId, async () => ({ type:"api_key", key }))` - (via the runtime's credential store / `setRuntimeApiKey` is for ephemeral; - persistence uses the `CredentialStore.modify` path). - - `logoutProvider` → `runtime.logout(providerId)`. - - `startOAuthLogin` → `runtime.login(providerId, "oauth", interaction)`. - - refresh → `await runtime.refresh()`. - - Construction becomes async (factory function returning a Promise, or an - `init()` step) — propagate to `sessiond.ts`. -2. **`authProviderOptions.ts`**: rederive login/logout options from - `runtime.getProviders()` (auth.apiKey / auth.oauth presence + names) and - `runtime.listCredentials()` / `getProviderAuthStatus()`. Update the - structural `AuthProviderModelRegistry`/`AuthProviderRuntime` interface and - its test double. -3. **`oauthLoginFlowService.ts`**: reimplement against `AuthInteraction` - (`prompt(AuthPrompt)` + `notify(AuthEvent)`) instead of `OAuthLoginCallbacks`. - Map `AuthPrompt` kinds (`text`/`secret`/`manual_code`/`select`) to the web - UI prompt/select shapes, and `AuthEvent` (`auth_url`/`device_code`/`progress`) - to the existing flow-state fields. This is the largest single slice. -4. **`piSessionService.ts`**: - - `createDefaultRuntimeFactory` passes `modelRuntime` to - `createAgentSessionServices` instead of `authStorage` + `modelRegistry`. - - `PiAgentSession` internal type: carry `modelRuntime` (or an adapted - registry) instead of the old `modelRegistry.authStorage`. - - `anthropicSubscriptionWarning`: replace - `modelRegistry.authStorage.get("anthropic")` with - `readStoredCredential("anthropic", authPath)` (sync, no `authStorage` - needed) — cleanest fit for this synchronous check. -5. **`sessiond.ts`**: adapt to async auth construction (create the runtime, - `await` init, then pass into `PiSessionService`). **This is session-daemon - code → requires a manual `pi-web-web-sessiond.service` restart after the fix - lands.** -6. **Tests / testSupport**: replace `AuthStorage.inMemory(...)` with pi-ai - `InMemoryCredentialStore` (+ `await ModelRuntime.create({ credentials })`), - and `ModelRegistry.create/inMemory(...)` accordingly. Update - `authService.test.ts`, `piSessionService.testSupport.ts`, - `piSessionService.promptQueue.test.ts`, `piSessionService.warnings.test.ts`, - `oauthLoginFlowService.test.ts`, `authProviderOptions.test.ts`. Follow the - testing-guide skill (esp. async construction, no over-mocking of SDK). - -### Dependency range correction (§6) - -- Change the three `@earendil-works/*` **peerDependencies** from - `>=0.80.0 <1` to a range that excludes the unsupported line, e.g. - `>=0.80.8 <0.81` (matching the current published minor). Keep the three - `devDependencies` on a matching `^0.80.8` (or exact `0.80.8`/`0.80.10`). -- `@earendil-works/pi-agent-core` and `@earendil-works/pi-ai` are siblings - released in lockstep with `pi-coding-agent` (coding-agent depends on - `^0.80.x` of both); correct all three ranges together. -- Rationale for the upper bound `<0.81`: the auth refactor shows this line makes - breaking changes within `0.80.x` patch releases, so a permissive `<1` is - unsafe. Pin to the known-good minor window and widen deliberately after - testing new releases. - -### Release / changeset - -- Add a **patch** (or minor, maintainer's call) `.changeset/*.md` for - `@jmfederico/pi-web` describing the user-visible fix: "Fix session daemon - crash with Pi 0.80.8+ by migrating to the new `ModelRuntime` API; require Pi - `>=0.80.8`." Do **not** edit `CHANGELOG.md` directly (Changesets generates it). -- Actual npm publish is out of scope for the fix branch; the release skill - (`npm-release-via-github-actions`) is only referenced so the changeset is - release-ready. - ---- - -## 6. Dependency range facts (current state) - -`package.json`: -``` -devDependencies: - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-coding-agent": "^0.80.6", -peerDependencies: - "@earendil-works/pi-agent-core": ">=0.80.0 <1", - "@earendil-works/pi-ai": ">=0.80.0 <1", - "@earendil-works/pi-coding-agent": ">=0.80.0 <1", -``` -No `dependencies`/`optionalDependencies` entries for these packages. The -permissive peer range `>=0.80.0 <1` is what lets consumers' npm resolve the -breaking 0.80.8/0.80.9/0.80.10 against a Pi Web build that expects the old -export. - -Published versions (npm): 0.79.10, 0.80.1, 0.80.2, 0.80.3, 0.80.5, 0.80.6, -0.80.7, 0.80.8, 0.80.9, 0.80.10. `latest` = 0.80.10. The removal landed in -0.80.8 and persists through 0.80.10. - ---- - -## 7. Verification artifacts - -- Extracted SDK tarballs for inspection: `/srv/dev/pi-inspect/` (v0.80.7, - v0.80.8, v0.80.10 of pi-coding-agent; pi-ai0807, pi-ai0808). These are - scratch/inspection only and outside the repo. -- Key diffs reproduced in §3.1 (index exports), §3.3 (class shapes), §3.4 - (session services). 0.80.8 vs 0.80.10 `.d.ts` are identical for the affected - files → the target API is stable. - -## 8. Risks / call-outs for the fix - -- **Session daemon restart required:** changes touch `sessiond.ts` and the - session runtime path; a manual restart of the sessiond service is needed after - the fix (per AGENTS.md). -- **Async construction ripple:** moving from sync `AuthStorage/ModelRegistry` - construction to `await ModelRuntime.create(...)` changes `AuthService` / - `PiSessionService` init and their call sites; keep the async boundary - explicit and injected (code-quality-architecture skill). -- **OAuth flow contract change is the riskiest slice** — the web UI prompt/ - select/device-code mapping must be re-verified end to end. -- **No local `node_modules`** in this worktree; the relay's first implementation - leg must `npm install` (pin to 0.80.8+) before it can typecheck/test. Note the - `/tmp` quota issue observed during assessment — install in the worktree, not - `/tmp`. diff --git a/README.md b/README.md index 0642d05..ea35cf1 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Your browser is the control surface. The work stays where it can keep running. Requirements: -- Node.js 22 or newer +- Node.js 22.19.0 or newer - npm - Pi Coding Agent configured for your user - git and the development tools your agents need diff --git a/docs/faq.html b/docs/faq.html index b0f0cfa..4030bf0 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -157,7 +157,7 @@

What does pi-web doctor check?

- It keeps two kinds of checks separate. General login-shell readiness covers Node 22+, npm, Pi, and optional + It keeps two kinds of checks separate. General login-shell readiness covers Node 22.19.0 or newer, npm, Pi, and optional ripgrep. Native-service diagnostics validate only the exact prerequisites of the selected service plan in the real systemd user-manager or launchd gui/<uid> context. Development installs follow their installed checkout plan; production checks are clearly labelled prospective when the installed executable @@ -181,7 +181,7 @@

  • Prefer version-manager shims when available; for mise, use shims or enable its shim setup rather than relying only on shell activation.
  • Move any required version-manager initialization to your login shell file.
  • -
  • Make sure node --version is at least v22 from bash -lc, zsh -lc, or your detected shell.
  • +
  • Make sure node --version is at least v22.19.0 from bash -lc, zsh -lc, or your detected shell.
  • Run pi-web doctor again after changing shell files.
diff --git a/docs/index.html b/docs/index.html index 56afff4..8ab248b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -38,7 +38,7 @@ "downloadUrl": "https://www.npmjs.com/package/@jmfederico/pi-web", "codeRepository": "https://github.com/jmfederico/pi-web", "description": "PI WEB is a web UI for Pi Coding Agent that keeps persistent agent sessions running in real workspaces on your machine or server.", - "softwareRequirements": "Node.js 22 or newer and Pi Coding Agent", + "softwareRequirements": "Node.js 22.19.0 or newer and Pi Coding Agent", "license": "https://github.com/jmfederico/pi-web/blob/main/LICENSE" } @@ -138,7 +138,7 @@ # laptop, phone, tablet — same live sessions $ pi-web doctor -✓ caller login shell can find node >= 22 +✓ caller login shell can find node >= 22.19.0 ✓ native-service plan requirements pass in manager context ✓ ready for persistent agent work diff --git a/docs/install.html b/docs/install.html index 4a37f49..33423c2 100644 --- a/docs/install.html +++ b/docs/install.html @@ -106,7 +106,7 @@

Requirements

    -
  • Node.js 22 or newer and npm.
  • +
  • Node.js 22.19.0 or newer and npm.
  • Pi Coding Agent installed/configured so the pi command works for your user.
  • A shell login environment that exposes Node, npm, Pi, git, and any tools your agents need.
  • For the automatic installer: a supported per-user service manager.
  • diff --git a/package-lock.json b/package-lock.json index edd9889..62f07d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "vitest": "^4.1.10" }, "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "peerDependencies": { "@earendil-works/pi-agent-core": ">=0.80.8 <0.81", diff --git a/package.json b/package.json index d06b393..e20ff17 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "access": "public" }, "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "repository": { "type": "git", diff --git a/relays/issue-62-authstorage/charter.md b/relays/issue-62-authstorage/charter.md deleted file mode 100644 index 961a1f1..0000000 --- a/relays/issue-62-authstorage/charter.md +++ /dev/null @@ -1,127 +0,0 @@ -# Relay charter — issue-62-authstorage - -## Relay identity -- **Name:** `issue-62-authstorage` -- **Root path:** `relays/issue-62-authstorage/` (in the repo, on branch - `fix/issue-62-authstorage`, worktree `/srv/dev/pi-web-issue-62`) -- **Packet files:** `charter.md`, `status.md`, `log.md` (this directory). - -## Background (read once, do not re-derive) -The full technical assessment lives at the worktree root: -`ASSESSMENT-issue-62.md`. Read it once at the start of your leg for the API -migration details; do not re-investigate the SDK from scratch. Short version: -Pi `@earendil-works/pi-coding-agent` 0.80.8 removed the `AuthStorage` export and -replaced the auth/model plumbing with an async `ModelRuntime` + pi-ai -`CredentialStore` model. Pi Web imports `AuthStorage` statically and crashes at -module load with any Pi 0.80.8+. The agreed fix is a **clean migration to the -new `ModelRuntime` API** (no dual-version shim) plus dependency-range -correction, tests, and a changeset. Rationale and the per-file migration shape -are in `ASSESSMENT-issue-62.md` §5. - -## Goal / finish line -Pi Web builds, typechecks, lints, and passes its full test suite against Pi -`@earendil-works/pi-coding-agent` **0.80.8+** (target the installed 0.80.8/0.80.10 -line), with: -1. No remaining import or use of the removed `AuthStorage` export (and no - reliance on `ModelRegistry.create(authStorage)` / `.inMemory(authStorage)` / - `modelRegistry.authStorage`). -2. Auth, OAuth login, API-key save/logout, provider enumeration, and the - Anthropic subscription warning all working through the new `ModelRuntime` / - `readStoredCredential` / pi-ai `CredentialStore` APIs. -3. `package.json` peerDependencies and devDependencies for the three - `@earendil-works/*` packages corrected so npm cannot resolve an unsupported - release (target range `>=0.80.8 <0.81`; devDeps on a matching `^0.80.8`). -4. A `.changeset/*.md` fragment describing the user-visible fix (no direct - `CHANGELOG.md` edit). -5. `npm run verify` (typecheck + lint + knip + test) passing. - -Finish line reached = all of the above true and committed on the branch. **Do -not open a PR** (out of scope for this relay). - -## Sizing — one leg -One leg = **one coherent slice** from the plan below that leaves the tree in a -committed, describable state. Prefer the pre-broken-out slices in `status.md`. -A leg does not have to leave `npm run verify` fully green (the migration is -interdependent), but it MUST: -- leave a clear, honest `status.md` describing what compiles/what doesn't yet, -- commit its work with a clear message, -- not expand scope beyond its slice ("just a bit more" is the main failure mode - here — the auth surfaces are interconnected; resist rewriting everything in - one leg). - -If a slice turns out bigger than expected, split it and hand off mid-plan with -an updated `status.md` — that is expected and fine. - -## Suggested slice breakdown (task selection default) -Follow `status.md`'s named next task. If none is named, pick the lowest-numbered -incomplete slice here: - -0. **Bootstrap:** `npm install` in the worktree pinning the three `@earendil-works/*` - packages to 0.80.8+ (e.g. `npm i -D @earendil-works/pi-coding-agent@0.80.8 - @earendil-works/pi-ai@0.80.8 @earendil-works/pi-agent-core@0.80.8`), and - correct the peerDependencies range to `>=0.80.8 <0.81`. Confirm the crash - reproduces / the new exports resolve. Commit. (Install in the worktree, NOT - `/tmp` — `/tmp` has a disk quota problem, see assessment §8.) -1. **`authService.ts` core migration:** move to `ModelRuntime` (async - construction via `ModelRuntime.create({ authPath, modelsPath })`), migrate - saveApiKey/logout/refresh/credential access. Propagate async construction to - `sessiond.ts`. (Session-daemon path — see restart note.) -2. **`authProviderOptions.ts` migration:** rederive login/logout provider - options from `runtime.getProviders()` + `listCredentials()` / - `getProviderAuthStatus()`; update its structural interface + test double. -3. **`oauthLoginFlowService.ts` migration:** reimplement against pi-ai - `AuthInteraction` (`prompt`/`notify`) instead of `OAuthLoginCallbacks`; wire - `runtime.login(providerId, "oauth", interaction)`. (Riskiest slice — verify - prompt/select/device-code/auth_url mapping.) -4. **`piSessionService.ts` migration:** pass `modelRuntime` to - `createAgentSessionServices`; update `PiAgentSession` type; switch - `anthropicSubscriptionWarning` to `readStoredCredential`. -5. **Tests + testSupport:** migrate all test doubles to `InMemoryCredentialStore` - + `ModelRuntime.create`; get `npm run verify` green. Follow the testing-guide - skill. -6. **Changeset + final verify + cleanup:** add `.changeset/*.md`; run full - `npm run verify`; remove scratch (`ASSESSMENT` stays, `/srv/dev/pi-inspect` - is outside the repo). Confirm goal, hand off to a final confirmation/stop. - -Slices may merge or split. 1–4 depend on 0. Slice 5 finalizes; slice 6 closes. - -## Handover -When handing off, `spawn_session` **once** with a prompt whose first line is: -`Relay "issue-62-authstorage" leg begins now.` followed by the standard -Relay handoff body pointing at: -- `relays/issue-62-authstorage/charter.md` -- `relays/issue-62-authstorage/status.md` - -Tell the next runner not to read `log.md` end-to-end. Make all work durable -(update `status.md`, append `log.md`, commit) **before** spawning. - -## Intervention signal — stop and get the human when: -- The new SDK API does not actually provide an operation the migration needs - (e.g. no viable credential persistence path for API-key save), i.e. the - assessment's assumed mapping is wrong. -- A slice would require changing the charter's goal or the agreed "clean - migration, no shim" decision. -- `npm install` / registry access fails and cannot be resolved in-leg. -- Charter churn: if you find yourself needing to edit this charter to proceed, - stop and involve the human instead. -To raise it: set a clear `## BLOCKED` section at the top of `status.md`, append a -`log.md` entry explaining the blocker and what decision is needed, do **not** -spawn the next leg, and end your run. - -## Reading discipline -Read, in order: this `charter.md`, then `status.md`, then `ASSESSMENT-issue-62.md` -(once), then only the specific `src/server/sessions/*` files your slice touches. -Do **not** read `log.md` end-to-end — only targeted entries if `status.md` points -you there. Do not re-extract SDK tarballs unless the assessment is contradicted -by reality. - -## Standing constraints (project conventions) -- Session-daemon changes (`sessiond.ts`, session runtime / auth construction - loaded by the daemon) require the human to **manually restart the sessiond - service** to take effect. Call this out in `status.md`/handoff whenever a leg - changes that path so the human knows a restart is pending. -- Follow the skills: `code-quality-architecture` (DI, async boundaries, - testable seams), `testing-guide` (test layers, no over-mocking), and - `changeset-changelog` (changeset not CHANGELOG edit). -- Keep changes scoped to the fix; do not opportunistically refactor unrelated - code. diff --git a/relays/issue-62-authstorage/log.md b/relays/issue-62-authstorage/log.md deleted file mode 100644 index 5092fff..0000000 --- a/relays/issue-62-authstorage/log.md +++ /dev/null @@ -1,397 +0,0 @@ -# Relay log — issue-62-authstorage - -Append-only. One concise entry per leg. Do not read end-to-end for orientation; -use `status.md`. Targeted lookups only. - ---- - -## Leg 0 — Planning (assessment + relay packet) - -**Did:** -- Read issue #62 and confirmed the crash: static `import { AuthStorage }` fails - at ESM load with Pi 0.80.8+. -- Investigated all `AuthStorage`/`ModelRegistry` usage in `src/` (3 production - files + 5 test/support files under `src/server/sessions/`; other - pi-coding-agent imports unaffected). -- Downloaded and diffed real npm tarballs (pi-coding-agent 0.80.7/0.80.8/0.80.10 - and pi-ai 0.80.7/0.80.8) into `/srv/dev/pi-inspect` (scratch, outside repo) to - establish the exact new export surface: `AuthStorage` and its backends removed - from exports; new `ModelRuntime` (async) + `readStoredCredential`; changed - `ModelRegistry` (constructed from a runtime, `refresh()` now async, no - `authStorage`); pi-ai `CredentialStore`/`InMemoryCredentialStore`/ - `AuthInteraction` model. Confirmed 0.80.8 and 0.80.10 `.d.ts` are identical - for the affected files (stable target). -- Wrote `ASSESSMENT-issue-62.md` (root). - -**Decisions:** -- **Clean migration to `ModelRuntime`, no dual-version compat shim.** Rationale: - sync→async, credential-store contract change, OAuth callback contract change, - and session-services option change span both surfaces with no small clean - adapter; 0.80.0–0.80.7 is already broken/superseded (`latest` = 0.80.10). -- **Dep range fix:** peerDeps `>=0.80.0 <1` → `>=0.80.8 <0.81` for all three - `@earendil-works/*` packages; upper bound `<0.81` because this line ships - breaking changes within `0.80.x`. -- Relay packet placed under `relays/issue-62-authstorage/` (committed; not in - `package.json` `files`, so not published; `.pi-web/` is gitignored so not used). - -**Artifacts changed:** `ASSESSMENT-issue-62.md`; -`relays/issue-62-authstorage/{charter,status,log}.md`. - -**Status update:** last completed leg 0, next leg 1 = charter slice 0 -(Bootstrap: install Pi 0.80.8+, correct dep ranges). - -**Blockers:** none. Noted `/tmp` disk-quota issue (install in worktree) and the -pending sessiond restart for later daemon-path slices. - -**Handoff:** Planning only — NOT auto-spawning the first implementation leg. -Assessment + relay plan are laid out ready to be kicked off by the user. - ---- - -## Leg 1 — Slice 0 Bootstrap (deps + range correction) - -**Did:** -- Corrected `package.json`: three `@earendil-works/*` devDependencies - `^0.80.6` → `^0.80.8`; peerDependencies `>=0.80.0 <1` → `>=0.80.8 <0.81`. -- `npm install` in the worktree. The default `/tmp`-based node-gyp build of - `node-pty` failed with "Disk quota exceeded" (/tmp is a 5.8G tmpfs at ~81%). - Re-ran with `TMPDIR="$PWD/.tmp-build" npm install`, which succeeded (618 - packages, 0 vulnerabilities). Removed `.tmp-build` after. -- Confirmed installed versions: all three packages resolve to **0.80.10** - (lockfile updated). -- Verified the new export surface via a node ESM check: `ModelRuntime` ✓, - `readStoredCredential` ✓, `ModelRegistry` ✓, `AuthStorage` absent ✓, pi-ai - `InMemoryCredentialStore` ✓. -- Ran `npx tsc --noEmit`: 24 errors, all in `src/server/sessions/` at the - expected migration sites (removed `AuthStorage`, `ModelRegistry.create/inMemory`, - `authStorage` on session-services options, `PiAgentSession.modelRegistry`). - This matches assessment §2/§3 — the new surface resolves; only Pi Web's old - usage is broken. - -**Decisions:** -- Committed with `git commit --no-verify`: the pre-commit hook runs a - whole-project typecheck which cannot pass until the migration lands. Charter - explicitly permits non-verify-green legs. Documented this + the `TMPDIR` - workaround in `status.md` so future legs don't rediscover them. -- Did not touch any `src/` migration code — kept strictly to slice 0 scope. - -**Artifacts changed:** `package.json`, `package-lock.json` (commit `0fa9d0e`); -`relays/issue-62-authstorage/{status,log}.md`. `node_modules/` installed -(gitignored, not committed). - -**Status update:** last completed leg 1, next leg 2 = charter slice 1 -(`authService.ts` core migration → propagate async construction to `sessiond.ts`). - -**Blockers:** none. Sessiond restart still pending (becomes relevant from leg 2 -onward once `sessiond.ts` changes land). - -**Handoff:** spawning leg 2 (slice 1). - - ---- - -## Leg 2 — slice 1: `authService.ts` core migration (commit `e37148c`) - -**What I did:** migrated `src/server/sessions/authService.ts` off the removed -`AuthStorage` / `ModelRegistry.create` surface onto the async `ModelRuntime` -API, and propagated the async construction into the session daemon. - -- `authService.ts`: - - Imports `ModelRuntime` from `@earendil-works/pi-coding-agent` and - `AuthInteraction` (type) from `@earendil-works/pi-ai`. Dropped - `AuthStorage` / `ModelRegistry`. - - `createModelRegistryForAgentDir` → `createModelRuntimeForAgentDir(agentDir)` - returning `ModelRuntime.create({ authPath: /auth.json, modelsPath: - /models.json })`. - - Construction is now async: private constructor + static - `AuthService.create({ agentDir? | runtime? | authFlows? })`. `runtime` dep - replaces the old `modelRegistry` dep; no-agentDir fallback is - `ModelRuntime.create({})`. - - Public field `readonly runtime: ModelRuntime` replaces `modelRegistry`. - - `saveApiKey` → `runtime.login(providerId, "api_key", interaction)` where - `interaction` is a non-interactive `AuthInteraction` (`prompt: async () => - key`, `notify: () => {}`). Verified against pi-ai `envApiKeyAuth().login`, - which calls `interaction.prompt({ type: "secret" })` and persists the - returned `{ type:"api_key", key }` through `credentials.modify` inside - `Models.login`. This is the credential-persistence path the assessment - (§5.1) called for. - - `logoutProvider` → `await runtime.logout(providerId)`. - - `refreshAuthState` → `await runtime.refresh()` (no more `authStorage.reload()` - — the file store is re-read by the runtime). Now async. - - `authProviders` and `requireOAuthLoginProvider` became async, awaiting - `runtime.refresh()` and the now-async `getLogin/LogoutProviderOptions`. - - `startOAuthLogin` passes `runtime: this.runtime` into - `OAuthLoginFlowService.start` (slice 3 will consume it via `runtime.login`). -- `sessiond.ts`: `createRuntime()` is now `async`; `new AuthService(...)` → - `await AuthService.create({ agentDir })`; `PiSessionService` now receives - `modelRuntime: auth.runtime` instead of `modelRegistry: auth.modelRegistry`. -- `sessiond/sessionDaemonStartup.ts`: `createRuntime` may now return - `Runtime | Promise` and `runSessionDaemonStartup` `await`s it. The - existing sync test doubles still satisfy the widened type. - -**Decisions:** -- **saveApiKey via `runtime.login("api_key", …)`** rather than reaching for a - raw `CredentialStore.modify`: the pi-ai `CredentialStore` is not exposed off - `ModelRuntime` publicly, and the provider's own api-key `login` is the - intended persistence entry point (it writes through `credentials.modify`). - Feeding the key back through a non-interactive `AuthInteraction.prompt` keeps - us on the supported public surface. This matches assessment §5.1's - "credential persistence via the pi-ai CredentialStore.modify path" without - depending on unexported internals. -- Kept `AuthService` construction async via a static factory (private ctor) - rather than an `init()` method — cleaner async boundary, single valid - construction path (code-quality-architecture skill). -- Did NOT touch `authProviderOptions.ts`, `oauthLoginFlowService.ts`, - `piSessionService.ts`, or any test/support files — strictly slice 1 scope. - The async call sites I introduced (`await getLoginProviderOptions(...)`, - `runtime:` in `authFlows.start`, `modelRuntime:` in PiSessionService deps) - deliberately point at the interfaces slices 2–4 will expose. - -**Typecheck state:** `npx tsc --noEmit` = 31 errors (was 24 at slice 0). The -increase is expected and honest: the migrated authService now calls -runtime-based interfaces that slices 2/3/4 have not migrated yet. All remaining -`authService.ts` / `sessiond.ts` errors are cross-slice (authProviderOptions -shape → slice 2; OAuthLoginFlowService.start `runtime` param → slice 3; -`PiSessionServiceDependencies.modelRuntime` → slice 4). Test/support files -(slice 5) still import the removed `AuthStorage`. - -**Artifacts changed:** `src/server/sessions/authService.ts`, -`src/server/sessiond.ts`, `src/server/sessiond/sessionDaemonStartup.ts` -(commit `e37148c`); `relays/issue-62-authstorage/{status,log}.md`. - -**Status update:** last completed leg 2, next leg 3 = charter slice 2 -(`authProviderOptions.ts` migration). - -**Blockers:** none. **Sessiond restart now ACTIVE-pending** — slice 1 changed -`sessiond.ts` and the daemon auth construction path; the human must manually -restart the sessiond service once the migration lands (noted in status.md). -Also: human confirmed `/tmp` is usable again, so the `TMPDIR` install -workaround is no longer required (status.md updated). - -**Handoff:** spawning leg 3 (slice 2). - -## Leg 3 — slice 2: authProviderOptions.ts migration (commit d09d7cc) - -**Did:** Migrated `src/server/sessions/authProviderOptions.ts` off the removed -`authStorage`-centric surface onto the new `ModelRuntime` API. -- Replaced the `AuthProviderModelRegistry` structural interface (which required - `authStorage.getOAuthProviders()/list()/get()`, `getAll()`, - `getProviderDisplayName()`) with a runtime-shaped `AuthProviderRuntime` - interface exposing `getProviders()` (`{ id, name, auth: { apiKey?, oauth? } }`), - `listCredentials()` (`Promise<{ providerId, type }[]>`), and - `getProviderAuthStatus(id)`. Kept it structural (not `Pick`) - so the test can supply a lightweight double; verified the real `ModelRuntime` - satisfies it (call sites in `authService.ts` typecheck clean). -- Made `getLoginProviderOptions` / `getLogoutProviderOptions` `async` to match - the `await` call sites already present in `authService.ts` (leg 2). -- Login options: OAuth options from providers with `auth.oauth`; api-key options - from providers with `auth.apiKey` filtered through the unchanged - `OAUTH_ONLY_PROVIDERS` / `isApiKeyLoginProvider` logic. Display names now come - from `Provider.name` (replacing `getProviderDisplayName`). Logout options - derived from `listCredentials()`, mapping provider id -> name via - `getProviders()`. -- Rewrote the `authProviderOptions.test.ts` double to the runtime shape (a - `getProviders` array with per-provider `auth`, a `listCredentials` promise, - `getProviderAuthStatus`); made the two option-building tests async. All 3 - tests pass. - -**Decisions:** `AuthProviderInfo.auth` typed as `{ apiKey?: unknown; oauth?: -unknown }` — presence is all this module needs, and `unknown` keeps the double -trivial while remaining assignable-from the real `ProviderAuth`. Structural -interface (not `Pick`) chosen for testability per -code-quality-architecture (injectable seam, no SDK construction in unit test). - -**Verify state:** `npx tsc --noEmit` 31 -> 28 errors. No `authProviderOptions` -errors; `getLogin/LogoutProviderOptions` call sites in `authService.ts` clean. -Remaining 28 are cross-slice: `authService.ts` line-83 `OAuthLoginFlowService. -start` still expects `authStorage` (slice 3); `sessiond.ts`(1)+`piSessionService. -ts`(6) slice 4; test/support files slice 5. - -**Artifacts:** `src/server/sessions/authProviderOptions.ts`, -`src/server/sessions/authProviderOptions.test.ts`; status.md updated; committed -`d09d7cc` with `--no-verify` (migration not yet verify-green, permitted). - -**Handoff:** spawning leg 4 (slice 3, oauthLoginFlowService.ts). Sessiond -restart from leg 2 still pending — carried forward, not cleared. - -## Leg 4 — slice 3: oauthLoginFlowService.ts migration (commit `1c3d6db`) - -**What:** Reimplemented `OAuthLoginFlowService` against the pi-ai -`AuthInteraction` contract and rewrote its test. - -- `start()` now takes `runtime: Pick` instead of - `authStorage: Pick`; login driven via - `runtime.login(providerId, "oauth", interaction)`. Resolves the - `authService.ts` line-83 tsc error (authService.ts now at 0 errors). -- Built a single `AuthInteraction` adapter (`{ signal, prompt, notify }`) - replacing the six `OAuthLoginCallbacks` (`onAuth`/`onDeviceCode`/`onPrompt`/ - `onManualCodeInput`/`onSelect`/`onProgress`). -- **Mapping decisions (verified carefully — riskiest slice):** - - `prompt(AuthPrompt)` dispatches on `type`: `select` → `waitForSelect` - (options `{id,label,description?}` → CommandOption `{value:id,label}`, - resolves chosen id); `manual_code` → web-UI prompt kind `manual`; - `text`/`secret` → web-UI prompt kind `prompt`. Old code special-cased - `onManualCodeInput` with a hardcoded message; now the provider supplies the - `manual_code` message, which is more correct. - - `notify(AuthEvent)`: `auth_url` → `auth:{url,instructions?}`; `device_code` - → reuse `auth` field (`url: verificationUri`, instructions - `"Enter code: "`) exactly as the old `onDeviceCode` did; - `info`+`progress` → append `message` to `progress` (old code only had - `onProgress`; `info` folds in naturally). - - Old `OAuthPrompt.allowEmpty`/`placeholder` handling: the new `AuthPrompt` - has no `allowEmpty`, so interactive prompts are always required - (`allowEmpty:false`); `select` keeps `allowEmpty:true`. Placeholder still - forwarded when present. -- **New behavior:** per-prompt `AuthPrompt.signal` now aborts just that pending - request (rejects `"Prompt cancelled"`, clears the interaction from state) - without ending the overall flow — the documented `manual_code`-vs-callback - race. Added `bindPromptSignal` + a dedicated test for it. -- **Test:** rewrote `oauthLoginFlowService.test.ts` with a `fakeRuntime` - `login` double (returns a stub oauth credential). Replaced the old - device-code-via-onDeviceCode coverage with an explicit `notify` device_code - test and a per-prompt-signal-abort test. 9 tests pass; both files lint clean. - -**tsc:** 28 → 26 errors. `authService.ts` = 0. Remaining: slice 4 -(`sessiond.ts` 1, `piSessionService.ts` 6) and slice 5 test/support files -(`authService.test.ts` 10, `.testSupport.ts` 3, `.promptQueue.test.ts` 2, -`.warnings.test.ts` 4). - -**Status:** updated (current position, leg tracking → last leg 4 / next leg 5, -next task = slice 4). Committed with `--no-verify` (migration not yet -verify-green, per charter). - -**Blockers:** none. Sessiond-restart-pending note still ACTIVE (unchanged; -this slice did not touch the daemon path, but slice 1 did). Handing off to -leg 5 (slice 4). - ---- - -## Leg 5 — slice 4: piSessionService.ts migration (commit `4ccd4f8`) - -**What:** Migrated `src/server/sessions/piSessionService.ts` to the new -`ModelRuntime` API. -- `createDefaultRuntimeFactory` now takes a `ModelRuntime` and passes - `modelRuntime` to `createAgentSessionServices({ cwd, agentDir, modelRuntime })` - (dropped the `authStorage` + `modelRegistry` args). -- `PiAgentSession.modelRegistry: ModelRegistryInstance` → `modelRuntime: - ModelRuntime`. Removed the `ModelRegistryInstance` type alias and the - `AuthStorage`/`ModelRegistry` SDK imports; added `type ModelRuntime` + - `readStoredCredential` imports and `join` from node:path. `authService.js` - import reduced to just `AuthChange` (dropped `createModelRegistryForAgentDir`). -- `anthropicSubscriptionWarning(session, authPath?)`: reads via - `readStoredCredential("anthropic", authPath)`; param narrowed to - `Pick`. `warningsForSession` - passes `join(this.agentDir, "auth.json")`. -- Model reads rederived onto the runtime: `availableModels`/`setModel` → - `await modelRuntime.refresh()` + `getAvailableSnapshot()` + `getModel(...)`; - `syncCurrentModelAuthWarning` → `getModel(...)` + - `hasConfiguredAuth(providerId)`. -- `applyAuthChange` no longer refreshes a registry (shared runtime is refreshed - by AuthService before emit; all sessions share it), keeping the subscribe - callback synchronous. - -**Decision:** made `modelRuntime` a **required** `PiSessionServiceDependencies` -field rather than keeping an optional `modelRegistry?`-style fallback. The old -fallback built a registry synchronously in the constructor; `ModelRuntime` can -only be created by the async `ModelRuntime.create`, which cannot run in a -constructor. `sessiond.ts` already injects `modelRuntime: auth.runtime` (slice -1), so production wiring is unaffected. Consequence: the slice-5 test surface is -wider than the four files the assessment listed — every `new -PiSessionService(...)` in tests now needs `modelRuntime`, and `fakeRuntime`/the -`TestSession` type in `testSupport.ts` must expose `modelRuntime`. Documented in -status.md "Next task". - -**Result:** `npx tsc --noEmit` — `sessiond.ts` and `piSessionService.ts` at 0 -errors; all production code migrated (tsc output filtered to non-test/support -files is empty). Remaining errors are slice-5 test/support only: -authService.test (10), testSupport (4), warnings (5), promptQueue (17), -lifecycle (19), archiveCleanup (9), spawnSession (3), spawnSubsession (18), -sessionRoutes (1). `piSessionService.ts` lints clean. Committed `--no-verify` -(migration not yet verify-green, per charter). - -**Blockers:** none. **Sessiond-restart-pending note still ACTIVE** — this slice -added `piSessionService.ts` (a session-daemon path) to the pending-restart -surface; do not clear the note. Handing off to leg 6 (slice 5: tests + -testSupport). - -## Leg 6 — slice 5 (tests + testSupport migration) — commit `d0cc55c` - -**What:** Migrated all test doubles + `piSessionService.testSupport.ts` off the -removed `AuthStorage.inMemory` / `ModelRegistry.create|inMemory` surface to the -pi-ai `InMemoryCredentialStore` + async `ModelRuntime.create({ credentials })`. -`npm run verify` is now **fully green** (typecheck + lint + knip + 1390 tests, -2 skipped) — the migration goal (charter criteria 1, 2, 5) is met; only the -changeset (criterion 4) remains for slice 6. - -**Files changed (all `src/server/sessions/`):** -- `piSessionService.testSupport.ts`: new seams `createTestModelRuntime`, - shared `testModelRuntime` (top-level await), `seedCredential`; `fakeRuntime` - and `testModel` moved onto `modelRuntime`; dropped AuthStorage/ModelRegistry. -- `archiveCleanup`/`lifecycle`/`promptQueue`/`spawnSession`/`spawnSubsession`/ - `sessionRoutes` tests: injected `modelRuntime: testModelRuntime` into every - `new PiSessionService(...)` (now required) + imported the shared runtime. -- `promptQueue.test.ts` auth-loss test: live `InMemoryCredentialStore` + - `createTestModelRuntime(credentials)`, driving changes through - `delete`/`seedCredential` + `refresh()` + `applyAuthChange(...)`. -- `warnings.test.ts`: `anthropicSubscriptionWarning` seam is now a temp - `auth.json` read via `readStoredCredential(id, authPath)`; type narrowed. -- `authService.test.ts`: reworked to async `AuthService.create` + - `InMemoryCredentialStore`; awaits async ops; OAuth-complete uses `vi.waitFor`. - -**Decisions:** -- Used a single shared `testModelRuntime` (top-level `await` in testSupport, an - allowed ESM pattern here) for the no-auth catalog case so the many - `PiSessionService` constructions and `fakeRuntime` sessions inject it - synchronously — avoided making `fakeRuntime` itself async (which would have - rippled through ~90 call sites). Auth-dependent tests build a dedicated - per-test runtime via `createTestModelRuntime(credentials)`. -- `anthropicSubscriptionWarning` seam: chose the on-disk temp `auth.json` + - `readStoredCredential` path (matches production exactly) rather than adding a - new injectable credential-read seam. Clean; no intervention needed. -- Made `getLoginProviderOptions` **synchronous** (it did no async work) to - satisfy `require-await`; de-awaited its 2 call sites in `authService.ts` and - the test. Also fixed pre-existing lint debt from earlier slices surfaced now - that lint ran green for the first time: `authRoutes.ts` return-await, - `authService.ts` api-key interaction (`() => Promise.resolve(key)` / - `notify: () => undefined`). -- Verified SDK behavior empirically with throwaway probe scripts (removed) - before writing doubles: providers/models catalog, api-key login persistence, - `getModel`, `hasConfiguredAuth` across store mutations + refresh, and - `readStoredCredential` against a temp auth.json. - -**Verification:** `npm run verify` green. Pre-commit hook (whole-project -typecheck + knip + eslint + related vitest) passed — committed normally -(no `--no-verify` needed since the tree is verify-green). - -**Blockers:** none. **Sessiond-restart-pending note still ACTIVE** — unchanged -this leg but slices 1 + 4 touched session-daemon paths; only the human clears -it after restarting the sessiond service. Handing off to leg 7 (slice 6: -changeset + final verify + cleanup; the finish line — no PR). - -## Leg 7 (slice 6 — changeset + final verify + cleanup) — RELAY COMPLETE -- **Added changeset** `.changeset/fix-pi-0-80-8-modelruntime-auth.md`: single - `patch` fragment for `@jmfederico/pi-web` describing the user-visible fix - (session daemon crash on Pi 0.80.8+ fixed by migrating auth to the new - `ModelRuntime` credential APIs; Pi Web now requires Pi `>=0.80.8`). Did NOT - edit `CHANGELOG.md`. -- **Bump type decision:** chose `patch`, not `minor`. status.md floated that a - minor was "defensible" (narrowed Pi range), but the `changeset-changelog` - skill is explicit for this CalVer repo: `patch` for all non-breaking changes, - never `minor` (the minor slot = release month), `major` only on explicit user - request. The fix is non-breaking to Pi Web consumers, so `patch` is correct. -- **Re-ran `npm run verify`:** fully GREEN — typecheck + lint + knip + 188 test - files, 1390 passed / 2 skipped. -- **Goal criteria confirmed:** (1) no live `AuthStorage`/`ModelRegistry.create| - inMemory`/`.authStorage`/`modelRegistry` use in `src/` — only remaining match - is an explanatory comment in `piSessionService.testSupport.ts`; (2) auth - surfaces all on new APIs (slices 1–5); (3) `package.json` peerDeps for the - three `@earendil-works/*` = `>=0.80.8 <0.81`, devDeps = `^0.80.8` (verified); - (4) changeset added this leg; (5) verify green. -- **Cleanup:** confirmed no scratch files in the repo (no `probe*.mjs`, - `.tmp-build/`). `ASSESSMENT-issue-62.md` intentionally kept (plan of record). -- **No PR opened** (explicitly out of scope for this relay). -- **STOP per charter:** goal reached, so no next leg was spawned. Surfaced to - the human: (a) verify green, (b) sessiond restart STILL PENDING (slices 1+4 - touched session-daemon paths; only the human clears that note after - restarting the sessiond service), (c) no PR by design. -- Committed status/log/changeset. diff --git a/relays/issue-62-authstorage/status.md b/relays/issue-62-authstorage/status.md deleted file mode 100644 index affc3f6..0000000 --- a/relays/issue-62-authstorage/status.md +++ /dev/null @@ -1,249 +0,0 @@ -# Relay status — issue-62-authstorage - -## RELAY COMPLETE — goal reached (leg 7, slice 6) -All charter goal criteria (1–5) are met and committed on branch -`fix/issue-62-authstorage`. **No PR was opened, by design (out of scope).** -The relay is finished; no further leg was spawned. - -**Surface to the human:** -- (a) `npm run verify` is fully GREEN (typecheck + lint + knip + 1390 tests, - 2 skipped). -- (b) **Sessiond restart is still PENDING** — slices 1 + 4 changed - session-daemon paths (`sessiond.ts`, `piSessionService.ts`). The human must - manually restart the sessiond service for the migration to take effect. Only - the human clears this note. -- (c) No PR opened (explicitly out of scope for this relay). - -Leg 7 (slice 6) added the changeset, re-verified green, confirmed goal -criteria, and confirmed no scratch files remain: -- **`.changeset/fix-pi-0-80-8-modelruntime-auth.md`** — a single `patch` - fragment for `@jmfederico/pi-web` describing the user-visible fix (session - daemon crash with Pi 0.80.8+ fixed by migrating to the new `ModelRuntime` - auth APIs; Pi Web now requires Pi `>=0.80.8`). Per the `changeset-changelog` - skill this repo uses **patch** for all non-breaking changes (CalVer: the - `minor` slot is the release month, not feature size; `major` only on explicit - request), so `patch` was chosen over the "minor is defensible" note. Commit - ``. -- Re-ran full `npm run verify`: GREEN. -- Double-checked `package.json`: peerDeps for the three `@earendil-works/*` - packages are `>=0.80.8 <0.81`, devDeps are `^0.80.8` — correct. -- Confirmed no scratch files in the repo (no `probe*.mjs`, `.tmp-build/`, - etc.). `ASSESSMENT-issue-62.md` intentionally stays (plan of record). -- Only `src` mention of the old API is an explanatory comment in - `piSessionService.testSupport.ts` (documents what the seam replaced) — no - live import/use. - -## Prior position (slice 5, leg 6, commit `d0cc55c`) -Slice 5 (tests + testSupport) complete and committed (`d0cc55c`). -**`npm run verify` was fully GREEN** — typecheck + lint + knip + 1390 tests -pass (2 skipped). All production code and all test/support code are off the -removed `AuthStorage` / `ModelRegistry.create|inMemory` surface. - -What slice 5 changed (all under `src/server/sessions/`): -- **`piSessionService.testSupport.ts`** (central helper): dropped - `AuthStorage`/`ModelRegistry` imports; added pi-ai `InMemoryCredentialStore`. - New seams: `createTestModelRuntime(credentials?)` (wraps - `ModelRuntime.create({ credentials })`), a shared `testModelRuntime` - (top-level `await createTestModelRuntime()` — the common no-auth catalog - runtime), and `seedCredential(store, providerId, credential)` (writes via the - `CredentialStore.modify` path). `fakeRuntime` session now carries - `modelRuntime: testModelRuntime`; `testModel()` reads - `testModelRuntime.getModel(...)`. -- Threaded `modelRuntime: testModelRuntime` into every `new PiSessionService(...)` - (now a required dep) across `archiveCleanup`/`lifecycle`/`promptQueue`/ - `spawnSession`/`spawnSubsession`/`sessionRoutes` tests, importing - `testModelRuntime` in each. -- **`piSessionService.promptQueue.test.ts`** auth-loss test rewritten: builds a - live `InMemoryCredentialStore` + `createTestModelRuntime(credentials)`, and - simulates auth changes via `credentials.delete/seedCredential` + - `modelRuntime.refresh()` + `applyAuthChange(...)` (matching AuthService's - real refresh-then-emit sequence). Removed the `modelRegistry` dep line. -- **`piSessionService.warnings.test.ts`**: `anthropicSubscriptionWarning` now - reads `readStoredCredential("anthropic", authPath)`, so the test seam is a - temp `auth.json` written per case (helper `anthropicAuthPath(...)`), passed - as the 2nd arg. `SubscriptionSession` type narrowed to - `Pick`. The "no credential" - case points at a temp dir with no auth.json (deterministic). -- **`authService.test.ts`** fully reworked to the async `AuthService.create({ - runtime | agentDir })` + `InMemoryCredentialStore` model. `saveApiKey`/ - `logoutProvider`/`startOAuthLogin` are awaited; OAuth-complete test asserts - `startOptions.runtime === runtime` and uses `vi.waitFor` for the async - refresh; credential assertions go through `credentials.read(...)`. -- Lint fixes surfaced by running lint green for the first time this relay: - `getLoginProviderOptions` made **synchronous** (it did no async work) and its - call sites in `authService.ts` + `authProviderOptions.test.ts` de-awaited; - `authRoutes.ts` handlers now `return await ...` (return-await rule); - `authService.ts` api-key interaction uses `() => Promise.resolve(key)` / - `notify: () => undefined`; test `modify` arrows use `() => Promise.resolve(...)`. - -SDK behavior verified empirically before writing doubles (throwaway probe -scripts, since removed): `ModelRuntime.create({ credentials })` exposes 36 -providers / 1072 models; `login(id, "api_key", interaction)` persists to the -store; `getModel("anthropic", "claude-sonnet-4-5-20250929")` resolves; -`hasConfiguredAuth` flips correctly across `delete`/`modify` + `refresh`; -`readStoredCredential(id, authPath)` reads a temp auth.json and returns -`undefined` for a missing file. - -### Prior position (slice 4, leg 5, commit `4ccd4f8`) -Slice 4 (`piSessionService.ts` migration) complete and committed (`4ccd4f8`). -`piSessionService.ts` now uses the new `ModelRuntime` API end to end: -- `createDefaultRuntimeFactory(modelRuntime, ...)` passes `modelRuntime` to - `createAgentSessionServices({ cwd, agentDir, modelRuntime })` (no more - `authStorage` + `modelRegistry`). -- `PiAgentSession.modelRegistry` → `PiAgentSession.modelRuntime: ModelRuntime`. -- `anthropicSubscriptionWarning(session, authPath?)` now reads via - `readStoredCredential("anthropic", authPath)` (sync); `warningsForSession` - passes `join(this.agentDir, "auth.json")`. Its `session` param narrowed to - `Pick` (no longer needs the - registry). -- Model reads rederived onto the runtime: `availableModels`/`setModel` use - `await modelRuntime.refresh()` + `getAvailableSnapshot()` + `getModel(...)`; - `syncCurrentModelAuthWarning` uses `getModel(...)` + - `hasConfiguredAuth(providerId)`. -- `applyAuthChange` no longer refreshes a registry (the shared runtime is - refreshed by AuthService before it emits, and all sessions share that - runtime), so the `auth.subscribe` callback stays synchronous. -- **`modelRuntime` is now a REQUIRED `PiSessionServiceDependencies` field** - (the old `modelRegistry?` fallback used a *sync* `ModelRegistry.create`; a - `ModelRuntime` can only be built by the async `ModelRuntime.create`, which - can't run inside a constructor). `sessiond.ts` already injects - `modelRuntime: auth.runtime` (slice 1), so it typechecks unchanged. -- Dropped the `AuthStorage` / `ModelRegistry` imports and the - `createModelRegistryForAgentDir` import (only `AuthChange` is still imported - from `authService.js`). - -`npx tsc --noEmit`: **`sessiond.ts` and `piSessionService.ts` are at 0 errors** -(production code is fully migrated; `grep -vE '\.test\.ts|testSupport\.ts'` on -tsc output is empty). All remaining errors are slice-5 test/support files. -`piSessionService.ts` lints clean. - -### Prior position (slice 3, leg 4, commit `1c3d6db`) -Slice 3 (`oauthLoginFlowService.ts` migration) complete and committed (`1c3d6db`). -`OAuthLoginFlowService` is reimplemented against the pi-ai `AuthInteraction` -contract (`{ signal?, prompt(AuthPrompt), notify(AuthEvent) }`); the old -`OAuthLoginCallbacks`/`AuthStorage` imports are gone. `start()` now takes a -`ModelRuntime` (narrowed to `Pick`) instead of -`authStorage`, and drives login via `runtime.login(providerId, "oauth", -interaction)`. Mapping: `AuthPrompt` `text`/`secret`/`manual_code` → web-UI -`prompt` (kind `prompt`, `manual_code` → kind `manual`); `select` → web-UI -`select` (options `{id,label}` → `{value,label}`, returns chosen id); -`AuthEvent` `auth_url` → `auth: {url, instructions?}`; `device_code` → reuse -`auth` field (`url: verificationUri`, `instructions: "Enter code: "`); -`info`/`progress` → append `message` to `progress`. Per-prompt -`AuthPrompt.signal` now aborts just that pending request (rejects -`"Prompt cancelled"`) without ending the overall flow — needed because a -`manual_code` prompt can race a callback server. `oauthLoginFlowService.test.ts` -rewritten to the new contract via a `fakeRuntime` login double; **9 tests pass**, -files lint clean. - -`npx tsc --noEmit` now reports **26 errors** (down from 28). `authService.ts` -is now at **0 errors** (as predicted). Remaining errors are all slice 4/5: -`sessiond.ts` (1) + `piSessionService.ts` (6) = slice 4; -`authService.test.ts` (10), `piSessionService.testSupport.ts` (3), -`.promptQueue.test.ts` (2), `.warnings.test.ts` (4) = slice 5. - -### Prior position (slice 2, leg 3, commit `d09d7cc`) -Slice 2 (`authProviderOptions.ts` migration) complete and committed (`d09d7cc`). -`authProviderOptions.ts` now derives options from a runtime-shaped -`AuthProviderRuntime` interface (`getProviders()` + `listCredentials()` + -`getProviderAuthStatus()`). `getLoginProviderOptions`/`getLogoutProviderOptions` -are now `async` (matching the `await` call sites already in `authService.ts`). -The old `AuthProviderModelRegistry` interface is gone; a real `ModelRuntime` -satisfies `AuthProviderRuntime` structurally. The test double in -`authProviderOptions.test.ts` was rewritten to the runtime shape and its 3 -tests pass. OAuth-capable = `auth.oauth` present; api-key = `auth.apiKey` -present; `OAUTH_ONLY_PROVIDERS` / `isApiKeyLoginProvider` logic preserved; -display names come from `Provider.name`. - -`npx tsc --noEmit` now reports **28 errors** (down from 31). No -`authProviderOptions` errors remain and the `getLoginProviderOptions` / -`getLogoutProviderOptions` call sites in `authService.ts` typecheck cleanly. -Remaining errors are all cross-slice: `authService.ts` (1: line-83 -`OAuthLoginFlowService.start` still expects `authStorage` not `runtime` — -slice 3), `sessiond.ts` (1) + `piSessionService.ts` (6, slice 4), and the -test/support files (slice 5): `authService.test.ts` (9), -`oauthLoginFlowService.ts`/`.test.ts` (1+1, slice 3), -`piSessionService.testSupport.ts` (3), `.promptQueue.test.ts` (2), -`.warnings.test.ts` (4). - -### Prior position (slice 1, leg 2, commit `e37148c`) -Slice 1 (`authService.ts` core migration) complete and committed (`e37148c`). -`authService.ts` now uses the async `ModelRuntime` API: `AuthService.create({ -agentDir | runtime })` factory wraps `ModelRuntime.create({ authPath, -modelsPath })`; `createModelRuntimeForAgentDir` replaces -`createModelRegistryForAgentDir`. `saveApiKey` → `runtime.login(id, "api_key", -nonInteractive)`, `logoutProvider` → `runtime.logout`, `refreshAuthState` → -`await runtime.refresh()`. `authProviders` / `requireOAuthLoginProvider` are now -async. `startOAuthLogin` passes `runtime` into `OAuthLoginFlowService.start`. -`sessiond.ts` uses async `createRuntime`, `AuthService.create`, and passes -`modelRuntime: auth.runtime` to `PiSessionService`; `sessionDaemonStartup` now -awaits `createRuntime`. - -`npx tsc --noEmit` reports **31 errors** (up from 24 — expected: the migrated -authService now calls the runtime-based interfaces that slices 2–4 haven't -exposed yet). Slice-1 files are internally consistent; every remaining error -in `authService.ts` / `sessiond.ts` is a **cross-slice** dependency: -- `authService.ts`: `getLoginProviderOptions/getLogoutProviderOptions` still - take the old `AuthProviderModelRegistry` shape (fixed in slice 2); - `OAuthLoginFlowService.start` still expects `authStorage` not `runtime` - (fixed in slice 3). -- `sessiond.ts`: `PiSessionServiceDependencies` still expects `modelRegistry` - not `modelRuntime` (fixed in slice 4). -Remaining errors otherwise live in slices 2/3/4 files and all test/support -files (slice 5). - -## Leg tracking -- **Last completed leg:** 7 (slice 6 — changeset + final verify + cleanup). **FINAL LEG.** -- **Next leg to run:** none — relay complete, no handoff spawned. - -## Next task -None — the relay goal is reached. If new work is needed (e.g. opening a PR), -that is a separate task outside this relay's charter. - -### Build/tooling note (important for every leg) -**Update (leg 2):** the human reports `/tmp` is now fully usable again, so the -previous `TMPDIR` workaround is no longer required — plain `npm install` should -work. (If a disk-quota error resurfaces, fall back to -`TMPDIR="$PWD/.tmp-build" npm install` and remove `.tmp-build` after; it is -scratch, do not commit it.) node_modules is already installed at 0.80.10, so a -fresh install is only needed if node_modules is cleared. The pre-commit hook -runs a whole-project typecheck; while the migration is incomplete, commit relay -work with `git commit --no-verify` (the charter permits legs that aren't -verify-green). Node: v24.18.0. - -## Relevant context for the next runner -- **Plan of record:** `ASSESSMENT-issue-62.md` (root) — read once. §5 has the - per-file migration shape; §3 has the exact new API shapes; §6 the dep ranges. -- **Changeset skill:** `.agents/skills/changeset-changelog/SKILL.md` (and - `changeset-changelog` in the skills list). Follow it for the fragment format. -- **The migration is done** — slice 6 is docs/changeset + confirmation only. - No further source changes are expected; if you find yourself editing - `src/`, re-check whether that's really in scope. -- **New API cheat-sheet:** `ModelRuntime.create({ authPath, modelsPath, - credentials? }): Promise`; credential persistence via the - pi-ai `CredentialStore.modify` path; `runtime.login(providerId, type, - AuthInteraction)`; `runtime.logout`; `runtime.getProviders()` / - `listCredentials()` / `getProviderAuthStatus()`; `readStoredCredential( - providerId, authPath?)` for the sync anthropic warning; pi-ai - `InMemoryCredentialStore` for tests. -- **Decision already made:** clean migration, **no dual-version compat shim** - (assessment §4/§5). Do not reopen this without the intervention signal. - -## Progress documentation expectations -Every leg: update this `status.md` (current position, leg tracking, next task, -context, blockers), append a concise `log.md` entry, make work durable, and -commit before handing off. Hand off with `spawn_session` **once** per the -charter's Handover section. - -## Blockers / intervention state -None blocking. Relay complete. Known constraints: -- **Sessiond restart pending (ACTIVE):** slice 1 (leg 2, commit `e37148c`) - changed `sessiond.ts` + the session-daemon auth construction path; slice 4 - (leg 5, commit `4ccd4f8`) added `piSessionService.ts` (a session-daemon path) - to this surface. Per AGENTS.md the human must **manually restart the sessiond - service** for these changes to take effect once the migration lands. Keep - this note until the human confirms the restart. -- `/tmp` disk-quota issue is resolved (human confirmed usable) — see the - Build/tooling note above. -- node_modules is installed (gitignored) at 0.80.10; a fresh `npm install` is - only needed if node_modules is cleared. diff --git a/src/cli.test.ts b/src/cli.test.ts index 80fa32e..843927a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -8,6 +8,7 @@ import { doctorExitCode, isCliEntrypoint, launchdRuntimeDetails, + nodeVersionCheck, regularFileExists, serviceBackendForPlatform, } from "./cli.js"; @@ -58,6 +59,18 @@ describe("commandWithVersionCheck", () => { }); }); +describe("nodeVersionCheck", () => { + it("checks the complete supported Node version with the resolved executable", () => { + process.env["SHELL"] = "/bin/bash"; + + const command = nodeVersionCheck(); + + expect(command).toContain("22.19.0"); + expect(command).toContain("process.versions.node"); + expect(command).toContain("\"$pi_web_probe_executable\""); + }); +}); + describe("agentCommandForChecks", () => { it("reads the configured agent command for doctor checks", () => { const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-")); diff --git a/src/cli.ts b/src/cli.ts index 3b2a0e1..d4ed1e7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { type NativeServiceInstallFailure, } from "./nativeServices/serviceInstall.js"; import { + minimumSupportedNodeVersion, nativeServiceManagerRefs, productionNativeServiceIds, type NativeServiceBackend, @@ -772,11 +773,14 @@ export function commandWithVersionCheck(command: string): string { return `${found} && (${commandWord} --version 2>&1 || true)`; } -function nodeVersionCheck(): string { - return [ - commandCheck("node"), - "node -e \"const major = Number(process.versions.node.split('.')[0]); console.log(process.version); process.exit(major >= 22 ? 0 : 1);\"", - ].join(" && "); +export function nodeVersionCheck(): string { + return nativeServicePrerequisiteShellCheck(detectServiceShell().name, { + id: "caller.node", + kind: "node-version", + command: "node", + minimumVersion: minimumSupportedNodeVersion, + description: `node >= ${minimumSupportedNodeVersion}`, + }); } export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string { @@ -787,7 +791,7 @@ function generalDoctorChecks(): Check[] { const shell = serviceShellLabel(); const agentCommand = agentCommandForChecks(); return [ - [`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())], + [`Caller login ${shell} can find node >= ${minimumSupportedNodeVersion}`, serviceShellCommand(nodeVersionCheck())], [`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))], [`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))], ]; diff --git a/src/nativeServices/servicePlan.test.ts b/src/nativeServices/servicePlan.test.ts index c8b44eb..31dee77 100644 --- a/src/nativeServices/servicePlan.test.ts +++ b/src/nativeServices/servicePlan.test.ts @@ -100,7 +100,7 @@ describe("production native service planning", () => { wants: [], prerequisites: [ { id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" }, - { id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 }, + { id: "sessiond.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" }, ], }, { @@ -111,7 +111,7 @@ describe("production native service planning", () => { wants: ["sessiond"], prerequisites: [ { id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" }, - { id: "web.node", kind: "node-version", command: "node", minimumMajor: 22 }, + { id: "web.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" }, ], }, ], @@ -196,7 +196,7 @@ describe("production native service planning", () => { namedCommandFailure: "command not found", }, prerequisites: [ - { id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 }, + { id: "sessiond.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" }, { id: "sessiond.entrypoint", kind: "readable-file", path: "/package with space/sessiond's entry.js" }, ], }); @@ -324,7 +324,7 @@ describe("development native service planning", () => { environment: { PI_WEB_CONFIG: "/tmp/config.json" }, workingDirectory: "/checkout with space", prerequisites: [ - { id: "sessiond.node", kind: "node-version", minimumMajor: 22 }, + { id: "sessiond.node", kind: "node-version", minimumVersion: "22.19.0" }, { id: "sessiond.command.npm", kind: "command-available", command: "npm" }, { id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] }, ], @@ -338,7 +338,7 @@ describe("development native service planning", () => { after: ["sessiond"], wants: ["sessiond"], prerequisites: [ - { id: "uiDev.node", kind: "node-version", minimumMajor: 22 }, + { id: "uiDev.node", kind: "node-version", minimumVersion: "22.19.0" }, { id: "uiDev.command.npm", kind: "command-available", command: "npm" }, { id: "uiDev.command.bash", kind: "command-available", command: "bash" }, { id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] }, diff --git a/src/nativeServices/servicePlan.ts b/src/nativeServices/servicePlan.ts index 077fc5a..af3507b 100644 --- a/src/nativeServices/servicePlan.ts +++ b/src/nativeServices/servicePlan.ts @@ -1,3 +1,5 @@ +export const minimumSupportedNodeVersion = "22.19.0"; + export type NativeServiceBackendKind = "systemd" | "launchd"; export type NativeServiceMode = "production" | "development"; export type NativeServiceId = "sessiond" | "web" | "uiDev"; @@ -64,7 +66,7 @@ export type NativeServicePrerequisite = id: string; kind: "node-version"; command: "node"; - minimumMajor: number; + minimumVersion: string; description: string; } | { @@ -571,8 +573,8 @@ function nodeRequirement(serviceId: NativeServiceId): NativeServicePrerequisite id: `${serviceId}.node`, kind: "node-version", command: "node", - minimumMajor: 22, - description: "node >= 22 is available to the service shell", + minimumVersion: minimumSupportedNodeVersion, + description: `node >= ${minimumSupportedNodeVersion} is available to the service shell`, }; } diff --git a/src/nativeServices/serviceProbe.test.ts b/src/nativeServices/serviceProbe.test.ts index bd1b491..6a3a8f8 100644 --- a/src/nativeServices/serviceProbe.test.ts +++ b/src/nativeServices/serviceProbe.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; import { LaunchdNativeServiceProbe, @@ -5,6 +6,7 @@ import { SystemdNativeServiceProbe, launchdProbePlist, nativeServicePrerequisiteShellCheck, + nodeVersionCheckScript, systemdRunArguments, type LaunchdProbeFileSystem, type ProbeCommandResult, @@ -433,13 +435,26 @@ describe("probe service definitions", () => { id: "sessiond.node", kind: "node-version", command: "node", - minimumMajor: 22, - description: "node >= 22", + minimumVersion: "22.19.0", + description: "node >= 22.19.0", }); expect(check).toContain("\"$pi_web_probe_executable\" '-e'"); + expect(check).toContain("22.19.0"); expect(check).not.toContain("&& node -e"); }); + it.each([ + { version: "21.99.99", accepted: false }, + { version: "22.18.99", accepted: false }, + { version: "22.19.0", accepted: true }, + { version: "22.19.1", accepted: true }, + { version: "23.0.0", accepted: true }, + ])("checks the complete Node version for $version", ({ version, accepted }) => { + const result = spawnSync(process.execPath, ["-e", nodeVersionCheckScript("22.19.0"), version]); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(accepted ? 0 : 1); + }); + it("requires bundled entrypoints to be readable regular files", () => { const check = nativeServicePrerequisiteShellCheck("bash", { id: "sessiond.entrypoint", diff --git a/src/nativeServices/serviceProbe.ts b/src/nativeServices/serviceProbe.ts index 8036f8b..12db593 100644 --- a/src/nativeServices/serviceProbe.ts +++ b/src/nativeServices/serviceProbe.ts @@ -434,10 +434,8 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam switch (prerequisite.kind) { case "command-available": return externalExecutableShellCheck(shell, prerequisite.command); - case "node-version": { - const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`; - return externalExecutableShellCheck(shell, "node", ["-e", script]); - } + case "node-version": + return externalExecutableShellCheck(shell, "node", ["-e", nodeVersionCheckScript(prerequisite.minimumVersion)]); case "readable-file": { const path = shellQuote(shell, prerequisite.path); return `test -f ${path} && test -r ${path}`; @@ -449,6 +447,11 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam } } +export function nodeVersionCheckScript(minimumVersion: string): string { + const encodedMinimum = JSON.stringify(minimumVersion); + return `const version=process.argv[1]??process.versions.node;console.log(process.version);const current=version.split('.').map(Number);const minimum=${encodedMinimum}.split('.').map(Number);const length=Math.max(current.length,minimum.length);let comparison=0;for(let index=0;indexright?1:-1;break}}process.exit(comparison>=0?0:1)`; +} + function externalExecutableShellCheck( shell: NativeServiceShellName, command: string, @@ -518,7 +521,7 @@ function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string { case "command-available": return `${prerequisite.command} did not resolve to an external executable in the native service environment.`; case "node-version": - return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`; + return `node >= ${prerequisite.minimumVersion} was not available in the native service environment.`; case "readable-file": return `${prerequisite.path} was not a readable regular file in the native service environment.`; case "package-scripts": diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 599faae..bbccade 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -5,7 +5,7 @@ import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OAuthFlowState } from "../../shared/apiTypes.js"; -import { AuthService, type AuthChange, type AuthServiceLogger } from "./authService.js"; +import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthServiceLogger } from "./authService.js"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; const tempDirs: string[] = []; @@ -251,7 +251,8 @@ describe("AuthService", () => { it("stores credentials in the configured agent directory", async () => { const agentDir = await tempAgentDir(); - const auth = await AuthService.create({ agentDir }); + const runtime = await createModelRuntimeForAgentDir(agentDir, false); + const auth = await AuthService.create({ runtime }); await auth.saveApiKey("anthropic", "sk-test"); diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index 6f91d85..cd939e0 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -31,8 +31,12 @@ interface AuthChangeContext { const noopLogger: AuthServiceLogger = { error() { /* no-op */ } }; -export function createModelRuntimeForAgentDir(agentDir: string): Promise { - return ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") }); +export function createModelRuntimeForAgentDir(agentDir: string, allowModelNetwork?: boolean): Promise { + return ModelRuntime.create({ + authPath: join(agentDir, "auth.json"), + modelsPath: join(agentDir, "models.json"), + ...(allowModelNetwork === undefined ? {} : { allowModelNetwork }), + }); } export class AuthService { diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts index e1d338d..5ddefa3 100644 --- a/src/server/sessions/piSessionService.testSupport.ts +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -79,7 +79,7 @@ export async function seedCredential(store: InMemoryCredentialStore, providerId: * behavior (e.g. auth-loss warnings). */ export function createTestModelRuntime(credentials: CredentialStore = new InMemoryCredentialStore()): Promise { - return ModelRuntime.create({ credentials }); + return ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false }); } /** From 3c3741b565bb60c9a4450fb8b13201aa94cb3728 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 18 Jul 2026 07:48:27 +0200 Subject: [PATCH 22/26] fix(auth): reconcile committed OAuth cancellation --- .changeset/fix-pi-0-80-8-modelruntime-auth.md | 2 +- src/server/sessions/authService.test.ts | 58 +++++++++++++++++-- src/server/sessions/authService.ts | 15 ++++- .../sessions/oauthLoginFlowService.test.ts | 24 ++++++++ src/server/sessions/oauthLoginFlowService.ts | 58 +++++++++++++++---- 5 files changed, 137 insertions(+), 20 deletions(-) diff --git a/.changeset/fix-pi-0-80-8-modelruntime-auth.md b/.changeset/fix-pi-0-80-8-modelruntime-auth.md index 0e25b42..822b379 100644 --- a/.changeset/fix-pi-0-80-8-modelruntime-auth.md +++ b/.changeset/fix-pi-0-80-8-modelruntime-auth.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Login options now follow each provider's interactive API-key and OAuth capabilities, OAuth prompts retain their input, selection, and device-code semantics, and unsupported multi-step API-key setup fails safely instead of storing malformed credentials. PI WEB now requires Node.js `>=22.19.0`. +Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Login options now follow each provider's interactive API-key and OAuth capabilities, OAuth prompts retain their input, selection, and device-code semantics, committed OAuth login remains truthful when cancellation races the final refresh, and unsupported multi-step API-key setup fails safely instead of storing malformed credentials. PI WEB now requires Node.js `>=22.19.0`. diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index bbccade..d3fb5ae 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -41,8 +41,9 @@ describe("AuthService", () => { auth.dispose(); }); - it("persists an API key and attempts every listener when propagation fails", async () => { - const error = vi.fn(); + it("persists an API key and attempts every listener when failure logging throws", async () => { + const loggingFailure = new Error("auth logger failed"); + const error = vi.fn(() => { throw loggingFailure; }); const logger: AuthServiceLogger = { error }; const { auth, credentials, changes } = await createAuthService({}, logger); const failure = new Error("session auth refresh failed"); @@ -260,6 +261,44 @@ describe("AuthService", () => { auth.dispose(); }); + it("reconciles cancellation after ModelRuntime persists OAuth but before its refresh completes", async () => { + const { auth, runtime, credentials, changes } = await createAuthService(); + const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined); + if (provider?.auth.oauth === undefined) throw new Error("Expected built-in OAuth provider"); + const credential: Credential = { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 60_000, + }; + vi.spyOn(provider.auth.oauth, "login").mockResolvedValue(credential); + vi.spyOn(runtime, "reloadConfig").mockResolvedValue(undefined); + const refreshStarted = deferred(); + const finishRefresh = deferred(); + const refresh = vi.spyOn(runtime, "refresh").mockImplementation(async () => { + refreshStarted.resolve(undefined); + await finishRefresh.promise; + return { aborted: false, errors: new Map() }; + }); + + const state = await auth.startOAuthLogin(provider.id); + await refreshStarted.promise; + + await expect(credentials.read(provider.id)).resolves.toEqual(credential); + expect(auth.cancelOAuthFlow(state.flowId)).toMatchObject({ status: "cancelled", error: "Login cancelled" }); + expect(changes).toEqual([]); + + finishRefresh.resolve(undefined); + await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); }); + + expect(auth.oauthFlow(state.flowId)).toMatchObject({ status: "complete", progress: ["Login complete"] }); + expect(auth.oauthFlow(state.flowId)).not.toHaveProperty("error"); + await expect(credentials.read(provider.id)).resolves.toEqual(credential); + expect(changes).toEqual([{}]); + expect(refresh).toHaveBeenCalledOnce(); + auth.dispose(); + }); + it("emits an auth change after OAuth login completes without refreshing twice", async () => { const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), @@ -293,8 +332,9 @@ describe("AuthService", () => { expect(authFlows.disposed).toBe(true); }); - it("completes OAuth when an auth-change listener rejects", async () => { - const error = vi.fn(); + it("completes OAuth when an auth-change listener and failure logging throw", async () => { + const loggingFailure = new Error("auth logger failed"); + const error = vi.fn(() => { throw loggingFailure; }); const logger: AuthServiceLogger = { error }; const { auth, runtime, changes } = await createAuthService({}, logger); const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined); @@ -353,6 +393,16 @@ async function tempAgentDir(): Promise { return dir; } +function deferred() { + let resolveValue: (value: T) => void = () => undefined; + let rejectValue: (reason?: unknown) => void = () => undefined; + const promise = new Promise((resolve, reject) => { + resolveValue = resolve; + rejectValue = reject; + }); + return { promise, resolve: resolveValue, reject: rejectValue }; +} + function radiusModelsConfig(name: string): string { return JSON.stringify({ providers: { diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index cd939e0..4e26c04 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -53,8 +53,9 @@ export class AuthService { static async create(deps: AuthServiceDependencies = {}): Promise { const runtime = deps.runtime ?? (deps.agentDir === undefined ? await ModelRuntime.create({}) : await createModelRuntimeForAgentDir(deps.agentDir)); - const authFlows = deps.authFlows ?? new OAuthLoginFlowService(); - return new AuthService(runtime, authFlows, deps.logger ?? noopLogger); + const logger = deps.logger ?? noopLogger; + const authFlows = deps.authFlows ?? new OAuthLoginFlowService({ logger }); + return new AuthService(runtime, authFlows, logger); } subscribe(listener: AuthChangeListener): () => void { @@ -130,11 +131,19 @@ export class AuthService { const results = await Promise.allSettled([...this.listeners].map(async (listener) => listener(change))); for (const result of results) { if (result.status === "rejected") { - this.logger.error({ err: result.reason, ...context }, "auth-change listener failed"); + this.logErrorNoThrow({ err: result.reason, ...context }, "auth-change listener failed"); } } } + private logErrorNoThrow(details: Record, message: string): void { + try { + this.logger.error(details, message); + } catch { + // A diagnostic failure cannot turn an already-committed auth mutation into an API failure. + } + } + private async requireApiKeyLoginProvider(providerId: string) { await this.runtime.reloadConfig(); const provider = getLoginProviderOptions(this.runtime, "api_key").find((option) => option.id === providerId); diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts index d42fbe8..90c0500 100644 --- a/src/server/sessions/oauthLoginFlowService.test.ts +++ b/src/server/sessions/oauthLoginFlowService.test.ts @@ -60,6 +60,30 @@ describe("OAuthLoginFlowService", () => { service.dispose(); }); + it("keeps a committed login complete when its completion callback and logger throw", async () => { + const completionFailure = new Error("completion propagation failed"); + const loggingFailure = new Error("OAuth logger failed"); + const error = vi.fn(() => { throw loggingFailure; }); + const onComplete = vi.fn(() => { throw completionFailure; }); + const service = new OAuthLoginFlowService({ logger: { error } }); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + runtime: fakeRuntime(() => Promise.resolve()), + onComplete, + }); + + await vi.waitFor(() => { expect(service.get(state.flowId).status).toBe("complete"); }); + + expect(service.get(state.flowId)).toMatchObject({ status: "complete", progress: ["Login complete"] }); + expect(onComplete).toHaveBeenCalledOnce(); + expect(error).toHaveBeenCalledWith( + { err: completionFailure, flowId: state.flowId, providerId: "test-provider" }, + "OAuth login completion callback failed", + ); + service.dispose(); + }); + it("allows blank text responses for providers that use blank as a default", async () => { let domain: string | undefined; const service = new OAuthLoginFlowService(); diff --git a/src/server/sessions/oauthLoginFlowService.ts b/src/server/sessions/oauthLoginFlowService.ts index 214f694..d38cf18 100644 --- a/src/server/sessions/oauthLoginFlowService.ts +++ b/src/server/sessions/oauthLoginFlowService.ts @@ -27,25 +27,33 @@ interface OAuthFlowRecord { cleanupTimer?: TimerHandle; } +export interface OAuthLoginFlowLogger { + error(details: Record, message: string): void; +} + export interface OAuthLoginFlowServiceOptions { terminalTtlMs?: number; runningTtlMs?: number; now?: () => number; + logger?: OAuthLoginFlowLogger; } const DEFAULT_TERMINAL_TTL_MS = 5 * 60 * 1000; const DEFAULT_RUNNING_TTL_MS = 30 * 60 * 1000; +const noopLogger: OAuthLoginFlowLogger = { error() { /* no-op */ } }; export class OAuthLoginFlowService { private readonly flows = new Map(); private readonly terminalTtlMs: number; private readonly runningTtlMs: number; private readonly now: () => number; + private readonly logger: OAuthLoginFlowLogger; constructor(options: OAuthLoginFlowServiceOptions = {}) { this.terminalTtlMs = options.terminalTtlMs ?? DEFAULT_TERMINAL_TTL_MS; this.runningTtlMs = options.runningTtlMs ?? DEFAULT_RUNNING_TTL_MS; this.now = options.now ?? (() => Date.now()); + this.logger = options.logger ?? noopLogger; } start(options: { @@ -80,20 +88,15 @@ export class OAuthLoginFlowService { notify: (event) => { this.handleEvent(record, event); }, }; - void options.runtime.login(options.providerId, "oauth", interaction) - .then(async () => { - if (!this.isCurrentRunning(record)) return; - this.clearPending(record); - await options.onComplete?.(); - if (!this.isCurrentRunning(record)) return; - this.markTerminal(record, { ...withoutInteraction(record.state), status: "complete", progress: [...record.state.progress, "Login complete"] }); - }) - .catch((error: unknown) => { - if (this.flows.get(record.flowId) !== record) return; + void options.runtime.login(options.providerId, "oauth", interaction).then( + () => this.reconcileCommittedLogin(record, options.onComplete), + (error: unknown) => { + if (!this.isCurrent(record)) return; this.clearPending(record); if (record.state.status !== "running") return; this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: error instanceof Error ? error.message : String(error) }); - }); + }, + ); return this.get(flowId); } @@ -274,8 +277,39 @@ export class OAuthLoginFlowService { return pending; } + // ModelRuntime persists the credential before its post-login refresh. If a + // cancellation lands during that refresh, the resolved login is committed + // truth and must supersede the transient cancelled state. + private async reconcileCommittedLogin(record: OAuthFlowRecord, onComplete?: () => void | Promise): Promise { + if (this.isCurrent(record)) this.clearPending(record); + try { + await onComplete?.(); + } catch (error) { + this.logErrorNoThrow( + { err: error, flowId: record.flowId, providerId: record.state.providerId }, + "OAuth login completion callback failed", + ); + } + if (!this.isCurrent(record)) return; + const completed = withoutInteraction(record.state); + delete completed.error; + this.markTerminal(record, { ...completed, status: "complete", progress: [...record.state.progress, "Login complete"] }); + } + + private isCurrent(record: OAuthFlowRecord): boolean { + return this.flows.get(record.flowId) === record; + } + private isCurrentRunning(record: OAuthFlowRecord): boolean { - return this.flows.get(record.flowId) === record && record.state.status === "running"; + return this.isCurrent(record) && record.state.status === "running"; + } + + private logErrorNoThrow(details: Record, message: string): void { + try { + this.logger.error(details, message); + } catch { + // Logging is post-commit diagnostics and must never change auth truth. + } } private updateState(record: OAuthFlowRecord, state: OAuthFlowState): void { From cc8f379143f7c09ee6c2ca84eb6d27bfc24c5524 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 18 Jul 2026 08:06:18 +0200 Subject: [PATCH 23/26] fix(auth): invalidate stale browser OAuth operations --- .../src/controllers/authController.test.ts | 111 +++++++++++++++++- src/client/src/controllers/authController.ts | 79 ++++++++++--- 2 files changed, 170 insertions(+), 20 deletions(-) diff --git a/src/client/src/controllers/authController.test.ts b/src/client/src/controllers/authController.test.ts index 1e9481b..0f2525d 100644 --- a/src/client/src/controllers/authController.test.ts +++ b/src/client/src/controllers/authController.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { api as defaultApi, type AuthProviderOption, type OAuthFlowState, type SessionInfo, type SessionStatus } from "../api"; import { initialAppState, type AppState } from "../appState"; import { AuthController, parseAuthSlashCommand } from "./authController"; @@ -134,6 +134,108 @@ describe("AuthController", () => { }); }); + it("does not recreate an OAuth dialog when a pending response settles during cancellation", async () => { + const prompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const; + const flow = oauthFlow({ prompt }); + const response = deferred(); + const cancellation = deferred(); + const { controller, getState } = createController( + { authDialog: { step: "oauth", flow, inputValue: "https://callback" } }, + { + respondOAuthFlow: () => response.promise, + cancelOAuthFlow: () => cancellation.promise, + }, + ); + + const responsePending = controller.respondOAuth(); + const cancellationPending = controller.cancelOAuth(); + const dialogAfterCancel = getState().authDialog; + + response.resolve(oauthFlow({ prompt, progress: ["Stale response"] })); + await responsePending; + const dialogAfterResponse = getState().authDialog; + + cancellation.resolve(oauthFlow({ status: "cancelled" })); + await cancellationPending; + + expect(dialogAfterCancel).toBeUndefined(); + expect(dialogAfterResponse).toBeUndefined(); + expect(getState().authDialog).toBeUndefined(); + }); + + it("does not let a stale OAuth response overwrite a newer flow", async () => { + vi.stubGlobal("window", { setInterval: () => 1, clearInterval: () => undefined }); + const oldPrompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const; + const oldFlow = oauthFlow({ prompt: oldPrompt }); + const newFlow = oauthFlow({ flowId: "flow-2", prompt: { requestId: "request-2", message: "Paste callback", kind: "manual" } }); + const response = deferred(); + const providers = [authProvider("anthropic", "oauth")]; + const { controller, getState } = createController( + { authDialog: { step: "oauth", flow: oldFlow, inputValue: "https://old-callback" } }, + { + respondOAuthFlow: () => response.promise, + authProviders: () => Promise.resolve({ providers }), + startOAuthLogin: () => Promise.resolve(newFlow), + }, + ); + + try { + const responsePending = controller.respondOAuth(); + await controller.openLogin("anthropic"); + const dialogAfterNewFlow = getState().authDialog; + + response.resolve(oauthFlow({ prompt: oldPrompt, progress: ["Stale response"] })); + await responsePending; + + expect(dialogAfterNewFlow).toMatchObject({ step: "oauth", flow: { flowId: "flow-2" } }); + expect(getState().authDialog).toMatchObject({ step: "oauth", flow: { flowId: "flow-2" } }); + } finally { + response.resolve(oldFlow); + controller.dispose(); + vi.unstubAllGlobals(); + } + }); + + it("does not let an older poll restore a running flow after a newer poll stops polling", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { setInterval: globalThis.setInterval, clearInterval: globalThis.clearInterval }); + const prompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const; + const runningFlow = oauthFlow({ prompt }); + const stalePoll = deferred(); + const providers = [authProvider("anthropic", "oauth")]; + let pollCalls = 0; + const { controller, getState } = createController( + {}, + { + authProviders: () => Promise.resolve({ providers }), + startOAuthLogin: () => Promise.resolve(runningFlow), + oauthFlow: () => { + pollCalls += 1; + return pollCalls === 1 ? stalePoll.promise : Promise.resolve(oauthFlow({ status: "cancelled", prompt })); + }, + }, + ); + + try { + await controller.openLogin("anthropic"); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + const dialogAfterPollingStopped = getState().authDialog; + + stalePoll.resolve(oauthFlow({ prompt, progress: ["Stale running poll"] })); + await flushMicrotasks(); + + expect(pollCalls).toBe(2); + expect(dialogAfterPollingStopped).toMatchObject({ step: "oauth", flow: { status: "cancelled" } }); + expect(getState().authDialog).toMatchObject({ step: "oauth", flow: { status: "cancelled" } }); + } finally { + stalePoll.resolve(runningFlow); + controller.dispose(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + it("cancels the active OAuth flow and closes the dialog even when cancellation fails", async () => { const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); const cancelCalls: { flowId: string; machineId: string | undefined }[] = []; @@ -246,6 +348,13 @@ async function flushMicrotasks(): Promise { await Promise.resolve(); } +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolveDeferred: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { resolveDeferred = resolve; }); + if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized"); + return { promise, resolve: resolveDeferred }; +} + function remoteMachine(id: string): NonNullable { return { id, diff --git a/src/client/src/controllers/authController.ts b/src/client/src/controllers/authController.ts index fe1b209..b9df1c9 100644 --- a/src/client/src/controllers/authController.ts +++ b/src/client/src/controllers/authController.ts @@ -1,6 +1,9 @@ import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api"; +import type { AuthDialogState } from "../appState"; import { selectedMachineId, type GetState, type SetState } from "./types"; +type OAuthDialogState = Extract; + export interface AuthControllerDependencies { api?: typeof defaultApi; pollIntervalMs?: number; @@ -9,6 +12,8 @@ export interface AuthControllerDependencies { export class AuthController { private readonly api: typeof defaultApi; private readonly pollIntervalMs: number; + private oauthOperationGeneration = 0; + private pollGeneration = 0; private pollTimer: number | undefined; constructor( @@ -22,6 +27,7 @@ export class AuthController { } dispose(): void { + this.oauthOperationGeneration += 1; this.stopPolling(); } @@ -128,15 +134,22 @@ export class AuthController { if (dialog?.step !== "oauth") return; const request = dialog.flow.prompt ?? dialog.flow.select; if (request === undefined) return; + const operationGeneration = this.oauthOperationGeneration; + const flowId = dialog.flow.flowId; + const requestId = request.requestId; const responseValue = value ?? dialog.inputValue ?? ""; const clean = { ...dialog }; delete clean.error; this.setState({ authDialog: { ...clean, responding: true } }); try { - const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue, selectedMachineId(this.getState())); + const flow = await this.api.respondOAuthFlow(flowId, requestId, responseValue, selectedMachineId(this.getState())); + const current = this.currentOAuthDialog(operationGeneration, flowId); + if (flow.flowId !== flowId || current === undefined || oauthRequestId(current.flow) !== requestId) return; this.updateOAuthFlow(flow); } catch (error) { - this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } }); + const current = this.currentOAuthDialog(operationGeneration, flowId); + if (current === undefined || oauthRequestId(current.flow) !== requestId) return; + this.setState({ authDialog: { ...current, responding: false, error: String(error) } }); } } @@ -146,16 +159,18 @@ export class AuthController { this.closeDialog(); return; } - this.stopPolling(); - try { - await this.api.cancelOAuthFlow(dialog.flow.flowId, selectedMachineId(this.getState())); - } catch { - // Best-effort cancel. The dialog closes either way. - } + const flowId = dialog.flow.flowId; + const machineId = selectedMachineId(this.getState()); this.closeDialog(); + try { + await this.api.cancelOAuthFlow(flowId, machineId); + } catch { + // Best-effort cancel. The dialog is already closed either way. + } } closeDialog(): void { + this.oauthOperationGeneration += 1; this.stopPolling(); this.setState({ authDialog: undefined }); } @@ -183,12 +198,15 @@ export class AuthController { private async startOAuth(provider: AuthProviderOption): Promise { if (this.rejectRemoteOAuth("login", provider)) return; + const operationGeneration = ++this.oauthOperationGeneration; + this.stopPolling(); try { const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState())); + if (operationGeneration !== this.oauthOperationGeneration) return; this.updateOAuthFlow(flow); - this.startPolling(flow.flowId); + if (flow.status === "running") this.startPolling(flow.flowId); } catch (error) { - this.setState({ error: String(error) }); + if (operationGeneration === this.oauthOperationGeneration) this.setState({ error: String(error) }); } } @@ -207,11 +225,14 @@ export class AuthController { void this.refreshStatus(); return; } - if (flow.status === "error" || flow.status === "cancelled") this.stopPolling(); + if (flow.status === "error" || flow.status === "cancelled") { + this.oauthOperationGeneration += 1; + this.stopPolling(); + } const existing = this.getState().authDialog; const previousInput = existing?.step === "oauth" && existing.flow.flowId === flow.flowId ? existing.inputValue ?? "" : ""; - const previousRequestId = existing?.step === "oauth" ? existing.flow.prompt?.requestId ?? existing.flow.select?.requestId : undefined; - const newRequestId = flow.prompt?.requestId ?? flow.select?.requestId; + const previousRequestId = existing?.step === "oauth" ? oauthRequestId(existing.flow) : undefined; + const newRequestId = oauthRequestId(flow); const sameRequest = previousRequestId !== undefined && previousRequestId === newRequestId; const inputValue = sameRequest ? previousInput : ""; const responding = sameRequest && existing?.step === "oauth" ? existing.responding === true : false; @@ -220,29 +241,45 @@ export class AuthController { private startPolling(flowId: string): void { this.stopPolling(); - this.pollTimer = window.setInterval(() => { void this.poll(flowId); }, this.pollIntervalMs); + const operationGeneration = this.oauthOperationGeneration; + const pollGeneration = this.pollGeneration; + this.pollTimer = window.setInterval(() => { void this.poll(flowId, operationGeneration, pollGeneration); }, this.pollIntervalMs); } private stopPolling(): void { + this.pollGeneration += 1; if (this.pollTimer === undefined) return; window.clearInterval(this.pollTimer); this.pollTimer = undefined; } - private async poll(flowId: string): Promise { - const dialog = this.getState().authDialog; - if (dialog?.step !== "oauth" || dialog.flow.flowId !== flowId) { + private async poll(flowId: string, operationGeneration: number, pollGeneration: number): Promise { + if (pollGeneration !== this.pollGeneration) return; + const dialog = this.currentOAuthDialog(operationGeneration, flowId); + if (dialog === undefined) { this.stopPolling(); return; } + const requestId = oauthRequestId(dialog.flow); try { - this.updateOAuthFlow(await this.api.oauthFlow(flowId, selectedMachineId(this.getState()))); + const flow = await this.api.oauthFlow(flowId, selectedMachineId(this.getState())); + const current = this.currentOAuthDialog(operationGeneration, flowId); + if (flow.flowId !== flowId || pollGeneration !== this.pollGeneration || current === undefined || oauthRequestId(current.flow) !== requestId) return; + this.updateOAuthFlow(flow); } catch (error) { + const current = this.currentOAuthDialog(operationGeneration, flowId); + if (pollGeneration !== this.pollGeneration || current === undefined || oauthRequestId(current.flow) !== requestId) return; this.stopPolling(); - this.setState({ authDialog: { ...dialog, error: String(error) } }); + this.setState({ authDialog: { ...current, error: String(error) } }); } } + private currentOAuthDialog(operationGeneration: number, flowId: string): OAuthDialogState | undefined { + if (operationGeneration !== this.oauthOperationGeneration) return undefined; + const dialog = this.getState().authDialog; + return dialog?.step === "oauth" && dialog.flow.flowId === flowId ? dialog : undefined; + } + private async refreshStatus(): Promise { const session = this.session(); if (session === undefined) return; @@ -260,6 +297,10 @@ export class AuthController { } } +function oauthRequestId(flow: OAuthFlowState): string | undefined { + return flow.prompt?.requestId ?? flow.select?.requestId; +} + export function parseAuthSlashCommand(text: string): { command: "login" | "logout"; providerId?: string } | undefined { const trimmed = text.trim(); const match = /^\/(login|logout)(?:\s+(\S+))?\s*$/u.exec(trimmed); From c569a03f548a3cf5ec498d71a38565bd19e3e802 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 18 Jul 2026 08:28:03 +0200 Subject: [PATCH 24/26] fix(auth): make API-key setup and status truthful --- .changeset/fix-pi-0-80-8-modelruntime-auth.md | 2 +- src/client/src/api/clients.ts | 1 + .../src/api/federatedRouteContract.test.ts | 1 + src/client/src/api/parsers.test.ts | 11 +- src/client/src/api/parsers.ts | 10 +- src/client/src/components/AuthDialog.ts | 6 +- .../src/controllers/authController.test.ts | 28 ++++ src/client/src/controllers/authController.ts | 11 +- .../sessions/authProviderOptions.test.ts | 31 ++++- src/server/sessions/authProviderOptions.ts | 15 +- src/server/sessions/authRoutes.ts | 10 ++ src/server/sessions/authService.test.ts | 129 +++++++++++++++++- src/server/sessions/authService.ts | 12 ++ .../sessions/oauthLoginFlowService.test.ts | 49 +++++-- src/server/sessions/oauthLoginFlowService.ts | 26 ++-- src/shared/apiTypes.ts | 2 + src/shared/federatedRoutes.ts | 1 + 17 files changed, 300 insertions(+), 45 deletions(-) diff --git a/.changeset/fix-pi-0-80-8-modelruntime-auth.md b/.changeset/fix-pi-0-80-8-modelruntime-auth.md index 822b379..24951dd 100644 --- a/.changeset/fix-pi-0-80-8-modelruntime-auth.md +++ b/.changeset/fix-pi-0-80-8-modelruntime-auth.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Login options now follow each provider's interactive API-key and OAuth capabilities, OAuth prompts retain their input, selection, and device-code semantics, committed OAuth login remains truthful when cancellation races the final refresh, and unsupported multi-step API-key setup fails safely instead of storing malformed credentials. PI WEB now requires Node.js `>=22.19.0`. +Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Login options now follow each provider's interactive API-key and OAuth capabilities, provider-driven API-key setup supports multi-step prompts while legacy one-secret clients still fail safely before storing malformed credentials, OAuth prompts retain their input, selection, and device-code semantics, and committed login remains truthful when cancellation races the final refresh. PI WEB now requires Node.js `>=22.19.0`. diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 7bf96e6..9bdc898 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -241,6 +241,7 @@ export const sessionsApi = { return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse); }, saveApiKey: (providerId: string, key: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }), + startInteractiveApiKeyLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key/interactive`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }), logoutProvider: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/logout`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }), startOAuthLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }), oauthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState), diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index d8fa3c7..748bcf0 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -88,6 +88,7 @@ describe("federated route contract", () => { ignoreParseFailure(sessionsApi.detachParent(session, machineId)), ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })), ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)), + ignoreParseFailure(sessionsApi.startInteractiveApiKeyLogin("amazon-bedrock", machineId)), ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)), ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)), ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)), diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 6020eb5..cf60131 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,8 +1,17 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; +import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { + it("preserves additive interactive API-key flow hints and defaults legacy options", () => { + const base = { id: "openai", name: "OpenAI", authType: "api_key", status: { configured: false } }; + + expect(parseAuthProvidersResponse({ providers: [{ ...base, loginFlow: "interactive" }, base] }).providers).toEqual([ + { ...base, loginFlow: "interactive" }, + base, + ]); + }); + it("preserves additive OAuth interaction semantics", () => { expect(parseOAuthFlowState({ flowId: "flow-1", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index d97aa17..1ed5ba8 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -369,7 +369,15 @@ function parseAuthProviderStatus(value: unknown): AuthProviderStatus { function parseAuthProviderOption(value: unknown): AuthProviderOption { const record = requireRecord(value); - return { id: requireString(record, "id"), name: requireString(record, "name"), authType: parseAuthType(record["authType"]), status: parseAuthProviderStatus(record["status"]) }; + const loginFlow = record["loginFlow"]; + if (loginFlow !== undefined && loginFlow !== "interactive") throw new Error("Invalid auth provider login flow"); + return { + id: requireString(record, "id"), + name: requireString(record, "name"), + authType: parseAuthType(record["authType"]), + status: parseAuthProviderStatus(record["status"]), + ...(loginFlow === undefined ? {} : { loginFlow }), + }; } export function parseAuthProvidersResponse(value: unknown): AuthProvidersResponse { diff --git a/src/client/src/components/AuthDialog.ts b/src/client/src/components/AuthDialog.ts index 7663a55..900f90f 100644 --- a/src/client/src/components/AuthDialog.ts +++ b/src/client/src/components/AuthDialog.ts @@ -42,7 +42,7 @@ export class AuthDialog extends LitElement { private dialogTitle(state: AuthDialogState): string { switch (state.step) { case "method": return "Configure provider authentication"; - case "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select API key provider"; + case "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select credential provider"; case "apiKey": return `API key for ${state.provider.name}`; case "oauth": return `Login to ${state.flow.providerName}`; case "logout": return "Remove stored provider authentication"; @@ -54,7 +54,7 @@ export class AuthDialog extends LitElement { case "method": return html`
    - +
    `; case "providers": return html`
    ${state.providers.length === 0 ? html`
    No providers available.
    ` : state.providers.map((provider) => this.renderProviderButton(provider))}
    `; @@ -183,7 +183,7 @@ export function oauthPromptInputType(promptType: NonNullable { expect(getState().authDialog).toMatchObject({ step: "apiKey", provider: { id: "anthropic", authType: "api_key" } }); }); + it("starts provider-driven API-key interactions instead of opening the legacy one-secret form", async () => { + vi.stubGlobal("window", { setInterval: () => 1, clearInterval: () => undefined }); + const provider: AuthProviderOption = { ...authProvider("amazon-bedrock", "api_key"), loginFlow: "interactive" }; + const calls: { providerId: string; machineId: string | undefined }[] = []; + const { controller, getState } = createController( + { authDialog: { step: "providers", mode: "login", authType: "api_key", providers: [provider] } }, + { + startInteractiveApiKeyLogin: (providerId, machineId) => { + calls.push({ providerId, machineId }); + return Promise.resolve(oauthFlow({ providerId, providerName: "Amazon Bedrock", select: { requestId: "request-1", message: "Choose method", options: [] } })); + }, + }, + ); + + try { + await controller.selectLoginProvider(provider.id, "api_key"); + + expect(calls).toEqual([{ providerId: "amazon-bedrock", machineId: "local" }]); + expect(getState().authDialog).toMatchObject({ + step: "oauth", + flow: { providerId: "amazon-bedrock", select: { requestId: "request-1" } }, + }); + } finally { + controller.dispose(); + vi.unstubAllGlobals(); + } + }); + it("keeps OAuth prompt input and submit state across poll refreshes for the same request", async () => { const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); const { controller, getState } = createController( diff --git a/src/client/src/controllers/authController.ts b/src/client/src/controllers/authController.ts index b9df1c9..d9e2ba6 100644 --- a/src/client/src/controllers/authController.ts +++ b/src/client/src/controllers/authController.ts @@ -61,7 +61,7 @@ export class AuthController { if (dialog?.step !== "providers") return; const provider = dialog.providers.find((candidate) => candidate.id === providerId && (authType === undefined || candidate.authType === authType)); if (provider === undefined) return; - if (provider.authType === "oauth") await this.startOAuth(provider); + if (provider.authType === "oauth" || provider.loginFlow === "interactive") await this.startLoginFlow(provider); else this.setState({ authDialog: { step: "apiKey", provider, value: "" } }); } @@ -189,19 +189,22 @@ export class AuthController { } const provider = exact[0]; if (provider === undefined) return; - if (provider.authType === "oauth") await this.startOAuth(provider); + if (provider.authType === "oauth" || provider.loginFlow === "interactive") await this.startLoginFlow(provider); else this.setState({ authDialog: { step: "apiKey", provider, value: "" } }); } catch (error) { this.setState({ error: String(error) }); } } - private async startOAuth(provider: AuthProviderOption): Promise { + private async startLoginFlow(provider: AuthProviderOption): Promise { if (this.rejectRemoteOAuth("login", provider)) return; const operationGeneration = ++this.oauthOperationGeneration; this.stopPolling(); try { - const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState())); + const machineId = selectedMachineId(this.getState()); + const flow = provider.authType === "oauth" + ? await this.api.startOAuthLogin(provider.id, machineId) + : await this.api.startInteractiveApiKeyLogin(provider.id, machineId); if (operationGeneration !== this.oauthOperationGeneration) return; this.updateOAuthFlow(flow); if (flow.status === "running") this.startPolling(flow.flowId); diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts index b7d5112..778403d 100644 --- a/src/server/sessions/authProviderOptions.test.ts +++ b/src/server/sessions/authProviderOptions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRuntime } from "./authProviderOptions"; -function runtime(): AuthProviderRuntime { +function runtime(configuredProviders: ReadonlySet = new Set(["openai"])): AuthProviderRuntime { const credentials = [{ providerId: "openai", type: "api_key" as const }]; // Auth shapes mirror what the Pi SDK actually reports for these providers: // github-copilot supports both methods, openai-codex is OAuth-only, and @@ -12,12 +12,17 @@ function runtime(): AuthProviderRuntime { { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", auth: { oauth: {} } }, { id: "openai", name: "OpenAI", auth: { apiKey: { login: () => undefined } } }, { id: "custom", name: "Custom", auth: { apiKey: { login: () => undefined } } }, + { id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway", auth: { apiKey: { login: () => undefined } } }, + { id: "cloudflare-workers-ai", name: "Cloudflare Workers AI", auth: { apiKey: { login: () => undefined } } }, + { id: "amazon-bedrock", name: "Amazon Bedrock", auth: { apiKey: { login: () => undefined } } }, + { id: "google-vertex", name: "Google Vertex AI", auth: { apiKey: { login: () => undefined } } }, { id: "ambient", name: "Ambient credentials", auth: { apiKey: {} } }, ]; return { getProviders: () => providers, listCredentials: () => Promise.resolve(credentials), getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }), + hasConfiguredAuth: (provider: string) => configuredProviders.has(provider), }; } @@ -32,18 +37,34 @@ describe("auth provider options", () => { expect.objectContaining({ id: "github-copilot", authType: "api_key" }), // OAuth-only provider surfaces only oauth. expect.objectContaining({ id: "openai-codex", authType: "oauth" }), - // API-key-only providers surface only api_key. - expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }), - expect.objectContaining({ id: "custom", authType: "api_key" }), + // API-key options use the generic AuthInteraction flow, including + // multi-field and select-first providers the legacy form cannot execute. + expect.objectContaining({ id: "openai", authType: "api_key", loginFlow: "interactive", status: { configured: true, source: "stored" } }), + expect.objectContaining({ id: "custom", authType: "api_key", loginFlow: "interactive" }), + expect.objectContaining({ id: "cloudflare-ai-gateway", authType: "api_key", loginFlow: "interactive" }), + expect.objectContaining({ id: "cloudflare-workers-ai", authType: "api_key", loginFlow: "interactive" }), + expect.objectContaining({ id: "amazon-bedrock", authType: "api_key", loginFlow: "interactive" }), + expect.objectContaining({ id: "google-vertex", authType: "api_key", loginFlow: "interactive" }), ])); expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })])); expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai", authType: "oauth" })])); expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "ambient", authType: "api_key" })])); }); + it("does not report a stored credential as configured when provider resolution is incomplete", async () => { + const unresolvedRuntime = runtime(new Set()); + + expect(getLoginProviderOptions(unresolvedRuntime, "api_key")).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "openai", status: { configured: false } }), + ])); + expect(await getLogoutProviderOptions(unresolvedRuntime)).toEqual([ + expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: false } }), + ]); + }); + it("returns only currently stored credentials for logout", async () => { expect(await getLogoutProviderOptions(runtime())).toEqual([ - expect.objectContaining({ id: "openai", authType: "api_key" }), + expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }), ]); }); }); diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts index 529ae90..f188413 100644 --- a/src/server/sessions/authProviderOptions.ts +++ b/src/server/sessions/authProviderOptions.ts @@ -23,6 +23,7 @@ export interface AuthProviderRuntime { getProviders(): readonly AuthProviderInfo[]; listCredentials(): Promise; getProviderAuthStatus(providerId: string): AuthProviderStatus; + hasConfiguredAuth(providerId: string): boolean; } export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] { @@ -35,7 +36,7 @@ export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: id: provider.id, name: provider.name, authType: "oauth", - status: runtime.getProviderAuthStatus(provider.id), + status: truthfulProviderStatus(runtime, provider.id), }); } @@ -45,7 +46,8 @@ export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: id: provider.id, name: provider.name, authType: "api_key", - status: runtime.getProviderAuthStatus(provider.id), + status: truthfulProviderStatus(runtime, provider.id), + loginFlow: "interactive", }); } @@ -60,12 +62,19 @@ export async function getLogoutProviderOptions(runtime: AuthProviderRuntime): Pr id: credential.providerId, name: providerNames.get(credential.providerId) ?? credential.providerId, authType: credential.type, - status: runtime.getProviderAuthStatus(credential.providerId), + status: truthfulProviderStatus(runtime, credential.providerId), }); } return filterAndSort(options); } +function truthfulProviderStatus(runtime: AuthProviderRuntime, providerId: string): AuthProviderStatus { + const reported = runtime.getProviderAuthStatus(providerId); + // ModelRuntime reports any stored entry as configured before checking whether + // the provider can resolve all required credential and ambient fields. + return reported.configured && !runtime.hasConfiguredAuth(providerId) ? { configured: false } : reported; +} + function filterAndSort(options: AuthProviderOption[], authType?: AuthType): AuthProviderOption[] { const filtered = authType === undefined ? options : options.filter((option) => option.authType === authType); return filtered.sort((a, b) => a.name.localeCompare(b.name) || a.authType.localeCompare(b.authType) || a.id.localeCompare(b.id)); diff --git a/src/server/sessions/authRoutes.ts b/src/server/sessions/authRoutes.ts index a8f516c..70089af 100644 --- a/src/server/sessions/authRoutes.ts +++ b/src/server/sessions/authRoutes.ts @@ -18,6 +18,16 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref } }); + // Additive endpoint for newer browsers; the one-secret route remains for + // rolling compatibility with older browser bundles. + app.post<{ Body: { providerId: string } }>(`${prefix}/auth/api-key/interactive`, async (request, reply) => { + try { + return await auth.startApiKeyLogin(request.body.providerId); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => { try { return await auth.logoutProvider(request.body.providerId); diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index d3fb5ae..e427e6c 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -11,6 +11,7 @@ import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; const tempDirs: string[] = []; afterEach(async () => { + vi.unstubAllEnvs(); await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); @@ -98,14 +99,22 @@ describe("AuthService", () => { auth.dispose(); }); - it("rejects Cloudflare multi-field setup without storing the secret as provider metadata", async () => { - const { auth, credentials, changes } = await createAuthService(); + it("keeps existing file-backed credentials unchanged when legacy Cloudflare setup cannot finish", async () => { + const seed = { + "cloudflare-ai-gateway": { + type: "api_key" as const, + key: "existing-secret", + env: { CLOUDFLARE_ACCOUNT_ID: "existing-account", CLOUDFLARE_GATEWAY_ID: "existing-gateway" }, + }, + }; + const { auth, authPath, changes } = await createFileBackedAuthService(seed); + const before = await readFile(authPath, "utf8"); - await expect(auth.saveApiKey("cloudflare-ai-gateway", "cf-secret")).rejects.toThrow( + await expect(auth.saveApiKey("cloudflare-ai-gateway", "new-secret")).rejects.toThrow( "Cloudflare AI Gateway requires interactive setup; use Pi's generic /login flow", ); - await expect(credentials.read("cloudflare-ai-gateway")).resolves.toBeUndefined(); + await expect(readFile(authPath, "utf8")).resolves.toBe(before); expect(changes).toEqual([]); auth.dispose(); }); @@ -113,18 +122,113 @@ describe("AuthService", () => { it.each([ { providerId: "amazon-bedrock", providerName: "Amazon Bedrock" }, { providerId: "google-vertex", providerName: "Google Vertex AI" }, - ])("rejects $providerName select-first setup without storing the secret", async ({ providerId, providerName }) => { - const { auth, credentials, changes } = await createAuthService(); + ])("keeps an empty file-backed store unchanged when legacy $providerName setup starts with a selection", async ({ providerId, providerName }) => { + const { auth, authPath, changes } = await createFileBackedAuthService({}); + const before = await readFile(authPath, "utf8"); await expect(auth.saveApiKey(providerId, "submitted-secret")).rejects.toThrow( `${providerName} requires interactive setup; use Pi's generic /login flow`, ); - await expect(credentials.read(providerId)).resolves.toBeUndefined(); + await expect(readFile(authPath, "utf8")).resolves.toBe(before); expect(changes).toEqual([]); auth.dispose(); }); + it("executes Cloudflare multi-field API-key setup through the interactive flow", async () => { + const { auth, credentials, changes } = await createAuthService(); + + const state = await auth.startApiKeyLogin("cloudflare-ai-gateway"); + expect(state.prompt).toMatchObject({ message: "Enter Cloudflare API key", promptType: "secret" }); + if (state.prompt === undefined) throw new Error("Expected Cloudflare key prompt"); + auth.respondToOAuthFlow(state.flowId, state.prompt.requestId, "cf-secret"); + + await vi.waitFor(() => { + expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: "Enter Cloudflare account ID", promptType: "text" }); + }); + const accountPrompt = auth.oauthFlow(state.flowId).prompt; + if (accountPrompt === undefined) throw new Error("Expected Cloudflare account prompt"); + auth.respondToOAuthFlow(state.flowId, accountPrompt.requestId, "account-1"); + + await vi.waitFor(() => { + expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: "Enter Cloudflare AI Gateway ID", promptType: "text" }); + }); + const gatewayPrompt = auth.oauthFlow(state.flowId).prompt; + if (gatewayPrompt === undefined) throw new Error("Expected Cloudflare gateway prompt"); + auth.respondToOAuthFlow(state.flowId, gatewayPrompt.requestId, "gateway-1"); + + await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); }); + await expect(credentials.read("cloudflare-ai-gateway")).resolves.toEqual({ + type: "api_key", + key: "cf-secret", + env: { CLOUDFLARE_ACCOUNT_ID: "account-1", CLOUDFLARE_GATEWAY_ID: "gateway-1" }, + }); + expect(changes).toEqual([{}]); + auth.dispose(); + }); + + it.each([ + { providerId: "amazon-bedrock", selection: "bearer-token", secretPrompt: "Enter Amazon Bedrock bearer token" }, + { providerId: "google-vertex", selection: "api-key", secretPrompt: "Enter Google Cloud API key" }, + ])("executes $providerId select-first API-key setup through the interactive flow", async ({ providerId, selection, secretPrompt }) => { + const { auth, credentials, changes } = await createAuthService(); + + const state = await auth.startApiKeyLogin(providerId); + expect(state.select).toBeDefined(); + if (state.select === undefined) throw new Error("Expected auth method selection"); + auth.respondToOAuthFlow(state.flowId, state.select.requestId, selection); + + await vi.waitFor(() => { + expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: secretPrompt, promptType: "secret" }); + }); + const prompt = auth.oauthFlow(state.flowId).prompt; + if (prompt === undefined) throw new Error("Expected provider secret prompt"); + auth.respondToOAuthFlow(state.flowId, prompt.requestId, "provider-secret"); + + await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); }); + await expect(credentials.read(providerId)).resolves.toEqual({ type: "api_key", key: "provider-secret" }); + expect(changes).toEqual([{}]); + auth.dispose(); + }); + + it("reports a key-only legacy Cloudflare credential as unconfigured", async () => { + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", ""); + vi.stubEnv("CLOUDFLARE_GATEWAY_ID", ""); + const { auth } = await createFileBackedAuthService({ + "cloudflare-ai-gateway": { type: "api_key", key: "legacy-secret" }, + }); + + const response = await auth.authProviders("login", "api_key"); + + expect(response.providers).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "cloudflare-ai-gateway", + loginFlow: "interactive", + status: { configured: false }, + }), + ])); + auth.dispose(); + }); + + it("reports a stored Cloudflare key as configured when ambient fields complete it", async () => { + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "ambient-account"); + vi.stubEnv("CLOUDFLARE_GATEWAY_ID", "ambient-gateway"); + const { auth } = await createFileBackedAuthService({ + "cloudflare-ai-gateway": { type: "api_key", key: "legacy-secret" }, + }); + + const response = await auth.authProviders("login", "api_key"); + + expect(response.providers).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "cloudflare-ai-gateway", + loginFlow: "interactive", + status: { configured: true, source: "stored" }, + }), + ])); + auth.dispose(); + }); + it.each([ { label: "text", prompt: { type: "text", message: "Account" } satisfies AuthPrompt }, { @@ -372,6 +476,17 @@ async function createAuthService(seed: Record = {}, logger?: return { auth, runtime, credentials, changes }; } +async function createFileBackedAuthService(seed: Record) { + const agentDir = await tempAgentDir(); + const authPath = join(agentDir, "auth.json"); + await writeFile(authPath, JSON.stringify(seed, null, 2)); + const runtime = await createModelRuntimeForAgentDir(agentDir, false); + const auth = await AuthService.create({ runtime }); + const changes: AuthChange[] = []; + auth.subscribe((change) => { changes.push(change); }); + return { auth, runtime, authPath, changes }; +} + function mockLoginPromptsBeforePersistence( runtime: ModelRuntime, credentials: InMemoryCredentialStore, diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index 4e26c04..5a237ff 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -105,12 +105,24 @@ export class AuthService { return { accepted: true }; } + async startApiKeyLogin(providerId: string): Promise { + const provider = await this.requireApiKeyLoginProvider(providerId); + return this.authFlows.start({ + providerId, + providerName: provider.name, + runtime: this.runtime, + authType: "api_key", + onComplete: () => this.emit({}, { operation: "login", providerId, authType: "api_key" }), + }); + } + async startOAuthLogin(providerId: string): Promise { const provider = await this.requireOAuthLoginProvider(providerId); return this.authFlows.start({ providerId, providerName: provider.name, runtime: this.runtime, + authType: "oauth", onComplete: () => this.emit({}, { operation: "login", providerId, authType: "oauth" }), }); } diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts index 90c0500..713d743 100644 --- a/src/server/sessions/oauthLoginFlowService.test.ts +++ b/src/server/sessions/oauthLoginFlowService.test.ts @@ -1,4 +1,4 @@ -import type { AuthInteraction } from "@earendil-works/pi-ai"; +import type { AuthInteraction, AuthType } from "@earendil-works/pi-ai"; import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, it, vi } from "vitest"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; @@ -41,6 +41,30 @@ describe("OAuthLoginFlowService", () => { service.dispose(); }); + it("runs API-key login through the same AuthInteraction transport", async () => { + const authTypes: AuthType[] = []; + let key: string | undefined; + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + runtime: fakeRuntime(async (_providerId, interaction) => { + key = await interaction.prompt({ type: "secret", message: "Enter API key" }); + }, authTypes), + authType: "api_key", + }); + + const prompt = state.prompt; + if (prompt === undefined) throw new Error("Expected API-key prompt"); + service.respond(state.flowId, prompt.requestId, "sk-test"); + await flushAsyncLogin(); + + expect(authTypes).toEqual(["api_key"]); + expect(key).toBe("sk-test"); + expect(service.get(state.flowId).status).toBe("complete"); + service.dispose(); + }); + it("awaits async completion propagation before marking the flow complete", async () => { const completion = deferred(); const service = new OAuthLoginFlowService(); @@ -79,7 +103,7 @@ describe("OAuthLoginFlowService", () => { expect(onComplete).toHaveBeenCalledOnce(); expect(error).toHaveBeenCalledWith( { err: completionFailure, flowId: state.flowId, providerId: "test-provider" }, - "OAuth login completion callback failed", + "login completion callback failed", ); service.dispose(); }); @@ -227,7 +251,7 @@ describe("OAuthLoginFlowService", () => { const select = state.select; if (select === undefined) throw new Error("Expected select prompt"); - expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid OAuth selection"); + expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid login selection"); expect(service.get(state.flowId).select).toEqual(select); service.dispose(); }); @@ -338,7 +362,7 @@ describe("OAuthLoginFlowService", () => { service.dispose(); await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" }); - expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found"); + expect(() => { service.get(state.flowId); }).toThrow("Login flow not found"); }); it("rejects stale or duplicate responses", () => { @@ -355,7 +379,7 @@ describe("OAuthLoginFlowService", () => { if (prompt === undefined) throw new Error("Expected prompt"); service.respond(state.flowId, prompt.requestId, "abc123"); - expect(() => { service.respond(state.flowId, prompt.requestId, "abc123"); }).toThrow("OAuth login request expired"); + expect(() => { service.respond(state.flowId, prompt.requestId, "abc123"); }).toThrow("Login request expired"); service.dispose(); }); @@ -378,19 +402,24 @@ describe("OAuthLoginFlowService", () => { await vi.advanceTimersByTimeAsync(1000); - expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "OAuth login flow expired" }); - await expect(promptRejected.promise).resolves.toMatchObject({ message: "OAuth login flow expired" }); + expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "Login flow expired" }); + await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login flow expired" }); await vi.advanceTimersByTimeAsync(1000); - expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found"); + expect(() => { service.get(state.flowId); }).toThrow("Login flow not found"); service.dispose(); }); }); -function fakeRuntime(login: LoginHandler): Pick { +function fakeRuntime(login: LoginHandler, authTypes?: AuthType[]): Pick { return { - login: (providerId, _type, interaction) => login(providerId, interaction).then(() => ({ type: "oauth", refresh: "r", access: "a", expires: 0 })), + login: (providerId, type, interaction) => { + authTypes?.push(type); + return login(providerId, interaction).then(() => type === "api_key" + ? { type: "api_key", key: "test" } + : { type: "oauth", refresh: "r", access: "a", expires: 0 }); + }, }; } diff --git a/src/server/sessions/oauthLoginFlowService.ts b/src/server/sessions/oauthLoginFlowService.ts index d38cf18..2e15130 100644 --- a/src/server/sessions/oauthLoginFlowService.ts +++ b/src/server/sessions/oauthLoginFlowService.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import type { AuthEvent, AuthInteraction, AuthPrompt } from "@earendil-works/pi-ai"; +import type { AuthEvent, AuthInteraction, AuthPrompt, AuthType } from "@earendil-works/pi-ai"; import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js"; @@ -42,6 +42,10 @@ const DEFAULT_TERMINAL_TTL_MS = 5 * 60 * 1000; const DEFAULT_RUNNING_TTL_MS = 30 * 60 * 1000; const noopLogger: OAuthLoginFlowLogger = { error() { /* no-op */ } }; +/** + * AuthInteraction transport shared by OAuth and provider-driven API-key login. + * The historical class and wire names remain for rolling browser/sessiond compatibility. + */ export class OAuthLoginFlowService { private readonly flows = new Map(); private readonly terminalTtlMs: number; @@ -60,6 +64,8 @@ export class OAuthLoginFlowService { providerId: string; providerName: string; runtime: OAuthLoginRuntime; + /** Defaults to OAuth so established callers retain their existing behavior. */ + authType?: AuthType; onComplete?: () => void | Promise; }): OAuthFlowState { const flowId = crypto.randomUUID(); @@ -88,7 +94,7 @@ export class OAuthLoginFlowService { notify: (event) => { this.handleEvent(record, event); }, }; - void options.runtime.login(options.providerId, "oauth", interaction).then( + void options.runtime.login(options.providerId, options.authType ?? "oauth", interaction).then( () => this.reconcileCommittedLogin(record, options.onComplete), (error: unknown) => { if (!this.isCurrent(record)) return; @@ -103,18 +109,18 @@ export class OAuthLoginFlowService { get(flowId: string): OAuthFlowState { const record = this.flows.get(flowId); - if (record === undefined) throw new Error("OAuth login flow not found"); + if (record === undefined) throw new Error("Login flow not found"); return cloneState(record.state); } respond(flowId: string, requestId: string, value: string): OAuthFlowState { const record = this.flows.get(flowId); - if (record === undefined) throw new Error("OAuth login flow not found"); + if (record === undefined) throw new Error("Login flow not found"); if (record.state.status !== "running") return cloneState(record.state); const pending = record.pending; - if (pending?.requestId !== requestId) throw new Error("OAuth login request expired"); + if (pending?.requestId !== requestId) throw new Error("Login request expired"); if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required"); - if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid OAuth selection"); + if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid login selection"); this.clearPending(record); this.updateState(record, withoutInteraction(record.state)); pending.resolve(value); @@ -123,7 +129,7 @@ export class OAuthLoginFlowService { cancel(flowId: string): OAuthFlowState { const record = this.flows.get(flowId); - if (record === undefined) throw new Error("OAuth login flow not found"); + if (record === undefined) throw new Error("Login flow not found"); if (record.state.status === "running") { record.abort.abort(); const pending = this.clearPending(record); @@ -287,7 +293,7 @@ export class OAuthLoginFlowService { } catch (error) { this.logErrorNoThrow( { err: error, flowId: record.flowId, providerId: record.state.providerId }, - "OAuth login completion callback failed", + "login completion callback failed", ); } if (!this.isCurrent(record)) return; @@ -352,8 +358,8 @@ export class OAuthLoginFlowService { if (!this.isCurrentRunning(record)) return; record.abort.abort(); const pending = this.clearPending(record); - this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" }); - pending?.reject(new Error("OAuth login flow expired")); + this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "Login flow expired" }); + pending?.reject(new Error("Login flow expired")); } private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void { diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 78666d4..d687d43 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -373,6 +373,8 @@ export interface AuthProviderOption { name: string; authType: AuthType; status: AuthProviderStatus; + /** Additive hint: use the generic AuthInteraction transport instead of the legacy one-secret form. */ + loginFlow?: "interactive"; } export interface AuthProvidersResponse { diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts index 8a4558f..6614c79 100644 --- a/src/shared/federatedRoutes.ts +++ b/src/shared/federatedRoutes.ts @@ -76,6 +76,7 @@ export const FEDERATED_HTTP_ROUTES = [ { method: "POST", path: "/sessions/:sessionId/detach-parent" }, { method: "GET", path: "/auth/providers" }, { method: "POST", path: "/auth/api-key" }, + { method: "POST", path: "/auth/api-key/interactive" }, { method: "POST", path: "/auth/logout" }, { method: "POST", path: "/auth/oauth" }, { method: "GET", path: "/auth/oauth/:flowId" }, From 65350fd1b57d5d4e0c5c4c086fb034164b2f5ca1 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 18 Jul 2026 08:33:51 +0200 Subject: [PATCH 25/26] fix(realtime): terminate failed sockets --- src/server/realtime/sessionEventHub.test.ts | 10 ++++++++-- src/server/realtime/sessionEventHub.ts | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/server/realtime/sessionEventHub.test.ts b/src/server/realtime/sessionEventHub.test.ts index 08641b9..c397dec 100644 --- a/src/server/realtime/sessionEventHub.test.ts +++ b/src/server/realtime/sessionEventHub.test.ts @@ -6,6 +6,7 @@ class FakeSocket extends EventEmitter implements RealtimeSocket { readonly OPEN = 1; readyState = this.OPEN; send = vi.fn(); + terminate = vi.fn(); } describe("SessionEventHub", () => { @@ -54,7 +55,7 @@ describe("SessionEventHub", () => { expect(removed.send).not.toHaveBeenCalled(); }); - it("continues publishing session events when one socket send fails", () => { + it("terminates a failed session socket without disrupting healthy delivery or sequence watermarks", () => { const hub = new SessionEventHub(); const failed = new FakeSocket(); const healthy = new FakeSocket(); @@ -65,6 +66,7 @@ describe("SessionEventHub", () => { hub.publish("s1", { type: "assistant.delta", text: "hello" }); expect(failed.send).toHaveBeenCalledOnce(); + expect(failed.terminate).toHaveBeenCalledOnce(); expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "assistant.delta", text: "hello", seq: 1 })); expect(hub.currentSeq("s1")).toBe(1); @@ -72,6 +74,7 @@ describe("SessionEventHub", () => { hub.publish("s1", { type: "assistant.delta", text: "again" }); expect(failed.send).not.toHaveBeenCalled(); + expect(failed.terminate).toHaveBeenCalledOnce(); expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "assistant.delta", text: "again", seq: 2 })); expect(hub.currentSeq("s1")).toBe(2); }); @@ -100,23 +103,26 @@ describe("SessionEventHub", () => { expect(sessionSocket.send).not.toHaveBeenCalled(); }); - it("continues publishing unstamped global events when one socket send fails", () => { + it("contains termination failures while publishing unstamped global events", () => { const hub = new SessionEventHub(); const failed = new FakeSocket(); const healthy = new FakeSocket(); failed.send.mockImplementation(() => { throw new Error("socket closed"); }); + failed.terminate.mockImplementation(() => { throw new Error("termination failed"); }); hub.addGlobal(failed); hub.addGlobal(healthy); hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed" }); expect(failed.send).toHaveBeenCalledOnce(); + expect(failed.terminate).toHaveBeenCalledOnce(); expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed" })); failed.send.mockClear(); hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed again" }); expect(failed.send).not.toHaveBeenCalled(); + expect(failed.terminate).toHaveBeenCalledOnce(); expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed again" })); }); diff --git a/src/server/realtime/sessionEventHub.ts b/src/server/realtime/sessionEventHub.ts index 9df8a1a..2cbb39f 100644 --- a/src/server/realtime/sessionEventHub.ts +++ b/src/server/realtime/sessionEventHub.ts @@ -5,6 +5,7 @@ export interface RealtimeSocket { readonly OPEN: number; readyState: number; send(payload: string): void; + terminate(): void; on(event: "close", listener: () => void): unknown; } @@ -64,6 +65,11 @@ export class SessionEventHub { socket.send(payload); } catch { sockets.delete(socket); + try { + socket.terminate(); + } catch { + // Removal is authoritative; cleanup failure must not block healthy sockets. + } } } } From 5ebcd346907230e02d2f827e2bf3b089e056e655 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 18 Jul 2026 08:40:12 +0200 Subject: [PATCH 26/26] docs: clarify supported Pi range --- .changeset/fix-pi-0-80-8-modelruntime-auth.md | 2 +- README.md | 2 +- docs/index.html | 2 +- docs/install.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/fix-pi-0-80-8-modelruntime-auth.md b/.changeset/fix-pi-0-80-8-modelruntime-auth.md index 24951dd..cfb5cd3 100644 --- a/.changeset/fix-pi-0-80-8-modelruntime-auth.md +++ b/.changeset/fix-pi-0-80-8-modelruntime-auth.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Login options now follow each provider's interactive API-key and OAuth capabilities, provider-driven API-key setup supports multi-step prompts while legacy one-secret clients still fail safely before storing malformed credentials, OAuth prompts retain their input, selection, and device-code semantics, and committed login remains truthful when cancellation races the final refresh. PI WEB now requires Node.js `>=22.19.0`. +Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Provider discovery now reloads model configuration and reports only complete usable credentials. Login options follow each provider's executable API-key and OAuth capabilities: multi-step API-key setup is supported, legacy one-secret clients fail safely before storing malformed credentials, and OAuth prompts retain their input, selection, and device-code semantics. A committed login remains successful through late cancellation or notification failures. Failed realtime delivery now closes only the affected socket so its browser can reconnect while healthy peers keep receiving events. PI WEB now requires Node.js `>=22.19.0`. diff --git a/README.md b/README.md index ea35cf1..d33c958 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Requirements: - Node.js 22.19.0 or newer - npm -- Pi Coding Agent configured for your user +- Pi Coding Agent `>=0.80.8 <0.81`, configured for your user - git and the development tools your agents need Install and start PI WEB as per-user services: diff --git a/docs/index.html b/docs/index.html index 8ab248b..d18ba83 100644 --- a/docs/index.html +++ b/docs/index.html @@ -38,7 +38,7 @@ "downloadUrl": "https://www.npmjs.com/package/@jmfederico/pi-web", "codeRepository": "https://github.com/jmfederico/pi-web", "description": "PI WEB is a web UI for Pi Coding Agent that keeps persistent agent sessions running in real workspaces on your machine or server.", - "softwareRequirements": "Node.js 22.19.0 or newer and Pi Coding Agent", + "softwareRequirements": "Node.js 22.19.0 or newer and Pi Coding Agent >=0.80.8 <0.81", "license": "https://github.com/jmfederico/pi-web/blob/main/LICENSE" } diff --git a/docs/install.html b/docs/install.html index 33423c2..8b3b4e2 100644 --- a/docs/install.html +++ b/docs/install.html @@ -107,7 +107,7 @@

    Requirements

    • Node.js 22.19.0 or newer and npm.
    • -
    • Pi Coding Agent installed/configured so the pi command works for your user.
    • +
    • Pi Coding Agent >=0.80.8 <0.81 installed/configured so the pi command works for your user.
    • A shell login environment that exposes Node, npm, Pi, git, and any tools your agents need.
    • For the automatic installer: a supported per-user service manager.