diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 9e85688ba..d35a72adb 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -1007,6 +1007,97 @@ Response: After a successful vote, every connected client sees `permission_resolved` with the same `requestId` and the chosen `outcome`. +### Auth device-flow routes (issue #4175 PR 21) + +The daemon brokers an OAuth 2.0 Device Authorization Grant (RFC 8628) so a remote SDK client can trigger a login whose tokens land on the **daemon** filesystem — not on the client. The daemon polls the IdP itself; the client's only job is to display the verification URL + user code and (optionally) subscribe to SSE for completion events. + +Capability tag: `auth_device_flow` (always advertised). Supported providers in v1: `qwen-oauth`. + +**Runtime locality.** The daemon never spawns a browser — even if it can. The client decides whether to call `open(verificationUri)` locally; on a headless pod (the canonical Mode B deployment) the user opens the URL on whatever device they have a browser on. See `docs/users/qwen-serve.md` for the recommended UX. + +**No token leakage in events.** `auth_device_flow_started` carries `{deviceFlowId, providerId, expiresAt}` only. The user code and verification URL come back point-to-point in the POST 201 body and via `GET /workspace/auth/device-flow/:id`; they are never broadcast on SSE. + +**Per-provider singleton.** A second `POST` for the same provider while a flow is pending is an idempotent take-over — it returns the existing entry with `attached: true` rather than starting a fresh IdP request. + +#### `POST /workspace/auth/device-flow` + +Strict mutation gate: requires a bearer token even on token-less loopback defaults (`401 token_required`). + +Request: + +```json +{ "providerId": "qwen-oauth" } +``` + +Response (`201` fresh start, `200` idempotent take-over): + +```json +{ + "deviceFlowId": "fa07c61b-…", + "providerId": "qwen-oauth", + "status": "pending", + "userCode": "USER-1", + "verificationUri": "https://chat.qwen.ai/api/v1/oauth2/device", + "verificationUriComplete": "https://chat.qwen.ai/api/v1/oauth2/device?user_code=USER-1", + "expiresAt": 1700000600000, + "intervalMs": 5000, + "attached": false +} +``` + +Errors: + +- `400 unsupported_provider` — unknown `providerId` (response includes `supportedProviders`) +- `409 too_many_active_flows` — workspace cap (4) reached; cancel one with `DELETE` +- `401 token_required` — strict gate denied a token-less request +- `502 upstream_error` — IdP returned an unexpected error + +#### `GET /workspace/auth/device-flow/:id` + +Read the current state. Pending entries echo `userCode/verificationUri/expiresAt/intervalMs`; terminal entries (5-min grace) drop them and surface `status` + optional `errorKind/hint`. + +Returns `404 device_flow_not_found` for unknown ids and post-grace evicted entries. + +#### `DELETE /workspace/auth/device-flow/:id` + +Idempotent cancel: + +- pending entry → `204` + emit `auth_device_flow_cancelled` +- terminal entry → `204` no-op (no event re-emit) +- unknown id → `404` + +#### `GET /workspace/auth/status` + +Snapshot of pending flows + supported providers: + +```json +{ + "v": 1, + "workspaceCwd": "/work/bound", + "providers": [], + "pendingDeviceFlows": [ + { + "deviceFlowId": "fa07c61b-…", + "providerId": "qwen-oauth", + "expiresAt": 1700000600000 + } + ], + "supportedDeviceFlowProviders": ["qwen-oauth"] +} +``` + +#### Device-flow SSE events + +Five typed events (workspace-scoped, fanned out to every active session bus): + +- `auth_device_flow_started` `{deviceFlowId, providerId, expiresAt}` — POST succeeded; SDK should subscribe (no userCode here, fetch via GET if needed) +- `auth_device_flow_throttled` `{deviceFlowId, intervalMs}` — daemon honored upstream `slow_down`; clients polling GET should bump their interval to match +- `auth_device_flow_authorized` `{deviceFlowId, providerId, expiresAt?, accountAlias?}` — credentials persisted; `accountAlias` is a non-PII label (never email/phone) +- `auth_device_flow_failed` `{deviceFlowId, errorKind, hint?}` — terminal; `errorKind` is one of `expired_token | access_denied | invalid_grant | upstream_error | persist_failed`. `persist_failed` is daemon-internal: the IdP exchange succeeded but the daemon couldn't durably store credentials (EACCES / EROFS / ENOSPC). The user should retry once the underlying disk condition is fixed. +- `auth_device_flow_cancelled` `{deviceFlowId}` — DELETE succeeded against a pending entry + +> **Not MCP-compatible.** The MCP authorization spec (2025-06-18) mandates OAuth 2.1 + PKCE auth-code with a redirect callback, which doesn't work for headless-pod daemons. Mode B's device-flow surface is daemon-private — clients targeting MCP-compliant servers should use a different auth path. + ## Streaming wire format Events are emitted as standard EventSource frames. The daemon writes one `data:` line per frame (the JSON has no embedded newlines after `JSON.stringify`); the SDK parser at `packages/sdk-typescript/src/daemon/sse.ts` handles both that and the spec-allowed multi-`data:` form on the receive side. diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 31b3a609b..19ba2a63c 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -360,6 +360,53 @@ The bridge keeps **one channel per daemon** (one daemon per workspace, per §02) **Peer agents (Cursor / Continue / Claude Code / OpenCode / Gemini CLI) all do single-process multi-session.** qwen-code matches them at the agent layer; the Stage 1 bridge in this PR makes the same architecture visible over HTTP. +## Logging in to a remote daemon (issue #4175 PR 21) + +When the daemon runs on a remote pod (no shared display with you), you can still log in to a Qwen account by triggering an OAuth device flow over HTTP. The daemon polls the IdP itself; your job is just to open a URL on whatever device has a browser. + +```bash +# 1. Start a flow. The daemon contacts the IdP, returns a code + URL. +curl -X POST http://127.0.0.1:4170/workspace/auth/device-flow \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"providerId":"qwen-oauth"}' +# → 201 { +# "deviceFlowId": "fa07c61b-…", +# "userCode": "USER-1", +# "verificationUri": "https://chat.qwen.ai/api/v1/oauth2/device", +# "verificationUriComplete": "https://chat.qwen.ai/...?user_code=USER-1", +# "expiresAt": 1700000600000, +# "intervalMs": 5000, +# "attached": false +# } + +# 2. Visit the URL on your phone / laptop, enter the user code. +# 3. Poll for completion (or subscribe to SSE for the auth_device_flow_authorized event): +curl http://127.0.0.1:4170/workspace/auth/device-flow/fa07c61b-… \ + -H "Authorization: Bearer $TOKEN" +# → status transitions: pending → authorized +``` + +The TypeScript SDK wraps both steps into a single helper: + +```ts +import { DaemonClient } from '@qwen-code/sdk'; + +const client = new DaemonClient({ baseUrl, token }); +const flow = await client.auth.start({ providerId: 'qwen-oauth' }); +console.log(`Open ${flow.verificationUri}\nCode: ${flow.userCode}`); +const result = await flow.awaitCompletion({ signal: abortCtrl.signal }); +// result.status === 'authorized' +``` + +**The daemon never opens a browser on your behalf.** Even when running locally, the daemon stays passive — it returns the URL and lets the SDK / user choose where to open it. This is intentional: a daemon on a headless pod that called `xdg-open` would silently fail, masking the actual auth surface. Mirror `gh auth login`'s "Press Enter to open browser" UX in your client. + +**`--require-auth` and dev convenience.** The device-flow routes use the strict mutation gate (PR 15), which means a token-less loopback default returns `401 token_required`. Locally, the simplest way around this during development is `qwen serve --token=dev-token`; you don't need `--require-auth` unless you're hardening the loopback default. + +**Cross-daemon limitation.** `oauth_creds.json` is daemon-shared (`~/.qwen/oauth_creds.json`), so a successful login in daemon A is automatically picked up by daemon B's next token refresh — but daemon B's SDK clients won't receive the `auth_device_flow_authorized` event (events are per-daemon). + +**Cross-client take-over.** Two SDK clients on the same daemon that both `POST /workspace/auth/device-flow` for the same provider get the per-provider singleton: the first call starts a fresh IdP request and returns `attached: false`; the second call returns the EXISTING in-flight entry with `attached: true`. The take-over is recorded on the audit trail (under the second client's `X-Qwen-Client-Id`) but does NOT emit a separate event — both clients eventually observe the SAME `auth_device_flow_authorized` once the user finishes the IdP page. If your UI distinguishes "I started this" from "someone else's flow I joined", branch on the `attached` field returned by `start()`. + ## What's next - **Build a client?** See the [DaemonClient TypeScript quickstart](../developers/examples/daemon-client-quickstart.md) and the [HTTP protocol reference](../developers/qwen-serve-protocol.md). diff --git a/packages/cli/src/serve/auth/deviceFlow.test.ts b/packages/cli/src/serve/auth/deviceFlow.test.ts new file mode 100644 index 000000000..a1ac9448a --- /dev/null +++ b/packages/cli/src/serve/auth/deviceFlow.test.ts @@ -0,0 +1,1129 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + brandSecret, + unsafeRevealSecret, + DEVICE_FLOW_DEFAULT_INTERVAL_MS, + DEVICE_FLOW_MAX_CONCURRENT, + DEVICE_FLOW_MAX_EXPIRES_IN_SEC, + DEVICE_FLOW_MAX_INTERVAL_MS, + DEVICE_FLOW_PERSIST_TIMEOUT_MS, + DEVICE_FLOW_SLOW_DOWN_BUMP_MS, + DEVICE_FLOW_START_TIMEOUT_MS, + DEVICE_FLOW_TERMINAL_GRACE_MS, + DeviceFlowRegistry, + TooManyActiveDeviceFlowsError, + UnsupportedDeviceFlowProviderError, + type DeviceFlowEventEmission, + type DeviceFlowEventSink, + type DeviceFlowPollResult, + type DeviceFlowProvider, + type DeviceFlowProviderId, +} from './deviceFlow.js'; + +interface FakeClock { + now: number; + tick(ms: number): void; +} + +interface ScheduledCallback { + fireAt: number; + cb: () => void; + cancelled: boolean; +} + +interface FakeScheduler { + callbacks: ScheduledCallback[]; + intervals: Array<{ ms: number; cb: () => void; cancelled: boolean }>; + flushDue(now: number): void; +} + +function makeClockAndScheduler(): { + clock: FakeClock; + scheduler: FakeScheduler; + schedule: (ms: number, cb: () => void) => unknown; + scheduleInterval: (ms: number, cb: () => void) => unknown; + clearScheduled: (handle: unknown) => void; + clearScheduledInterval: (handle: unknown) => void; + now: () => number; +} { + const clock: FakeClock = { + now: 1_700_000_000_000, + tick(ms) { + clock.now += ms; + }, + }; + const callbacks: ScheduledCallback[] = []; + const intervals: Array<{ ms: number; cb: () => void; cancelled: boolean }> = + []; + return { + clock, + scheduler: { + callbacks, + intervals, + flushDue(now) { + for (const c of callbacks) { + if (!c.cancelled && c.fireAt <= now) { + c.cancelled = true; + c.cb(); + } + } + }, + }, + now: () => clock.now, + schedule: (ms, cb) => { + const entry: ScheduledCallback = { + fireAt: clock.now + ms, + cb, + cancelled: false, + }; + callbacks.push(entry); + return entry; + }, + scheduleInterval: (ms, cb) => { + const entry = { ms, cb, cancelled: false }; + intervals.push(entry); + return entry; + }, + clearScheduled: (h) => { + (h as ScheduledCallback).cancelled = true; + }, + clearScheduledInterval: (h) => { + (h as { cancelled: boolean }).cancelled = true; + }, + }; +} + +class FakeProvider implements DeviceFlowProvider { + readonly providerId: DeviceFlowProviderId = 'qwen-oauth'; + startCount = 0; + pollCount = 0; + pollScript: DeviceFlowPollResult[] = []; + persistCalls = 0; + startError: Error | undefined; + expiresIn = 600; // 10 minutes + interval: number | undefined = undefined; + /** Test hook: when `true`, `start()` returns a Promise that NEVER + * resolves and ignores the supplied `signal`. Models a misbehaving + * / future provider whose underlying I/O isn't abortable — + * registry's authoritative timeout (Promise.race) is the only + * thing that can rescue the await. PR #4255 fold-in 7 #1. */ + startHangs = false; + /** Test hook: when set, `poll()` throws this Error on the next call. + * Models a non-conforming provider that violates the + * `DeviceFlowProvider.poll()` `@remarks` sanitization contract by + * throwing raw IdP detail. PR #4255 fold-in 8 #1. */ + pollThrowsWith: Error | undefined; + /** Most recent `opts.signal` observed by `poll`. Test hook for the + * abort-mid-poll assertion: after `registry.cancel(...)`, this + * signal MUST report `.aborted === true` so the upstream HTTP + * socket can be torn down. */ + lastPollSignal: AbortSignal | undefined; + + async start(): Promise<{ + deviceCode: ReturnType; + pkceVerifier: ReturnType; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresIn: number; + interval?: number; + }> { + this.startCount += 1; + if (this.startError) throw this.startError; + if (this.startHangs) { + // Never resolves and intentionally ignores `signal` — models a + // non-cooperative provider. Registry's Promise.race timeout is + // what must rescue this `await`. + await new Promise(() => {}); + throw new Error('unreachable'); + } + return { + deviceCode: brandSecret(`device-${this.startCount}`), + pkceVerifier: brandSecret(`pkce-${this.startCount}`), + userCode: `USER-${this.startCount}`, + verificationUri: 'https://idp.example/verify', + verificationUriComplete: 'https://idp.example/verify?user=AB12', + expiresIn: this.expiresIn, + ...(this.interval !== undefined ? { interval: this.interval } : {}), + }; + } + + async poll( + _state: unknown, + opts: { signal: AbortSignal }, + ): Promise { + this.pollCount += 1; + this.lastPollSignal = opts.signal; + if (this.pollThrowsWith !== undefined) { + const err = this.pollThrowsWith; + this.pollThrowsWith = undefined; + throw err; + } + if (opts.signal.aborted) return { kind: 'pending' }; + if (this.pollScript.length === 0) { + return { kind: 'pending' }; + } + const next = this.pollScript.shift()!; + if (next.kind === 'success') { + const inner = next; + return { + kind: 'success', + persist: async (persistOpts: { signal: AbortSignal }) => { + this.persistCalls += 1; + return inner.persist(persistOpts); + }, + }; + } + return next; + } +} + +function makeEventSink(): { + sink: DeviceFlowEventSink; + emissions: Array<{ emission: DeviceFlowEventEmission; clientId?: string }>; +} { + const emissions: Array<{ + emission: DeviceFlowEventEmission; + clientId?: string; + }> = []; + return { + emissions, + sink: { + publish(emission, originatorClientId) { + emissions.push({ emission, clientId: originatorClientId }); + }, + }, + }; +} + +function buildRegistry(provider: FakeProvider) { + const env = makeClockAndScheduler(); + const events = makeEventSink(); + const auditLines: Array> = []; + const registry = new DeviceFlowRegistry({ + events: events.sink, + audit: { record: (line) => auditLines.push({ ...line }) }, + resolveProvider: (id) => (id === 'qwen-oauth' ? provider : undefined), + now: env.now, + schedule: env.schedule as never, + scheduleInterval: env.scheduleInterval as never, + clearScheduled: env.clearScheduled as never, + clearScheduledInterval: env.clearScheduledInterval as never, + }); + return { registry, env, events: events.emissions, auditLines }; +} + +describe('BrandedSecret', () => { + // The earlier `new String(value)` shape leaked through `+`, template + // literals, and `valueOf` — coercion via `Symbol.toPrimitive` followed + // the wrapper's `valueOf` which returned the primitive. The fix uses a + // frozen plain object + WeakMap; ALL four coercion paths + // (`String()`, `JSON.stringify`, `+`, template literal) must redact. + + it('JSON.stringify on a secret returns "[redacted]" and preserves siblings', () => { + const secret = brandSecret('SUPER-SECRET-DEVICE-CODE'); + const wrapped = { deviceCode: secret, label: 'demo' }; + const json = JSON.stringify(wrapped); + expect(json).not.toContain('SUPER-SECRET-DEVICE-CODE'); + expect(json).toContain('[redacted]'); + expect(json).toContain('"label":"demo"'); + }); + + it('String(secret) redacts (toString hook)', () => { + const secret = brandSecret('LEAK-ME-IF-YOU-DARE'); + expect(String(secret)).toBe('[redacted]'); + }); + + it('"prefix" + secret redacts (the path the old String-wrapper LEAKED)', () => { + const secret = brandSecret('PRIMITIVE-WOULD-LEAK'); + const concatenated = 'device_code=' + secret; + expect(concatenated).not.toContain('PRIMITIVE-WOULD-LEAK'); + expect(concatenated).toBe('device_code=[redacted]'); + }); + + it('template literal `${secret}` redacts', () => { + const secret = brandSecret('TEMPLATE-LEAK'); + const interpolated = `code=${secret} mode=foo`; + expect(interpolated).not.toContain('TEMPLATE-LEAK'); + expect(interpolated).toBe('code=[redacted] mode=foo'); + }); + + it('`+secret` (numeric coercion) yields NaN — does not expose primitive', () => { + const secret = brandSecret('NUMERIC-COERCION-LEAK'); + expect(Number.isNaN(+secret)).toBe(true); + }); + + it('unsafeRevealSecret returns the original primitive', () => { + const secret = brandSecret('THE-REAL-VALUE'); + expect(unsafeRevealSecret(secret)).toBe('THE-REAL-VALUE'); + }); + + it('unsafeRevealSecret throws when called on a non-secret object', () => { + const fake = { toString: () => '[redacted]' } as unknown as ReturnType< + typeof brandSecret + >; + expect(() => unsafeRevealSecret(fake)).toThrowError(/not a BrandedSecret/); + }); + + it('two distinct brands compare unequal even when contents match', () => { + const a = brandSecret('SAME'); + const b = brandSecret('SAME'); + expect(a).not.toBe(b); + expect(unsafeRevealSecret(a)).toBe(unsafeRevealSecret(b)); + }); +}); + +describe('DeviceFlowRegistry — start / public view', () => { + let provider: FakeProvider; + let registry: DeviceFlowRegistry; + let events: ReturnType['events']; + let auditLines: ReturnType['auditLines']; + + beforeEach(() => { + provider = new FakeProvider(); + const built = buildRegistry(provider); + registry = built.registry; + events = built.events; + auditLines = built.auditLines; + }); + + afterEach(() => { + registry.dispose(); + }); + + it('emits started + returns redacted public view', async () => { + const { view, attached } = await registry.start({ + providerId: 'qwen-oauth', + }); + expect(attached).toBe(false); + expect(view.status).toBe('pending'); + expect(view.userCode).toBe('USER-1'); + // Critical: public view never carries device_code / pkce_verifier. + expect(JSON.stringify(view)).not.toContain('device-1'); + expect(JSON.stringify(view)).not.toContain('pkce-1'); + expect(events).toHaveLength(1); + expect(events[0].emission.type).toBe('started'); + // Started emission MUST NOT include userCode/verificationUri (PR 21 §3). + expect(JSON.stringify(events[0].emission.data)).not.toContain('USER-1'); + expect(JSON.stringify(events[0].emission.data)).not.toContain( + 'idp.example/verify', + ); + }); + + it('idempotent take-over for the same providerId', async () => { + const first = await registry.start({ providerId: 'qwen-oauth' }); + expect(first.attached).toBe(false); + expect(provider.startCount).toBe(1); + const second = await registry.start({ providerId: 'qwen-oauth' }); + expect(second.attached).toBe(true); + expect(second.view.deviceFlowId).toBe(first.view.deviceFlowId); + // Critical: provider.start should NOT have been called a second time. + expect(provider.startCount).toBe(1); + }); + + it('take-over by a different clientId emits a take-over audit (fold-in 6 #6)', async () => { + await registry.start({ + providerId: 'qwen-oauth', + initiatorClientId: 'sdk-client-A', + }); + auditLines.length = 0; + await registry.start({ + providerId: 'qwen-oauth', + initiatorClientId: 'sdk-client-B', + }); + const takeoverAudit = auditLines.find( + (line) => + line['status'] === 'started' && + line['clientId'] === 'sdk-client-B' && + typeof line['hint'] === 'string' && + (line['hint'] as string).startsWith('take-over'), + ); + expect(takeoverAudit).toBeDefined(); + expect(takeoverAudit?.['hint']).toContain('sdk-client-A'); + }); + + it('take-over by the SAME clientId does not emit a take-over audit', async () => { + await registry.start({ + providerId: 'qwen-oauth', + initiatorClientId: 'sdk-client-A', + }); + auditLines.length = 0; + await registry.start({ + providerId: 'qwen-oauth', + initiatorClientId: 'sdk-client-A', + }); + expect( + auditLines.some( + (line) => + typeof line['hint'] === 'string' && + (line['hint'] as string).startsWith('take-over'), + ), + ).toBe(false); + }); + + it('concurrent start() for the same providerId coalesces — provider.start fires once', async () => { + // Without the in-flight Promise map, both concurrent callers would + // pass the "no existing pending entry" check, both would call + // provider.start (two IdP round-trips), and the second's byProvider + // write would clobber the first — leaking an orphan poll timer. + const [first, second, third] = await Promise.all([ + registry.start({ providerId: 'qwen-oauth' }), + registry.start({ providerId: 'qwen-oauth' }), + registry.start({ providerId: 'qwen-oauth' }), + ]); + expect(provider.startCount).toBe(1); + // All three observers should agree on the same deviceFlowId. + expect(first.view.deviceFlowId).toBe(second.view.deviceFlowId); + expect(second.view.deviceFlowId).toBe(third.view.deviceFlowId); + // Exactly one is the fresh start; the other two are take-overs. + const attachedCount = [first, second, third].filter( + (r) => r.attached, + ).length; + expect(attachedCount).toBe(2); + }); + + it('rejects unsupported provider', async () => { + await expect( + registry.start({ providerId: 'unknown-idp' as DeviceFlowProviderId }), + ).rejects.toBeInstanceOf(UnsupportedDeviceFlowProviderError); + }); + + it('caps at DEVICE_FLOW_MAX_CONCURRENT', async () => { + const providers = new Map(); + for (let i = 0; i < DEVICE_FLOW_MAX_CONCURRENT + 1; i += 1) { + providers.set( + `provider-${i}` as DeviceFlowProviderId, + new FakeProvider(), + ); + } + const env = makeClockAndScheduler(); + const events = makeEventSink(); + const reg = new DeviceFlowRegistry({ + events: events.sink, + resolveProvider: (id) => providers.get(id), + now: env.now, + schedule: env.schedule as never, + scheduleInterval: env.scheduleInterval as never, + clearScheduled: env.clearScheduled as never, + clearScheduledInterval: env.clearScheduledInterval as never, + }); + try { + for (let i = 0; i < DEVICE_FLOW_MAX_CONCURRENT; i += 1) { + await reg.start({ + providerId: `provider-${i}` as DeviceFlowProviderId, + }); + } + await expect( + reg.start({ + providerId: + `provider-${DEVICE_FLOW_MAX_CONCURRENT}` as DeviceFlowProviderId, + }), + ).rejects.toBeInstanceOf(TooManyActiveDeviceFlowsError); + } finally { + reg.dispose(); + } + }); + + it('caps at DEVICE_FLOW_MAX_CONCURRENT under CONCURRENT distinct-provider starts (round-13 #1)', async () => { + // PR #4255 round-13 #1 (gpt-5.5 review C1gh0): the sequential + // cap test above established the accounting rule, but only the + // CONCURRENT case exposes the bug fix. Pre-fix: + // `countActive()` counted only `byProvider`; concurrent + // `start()` calls for MAX+1 distinct providers all reach the + // cap check synchronously (before any awaits), all see count=0 + // (no byProvider entries yet), and all pass. Post-fix: the + // counter includes `inFlightStarts.size`, so the second concurrent + // caller sees count=1, the third count=2, and the (MAX+1)th + // caller is rejected. + const providers = new Map(); + for (let i = 0; i < DEVICE_FLOW_MAX_CONCURRENT + 1; i += 1) { + providers.set( + `provider-${i}` as DeviceFlowProviderId, + new FakeProvider(), + ); + } + const env = makeClockAndScheduler(); + const events = makeEventSink(); + const reg = new DeviceFlowRegistry({ + events: events.sink, + resolveProvider: (id) => providers.get(id), + now: env.now, + schedule: env.schedule as never, + scheduleInterval: env.scheduleInterval as never, + clearScheduled: env.clearScheduled as never, + clearScheduledInterval: env.clearScheduledInterval as never, + }); + try { + const results = await Promise.allSettled( + Array.from({ length: DEVICE_FLOW_MAX_CONCURRENT + 1 }, (_, i) => + reg.start({ + providerId: `provider-${i}` as DeviceFlowProviderId, + }), + ), + ); + const fulfilled = results.filter((r) => r.status === 'fulfilled'); + const rejected = results.filter((r) => r.status === 'rejected'); + expect(fulfilled).toHaveLength(DEVICE_FLOW_MAX_CONCURRENT); + expect(rejected).toHaveLength(1); + expect( + rejected[0]!.status === 'rejected' ? rejected[0]!.reason : null, + ).toBeInstanceOf(TooManyActiveDeviceFlowsError); + } finally { + reg.dispose(); + } + }); +}); + +describe('DeviceFlowRegistry — polling state machine', () => { + let provider: FakeProvider; + let env: ReturnType['env']; + let registry: DeviceFlowRegistry; + let events: ReturnType['events']; + + beforeEach(() => { + provider = new FakeProvider(); + const built = buildRegistry(provider); + env = built.env; + registry = built.registry; + events = built.events; + }); + + afterEach(() => { + registry.dispose(); + }); + + it('honors slow_down by bumping intervalMs and emits throttled', async () => { + provider.pollScript = [{ kind: 'slow_down' }]; + const { view: started } = await registry.start({ + providerId: 'qwen-oauth', + }); + // Advance past one polling interval and flush. + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + // Wait for the async poll handler to settle. + await flushAsync(); + + const throttled = events.find((e) => e.emission.type === 'throttled'); + expect(throttled).toBeDefined(); + expect( + (throttled!.emission.data as { intervalMs: number }).intervalMs, + ).toBe(DEVICE_FLOW_DEFAULT_INTERVAL_MS + DEVICE_FLOW_SLOW_DOWN_BUMP_MS); + + const refreshed = registry.get(started.deviceFlowId); + expect(refreshed?.intervalMs).toBe( + DEVICE_FLOW_DEFAULT_INTERVAL_MS + DEVICE_FLOW_SLOW_DOWN_BUMP_MS, + ); + expect(refreshed?.status).toBe('pending'); + }); + + it('persists credentials on success and emits authorized', async () => { + let persisted = false; + provider.pollScript = [ + { + kind: 'success', + persist: async () => { + persisted = true; + return { expiresAt: 9_999, accountAlias: 'demo-user' }; + }, + }, + ]; + const { view: started } = await registry.start({ + providerId: 'qwen-oauth', + }); + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + + expect(persisted).toBe(true); + expect(provider.persistCalls).toBe(1); + const authorized = events.find((e) => e.emission.type === 'authorized'); + expect(authorized).toBeDefined(); + const refreshed = registry.get(started.deviceFlowId); + expect(refreshed?.status).toBe('authorized'); + // Public view of an authorized entry should NOT echo userCode/verificationUri. + expect(refreshed?.userCode).toBeUndefined(); + expect(refreshed?.verificationUri).toBeUndefined(); + }); + + it('emits failed with errorKind on upstream RFC 8628 error', async () => { + provider.pollScript = [ + { kind: 'error', errorKind: 'access_denied', hint: 'user said no' }, + ]; + const { view: started } = await registry.start({ + providerId: 'qwen-oauth', + }); + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + + const failed = events.find((e) => e.emission.type === 'failed'); + expect(failed).toBeDefined(); + expect((failed!.emission.data as { errorKind: string }).errorKind).toBe( + 'access_denied', + ); + const refreshed = registry.get(started.deviceFlowId); + expect(refreshed?.status).toBe('error'); + expect(refreshed?.errorKind).toBe('access_denied'); + }); + + it('terminal entries are readable via GET within grace, evicted after', async () => { + provider.pollScript = [ + // Note: an upstream `expired_token` error puts the entry into + // `status: 'error'` with `errorKind: 'expired_token'`. The + // `'expired'` status is reserved for the time-based path + // (now >= expiresAt) — see PR 21 §2 status machine. + { kind: 'error', errorKind: 'expired_token' }, + ]; + const { view: started } = await registry.start({ + providerId: 'qwen-oauth', + }); + // Drive to terminal. + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + expect(registry.get(started.deviceFlowId)?.status).toBe('error'); + expect(registry.get(started.deviceFlowId)?.errorKind).toBe('expired_token'); + + // Advance to just before grace expires — entry still readable. + env.clock.tick(DEVICE_FLOW_TERMINAL_GRACE_MS - 1); + runSweepers(env); + expect(registry.get(started.deviceFlowId)?.status).toBe('error'); + + // Push past grace + one sweeper tick. + env.clock.tick(2); + runSweepers(env); + expect(registry.get(started.deviceFlowId)).toBeUndefined(); + }); + + it('does NOT import child_process or browser-launch helpers anywhere in the device-flow source path', () => { + // Static-source check (PR 21 §8 #1 — runtime-locality contract). + // + // ESM module-namespace immutability prevents a runtime spawn-spy + // (`Cannot redefine property: spawn`), so we assert structurally: + // the source files must not reference any of the spawn / browser- + // launch primitives that could break the "daemon never opens a + // browser" property. A future commit that re-introduces one fails + // here loudly. + const dir = path.dirname(fileURLToPath(import.meta.url)); + const sources = [ + fs.readFileSync(path.join(dir, 'deviceFlow.ts'), 'utf8'), + fs.readFileSync(path.join(dir, 'qwenDeviceFlowProvider.ts'), 'utf8'), + ]; + const forbiddenPatterns = [ + // Static imports + /from\s*['"]node:child_process['"]/, + /from\s*['"]child_process['"]/, + /from\s*['"]open['"]/, + /from\s*['"]execa['"]/, + /from\s*['"]shelljs['"]/, + // Dynamic imports / requires + /import\s*\(\s*['"](node:)?child_process['"]\s*\)/, + /require\s*\(\s*['"](node:)?child_process['"]\s*\)/, + /require\s*\(\s*['"]open['"]\s*\)/, + // Direct API surface + /\bxdg-open\b/, + /\bshell\.openExternal\b/, + /\bprocess\.spawn\b/, + ]; + for (const src of sources) { + for (const pattern of forbiddenPatterns) { + expect(src).not.toMatch(pattern); + } + } + }); +}); + +describe('DeviceFlowRegistry — authoritative timeouts (fold-in 7)', () => { + it('start() rejects when a non-abortable provider.start() hangs past START_TIMEOUT_MS (#1)', async () => { + const provider = new FakeProvider(); + provider.startHangs = true; + const built = buildRegistry(provider); + const { registry, env } = built; + try { + const startPromise = registry.start({ providerId: 'qwen-oauth' }); + // Let the registry register its race timer. + await flushAsync(); + env.clock.tick(DEVICE_FLOW_START_TIMEOUT_MS + 1); + env.scheduler.flushDue(env.clock.now); + await expect(startPromise).rejects.toThrow(/start timeout/); + // Critical: inFlightStarts slot must be released so a future + // POST creates a fresh flow rather than re-attaching to the + // hung promise. + provider.startHangs = false; + await expect( + registry.start({ providerId: 'qwen-oauth' }), + ).resolves.toMatchObject({ attached: false }); + } finally { + registry.dispose(); + } + }); + + it('persist() that hangs past PERSIST_TIMEOUT_MS maps to persist_failed (#2)', async () => { + const provider = new FakeProvider(); + // Single poll tick returns success whose persist() never resolves. + provider.pollScript = [ + { + kind: 'success', + persist: () => + new Promise<{ expiresAt?: number; accountAlias?: string }>( + () => undefined, + ), + }, + ]; + const built = buildRegistry(provider); + const { registry, env, events } = built; + try { + const { view } = await registry.start({ providerId: 'qwen-oauth' }); + // Drive the first poll → success → enters persist race. + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + // Now advance past the persist timeout. + env.clock.tick(DEVICE_FLOW_PERSIST_TIMEOUT_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + const snapshot = registry.get(view.deviceFlowId); + expect(snapshot?.status).toBe('error'); + expect(snapshot?.errorKind).toBe('persist_failed'); + const failed = events.find( + (e) => + e.emission.type === 'failed' && + e.emission.data.errorKind === 'persist_failed', + ); + expect(failed).toBeDefined(); + } finally { + registry.dispose(); + } + }); + + it('clamps an extreme expiresIn to DEVICE_FLOW_MAX_EXPIRES_IN_SEC (#3)', async () => { + const provider = new FakeProvider(); + provider.expiresIn = 1e12; // years; would pin singleton without clamp + const built = buildRegistry(provider); + const { registry, env } = built; + try { + const { view } = await registry.start({ providerId: 'qwen-oauth' }); + const ttlMs = (view.expiresAt ?? 0) - env.clock.now; + expect(ttlMs).toBeLessThanOrEqual(DEVICE_FLOW_MAX_EXPIRES_IN_SEC * 1000); + expect(ttlMs).toBeGreaterThan(0); + } finally { + registry.dispose(); + } + }); + + it('clamps an extreme interval to DEVICE_FLOW_MAX_INTERVAL_MS (#3)', async () => { + const provider = new FakeProvider(); + provider.interval = 1e9; // billions of seconds; setTimeout(huge) is dropped + const built = buildRegistry(provider); + const { registry } = built; + try { + const { view } = await registry.start({ providerId: 'qwen-oauth' }); + expect(view.intervalMs).toBeLessThanOrEqual(DEVICE_FLOW_MAX_INTERVAL_MS); + } finally { + registry.dispose(); + } + }); + + it('runPollTick catch uses a static SSE hint and preserves raw on the audit (fold-in 9 #1)', async () => { + // Models a non-conforming provider that violates the @remarks + // sanitization contract by throwing a multi-KB raw payload that + // could include secret material (here, an HTML-error-page-shaped + // string with a fake-secret marker). + const provider = new FakeProvider(); + const secretMarker = 'CONFIDENTIAL-DEVICE-CODE-DO-NOT-LEAK'; + const longRaw = `${secretMarker} ${'X'.repeat(4_000)}`; + provider.pollThrowsWith = new Error(longRaw); + const built = buildRegistry(provider); + const { registry, env, events, auditLines } = built; + try { + const { view } = await registry.start({ providerId: 'qwen-oauth' }); + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + const failedEvent = events.find( + (e) => + e.emission.type === 'failed' && + e.emission.data.deviceFlowId === view.deviceFlowId, + ); + expect(failedEvent).toBeDefined(); + const sseHint = + failedEvent && failedEvent.emission.type === 'failed' + ? failedEvent.emission.data.hint + : undefined; + // fold-in 9 strengthens fold-in 8: SSE hint is now a STATIC + // bounded message — even the truncated prefix could carry + // secret material if the provider templated it into + // err.message. Static keeps SSE broadcasters fully isolated + // from raw provider text. + expect(sseHint).toBeDefined(); + expect(sseHint).not.toContain(secretMarker); + expect(sseHint).toBe( + 'provider.poll() failed; see daemon audit log for details', + ); + // Audit line still retains the FULL raw detail (including the + // secret marker) for operator incident response. + const failedAudit = auditLines.find( + (line) => + line['status'] === 'failed' && + line['errorKind'] === 'upstream_error' && + typeof line['hint'] === 'string', + ); + expect(failedAudit).toBeDefined(); + expect(failedAudit?.['hint']).toContain(secretMarker); + } finally { + registry.dispose(); + } + }); +}); + +describe('DeviceFlowRegistry — abort propagation to provider.poll', () => { + it('cancel() aborts the signal observed by the in-flight provider.poll', async () => { + const provider = new FakeProvider(); + const built = buildRegistry(provider); + const { registry, env } = built; + try { + const { view: started } = await registry.start({ + providerId: 'qwen-oauth', + }); + // Drive one polling tick so the provider records its signal. + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + // Two microtask flushes so the poll handler resolves and + // `lastPollSignal` is populated. + await Promise.resolve(); + await Promise.resolve(); + expect(provider.lastPollSignal).toBeDefined(); + expect(provider.lastPollSignal!.aborted).toBe(false); + + // Cancel the flow — registry should abort the entry's + // cancelController, which is the SAME signal the provider's + // `poll` saw. A real Qwen provider passes this to `fetch`, so + // an in-flight HTTP socket gets torn down immediately. + registry.cancel(started.deviceFlowId); + expect(provider.lastPollSignal!.aborted).toBe(true); + } finally { + registry.dispose(); + } + }); + + it('dispose() also aborts the signal observed by every active flow', async () => { + const provider = new FakeProvider(); + const built = buildRegistry(provider); + const { registry, env } = built; + await registry.start({ providerId: 'qwen-oauth' }); + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + await Promise.resolve(); + await Promise.resolve(); + expect(provider.lastPollSignal!.aborted).toBe(false); + registry.dispose(); + expect(provider.lastPollSignal!.aborted).toBe(true); + }); +}); + +describe('DeviceFlowRegistry — persist failure paths (fold-in 10 #1)', () => { + // Round-8 thread Cvho9 (Critical): persist failure branches are the + // most consequential code paths in the success arm — `persist_failed` + // was specifically introduced for disk-write failures (EACCES, + // EROFS, ENOSPC) and the cancel-during-persist + past-expiresAt + // branches were added by fold-in 5/9 to handle race conditions. + // Every prior test used a persist that always succeeded; this + // block exercises the three terminal mappings. + + it('persist throws → entry transitions to error/persist_failed + failed event emitted', async () => { + const provider = new FakeProvider(); + const persistError = new Error('EACCES: permission denied'); + provider.pollScript = [ + { + kind: 'success', + persist: async () => { + throw persistError; + }, + }, + ]; + const built = buildRegistry(provider); + const { registry, env, events, auditLines } = built; + try { + const { view } = await registry.start({ providerId: 'qwen-oauth' }); + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + const snapshot = registry.get(view.deviceFlowId); + expect(snapshot?.status).toBe('error'); + expect(snapshot?.errorKind).toBe('persist_failed'); + const failedEvent = events.find( + (e) => + e.emission.type === 'failed' && + e.emission.data.deviceFlowId === view.deviceFlowId, + ); + expect(failedEvent).toBeDefined(); + if (failedEvent && failedEvent.emission.type === 'failed') { + expect(failedEvent.emission.data.errorKind).toBe('persist_failed'); + // SSE hint is the static bounded string; no raw EACCES text. + expect(failedEvent.emission.data.hint).toContain( + 'credentials could not be written', + ); + } + const failedAudit = auditLines.find( + (line) => + line['status'] === 'failed' && line['errorKind'] === 'persist_failed', + ); + expect(failedAudit).toBeDefined(); + } finally { + registry.dispose(); + } + }); + + it('persist throws after cancel() → entry transitions to cancelled (not authorized; not persist_failed)', async () => { + const provider = new FakeProvider(); + // Persist takes a controllable promise so the test can fire cancel + // mid-await and then resolve persist (with rejection) afterward. + let rejectPersist!: (err: Error) => void; + const persistPromise = new Promise<{ expiresAt?: number }>( + (_resolve, reject) => { + rejectPersist = reject; + }, + ); + provider.pollScript = [ + { + kind: 'success', + persist: () => persistPromise, + }, + ]; + const built = buildRegistry(provider); + const { registry, env, events } = built; + try { + const { view } = await registry.start({ providerId: 'qwen-oauth' }); + // First poll tick: enters success → persist starts. + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + // While persist is in flight, request cancel via the route + // surface (cancellerClientId differs from the initiator). + const cancelResult = registry.cancel(view.deviceFlowId, 'sdk-canceller'); + expect(cancelResult).toEqual({ alreadyTerminal: false }); + // Persist now fails (signal-aborted by cancel). Resolve with + // an abort-shaped error. The registry's persistError branch + // routes through `cancelDuringPersist` → `cancelled`. + rejectPersist(new Error('aborted: cancel during persist')); + await flushAsync(); + const snapshot = registry.get(view.deviceFlowId); + expect(snapshot?.status).toBe('cancelled'); + expect(snapshot?.errorKind).toBeUndefined(); + // Event emission is on the canceller's id (fold-in 9 #5). + const cancelledEvent = events.find( + (e) => + e.emission.type === 'cancelled' && + e.emission.data.deviceFlowId === view.deviceFlowId, + ); + expect(cancelledEvent).toBeDefined(); + expect(cancelledEvent?.clientId).toBe('sdk-canceller'); + // No `failed`/`persist_failed` event leaked through. + expect( + events.some( + (e) => + e.emission.type === 'failed' && + e.emission.data.errorKind === 'persist_failed', + ), + ).toBe(false); + } finally { + registry.dispose(); + } + }); + + it('records lost_success_after_timeout when persist resolves AFTER the registry timeout (round-12 #8)', async () => { + // PR #4255 round-12 #8 (Cy_ZH): pins the split-brain detector + // for non-conforming providers. fold-in 9 #7 added an + // independent tracker on the original `result.persist(...)` + // promise: if the race timed out (`persistTimedOut === true`) + // AND the underlying persist later resolved successfully, + // emit a `lost_success_after_timeout` audit breadcrumb. + // Reachable scenario: provider's persist runs non-abortable + // I/O (mkdir/chmod/mv outside the `fs.writeFile` signal + // path) and the disk write succeeds 100ms after the 30s + // timeout fires. + const provider = new FakeProvider(); + let resolveLate!: (m: { expiresAt?: number }) => void; + const latePersist = new Promise<{ expiresAt?: number }>((resolve) => { + resolveLate = resolve; + }); + provider.pollScript = [ + { + kind: 'success', + // Persist intentionally ignores signal — models a + // non-conforming provider. Resolves only when the test + // fires it. + persist: () => latePersist, + }, + ]; + const built = buildRegistry(provider); + const { registry, env, auditLines } = built; + try { + await registry.start({ providerId: 'qwen-oauth' }); + // Drive first poll → success → enter persist race. + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + // Advance past PERSIST_TIMEOUT_MS so the race fires its timer. + env.clock.tick(DEVICE_FLOW_PERSIST_TIMEOUT_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + // Registry already published persist_failed and transitioned. + // Now the non-cooperative provider "silently" commits anyway. + resolveLate({ expiresAt: 1_700_000_999_000 }); + await flushAsync(); + const lostSuccessAudit = auditLines.find( + (line) => + typeof line['hint'] === 'string' && + (line['hint'] as string).startsWith('lost_success_after_timeout'), + ); + expect(lostSuccessAudit).toBeDefined(); + // The audit line records `status: 'authorized'` so operators + // can grep "tokens are on disk despite persist_failed event." + expect(lostSuccessAudit?.['status']).toBe('authorized'); + expect(lostSuccessAudit?.['hint']).toContain('1700000999000'); + } finally { + registry.dispose(); + } + }); + + it('persist throws past expiresAt → persist_failed (NOT expired_token; fold-in 9 #13)', async () => { + // Round-8 #13: previously the registry classified this branch as + // `expired_token`, routing operator remediation to "tell user to + // retry" (RFC 8628 expiry) when the actual root cause is disk + // I/O. fold-in 9 #13 reclassified to `persist_failed` with a + // `persist_also_failed_past_expiry` audit hint preserving the + // timing detail. + const provider = new FakeProvider(); + provider.expiresIn = 60; // 60s flow window + let rejectPersist!: (err: Error) => void; + const persistPromise = new Promise<{ expiresAt?: number }>( + (_resolve, reject) => { + rejectPersist = reject; + }, + ); + provider.pollScript = [ + { + kind: 'success', + persist: () => persistPromise, + }, + ]; + const built = buildRegistry(provider); + const { registry, env, events, auditLines } = built; + try { + const { view } = await registry.start({ providerId: 'qwen-oauth' }); + // Drive first poll → success → persist begins. + env.clock.tick(DEVICE_FLOW_DEFAULT_INTERVAL_MS + 1); + env.scheduler.flushDue(env.clock.now); + await flushAsync(); + // Advance past `expiresAt` (60s) WHILE persist is still pending. + env.clock.tick(120_000); + // Now resolve persist with rejection — non-cancel disk error. + rejectPersist(new Error('ENOSPC: no space left')); + await flushAsync(); + const snapshot = registry.get(view.deviceFlowId); + expect(snapshot?.status).toBe('error'); + expect(snapshot?.errorKind).toBe('persist_failed'); + // Audit hint preserves the past-expiry timing detail for + // operator visibility (per fold-in 9 #13). + const failedAudit = auditLines.find( + (line) => + line['status'] === 'failed' && + line['errorKind'] === 'persist_failed' && + typeof line['hint'] === 'string' && + (line['hint'] as string).startsWith( + 'persist_also_failed_past_expiry', + ), + ); + expect(failedAudit).toBeDefined(); + // Critical: no `expired_token` classification anywhere. + expect( + events.some( + (e) => + e.emission.type === 'failed' && + e.emission.data.errorKind === 'expired_token', + ), + ).toBe(false); + } finally { + registry.dispose(); + } + }); +}); + +describe('DeviceFlowRegistry — cancel', () => { + it('cancels a pending flow, emits cancelled, idempotent on terminal', async () => { + const provider = new FakeProvider(); + const built = buildRegistry(provider); + const { registry, events } = built; + try { + const { view: started } = await registry.start({ + providerId: 'qwen-oauth', + }); + + const result = registry.cancel(started.deviceFlowId, 'client-X'); + expect(result).toEqual({ alreadyTerminal: false }); + const cancelled = events.find((e) => e.emission.type === 'cancelled'); + expect(cancelled?.clientId).toBe('client-X'); + + // Second cancel is a no-op (no second event). + const second = registry.cancel(started.deviceFlowId, 'client-Y'); + expect(second).toEqual({ alreadyTerminal: true }); + expect( + events.filter((e) => e.emission.type === 'cancelled'), + ).toHaveLength(1); + } finally { + registry.dispose(); + } + }); + + it('returns undefined for unknown id', async () => { + const provider = new FakeProvider(); + const built = buildRegistry(provider); + try { + expect(built.registry.cancel('nonexistent', 'client-X')).toBeUndefined(); + } finally { + built.registry.dispose(); + } + }); +}); + +describe('DeviceFlowRegistry — dispose', () => { + it('clears all pending poll handles and the sweeper interval', async () => { + const provider = new FakeProvider(); + const built = buildRegistry(provider); + const { registry, env } = built; + await registry.start({ providerId: 'qwen-oauth' }); + expect(env.scheduler.callbacks.some((c) => !c.cancelled)).toBe(true); + expect(env.scheduler.intervals.some((i) => !i.cancelled)).toBe(true); + registry.dispose(); + expect(env.scheduler.callbacks.every((c) => c.cancelled)).toBe(true); + expect(env.scheduler.intervals.every((i) => i.cancelled)).toBe(true); + expect(registry.listPending()).toHaveLength(0); + }); +}); + +function runSweepers(env: { + clock: FakeClock; + scheduler: FakeScheduler; +}): void { + for (const interval of env.scheduler.intervals) { + if (!interval.cancelled) interval.cb(); + } +} + +async function flushAsync(): Promise { + // Five microtask flushes cover the longest synchronous chain inside + // `runPollTick`: `await provider.poll` → `await result.persist` → + // a few intermediate state-transition + publish microtasks. Five is + // enough headroom while still finishing in <1ms wall-clock. + for (let i = 0; i < 5; i += 1) await Promise.resolve(); +} diff --git a/packages/cli/src/serve/auth/deviceFlow.ts b/packages/cli/src/serve/auth/deviceFlow.ts new file mode 100644 index 000000000..104d76c7b --- /dev/null +++ b/packages/cli/src/serve/auth/deviceFlow.ts @@ -0,0 +1,1547 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Device-flow authorization registry for `qwen serve` (issue #4175 Wave 4 + * PR 21). The registry brokers an OAuth 2.0 Device Authorization Grant + * (RFC 8628) initiated through `POST /workspace/auth/device-flow` so a + * remote SDK client can ask the daemon to log in. Tokens land on the + * **daemon** filesystem, not the client — the client only displays the + * verification URL + user code. + * + * Key contracts (locked in `notes/pr21-design.md` §2): + * - per-`providerId` singleton (idempotent take-over for repeat POSTs) + * - workspace-wide cap of 4 active flows (abuse defense) + * - terminal entries kept for `TERMINAL_GRACE_MS` so SDK reconnects can + * still observe the result via GET + * - secrets (`device_code`, PKCE verifier) never appear in HTTP bodies, + * events, or logs — wrapped in a `BrandedSecret` whose `toJSON` returns + * `'[redacted]'` + * - polling state is owned by the daemon; SDK liveness is irrelevant + */ + +import { randomUUID } from 'node:crypto'; + +export const DEVICE_FLOW_DEFAULT_INTERVAL_MS = 5_000; +export const DEVICE_FLOW_TERMINAL_GRACE_MS = 5 * 60_000; +export const DEVICE_FLOW_SWEEP_INTERVAL_MS = 30_000; +export const DEVICE_FLOW_MAX_CONCURRENT = 4; +export const DEVICE_FLOW_SLOW_DOWN_BUMP_MS = 5_000; +/** + * Hard ceiling on `provider.persist()`. A wedged disk I/O (NFS stall, + * encrypted-volume contention) without this would leave a flow stuck + * in `pending` until the sweeper catches the upstream `expires_in` — + * potentially minutes. 30s is generous for a normal local FS write + * but short enough that operators see disk problems quickly. + * PR #4255 review C3. + */ +export const DEVICE_FLOW_PERSIST_TIMEOUT_MS = 30_000; +/** + * Hard ceiling on `provider.start()`. A hung IdP (network partition, + * unresponsive `requestDeviceAuthorization` endpoint) without this + * would leave the per-`providerId` slot in `inFlightStarts` occupied + * forever, blocking ALL subsequent `POST /workspace/auth/device-flow` + * requests for the same provider until daemon restart. 30s matches + * `DEVICE_FLOW_PERSIST_TIMEOUT_MS` and is well over typical IdP + * round-trip times for `device/code` (sub-second on a healthy IdP). + * PR #4255 review fold-in 3 (#2). + */ +export const DEVICE_FLOW_START_TIMEOUT_MS = 30_000; +/** + * Operator-safe upper bound on the IdP-provided `expires_in`. RFC + * 8628 §6.1 calls 5–30 minutes "reasonable"; 1 hour is the practical + * ceiling for any well-behaved IdP. PR #4255 fold-in 7 review thread + * #3: `Number.isFinite + > 0` keeps NaN/Infinity out, but a malicious + * or buggy IdP returning `1e12` still pins the per-provider singleton + * for years and ties up an entry slot the entire time. Clamping + * silently bounds the worst case to 1 hour — an IdP that genuinely + * needs longer is not RFC 8628 compliant. + */ +export const DEVICE_FLOW_MAX_EXPIRES_IN_SEC = 60 * 60; +/** + * Operator-safe lower bound on the IdP-provided `expires_in`. + * Symmetric with `DEVICE_FLOW_MAX_EXPIRES_IN_SEC`. PR #4255 round-12 + * #5 (gpt-5.5 review Cy_ZF): a misbehaving / fuzzed IdP returning + * `expires_in: 0.5` would produce `expiresAt = now() + 500 ms` — + * the very first poll (clamped at `>=1 s`) would fire AFTER + * `expiresAt` and the entry would expire before any user could + * authorize. RFC 8628 §3.2 calls 5–30 minutes "reasonable"; sub-30 s + * `expires_in` is effectively non-compliant. Floor lifts those + * pathological values to 30 s so the user gets at least one + * chance to complete the IdP page. + */ +export const DEVICE_FLOW_MIN_EXPIRES_IN_SEC = 30; +/** + * Upper bound on the polling interval. RFC 8628's normal `interval` + * + `slow_down` bumps live in the 5–30 s range; values past 60 s + * indicate an IdP misbehaving (or, more likely, `1e12` from a + * fuzzed/buggy response). Capping keeps `setTimeout` from being + * scheduled with a value that Node's scheduler clamps to + * `TIMEOUT_MAX` (≈24.8 d) — at which point the poll never fires + * within the entry's `expiresAt` window. PR #4255 fold-in 7 review + * thread #3. + */ +export const DEVICE_FLOW_MAX_INTERVAL_MS = 60_000; + +// PR #4255 fold-in 6 review thread #2: derive the type from the +// supported-providers tuple so adding/removing a provider id +// requires touching exactly ONE site. The earlier shape (standalone +// union + `readonly DeviceFlowProviderId[]` annotation) let the +// type and the array drift apart silently. Mirrors the codebase's +// `SERVE_ERROR_KINDS` / `ServeErrorKind` pattern in `status.ts`. +export const DEVICE_FLOW_SUPPORTED_PROVIDERS = ['qwen-oauth'] as const; +export type DeviceFlowProviderId = + (typeof DEVICE_FLOW_SUPPORTED_PROVIDERS)[number]; + +export type DeviceFlowStatus = + | 'pending' + | 'authorized' + | 'expired' + | 'error' + | 'cancelled'; + +/** + * Terminal error classifications surfaced on `auth_device_flow_failed`. + * + * RFC 8628 §3.5 defines the upstream error codes for the polling + * endpoint; the daemon adds one daemon-internal kind (`persist_failed`) + * for the disk-write phase. Keep these mutually exclusive — a + * mis-classification (e.g. routing a network error into + * `invalid_grant`) drives operators toward the wrong remediation. + */ +export type DeviceFlowErrorKind = + /** RFC 8628: device_code has aged out (`expires_in` elapsed + * upstream) before user authorization. Recovery: re-issue + * `client.auth.start`; daemon also surfaces this kind on its own + * time-based sweep when the entry's `expiresAt` passes. */ + | 'expired_token' + /** RFC 8628: user explicitly rejected the authorization at the + * IdP page. Recovery: re-issue with consent, or surface the + * refusal back to the human. */ + | 'access_denied' + /** RFC 8628: protocol-level violation — `device_code` / + * `client_id` / PKCE verifier didn't validate. Treat as a + * programmer error in the daemon's flow construction (the user + * can't fix this themselves). */ + | 'invalid_grant' + /** Catch-all for IdP-side failures that don't map to an RFC 8628 + * code: network errors, malformed JSON, 5xx responses, unknown + * error codes. Distinguished from `persist_failed` by the LOCATION + * of the failure (upstream HTTP vs daemon-local disk). */ + | 'upstream_error' + /** Daemon-local: the IdP exchange succeeded, but the daemon could + * not durably store the credentials (EACCES, EROFS, ENOSPC, etc.). + * Distinct from `upstream_error` so operators can route remediation + * to disk / permissions rather than chasing an IdP outage. The + * `device_code` was consumed upstream, so the user must + * `client.auth.start` again after fixing the underlying disk + * condition. + * + * @remarks + * **Lost-success / retry-after-persist-failed UX caveat.** When + * the failure originated from `provider.persist()` ignoring the + * registry's signal AND the underlying disk write later + * succeeded (PR #4255 fold-in 9 #7 — only reachable for + * non-conforming future providers; the Qwen provider honors + * signal end-to-end), the daemon emits + * `auth_device_flow_failed`/`persist_failed` to SSE while the + * credentials are silently on disk. A naive SDK retry (\"disk + * transient, try again\") will hit the IdP a second time with + * a fresh `device_code`, prompting the user a second time — + * but the FIRST credential set is already valid. If the second + * prompt times out without approval, the user is logged in + * (from the first lost-success persist) without realizing they + * retried. + * + * Mitigations for SDK consumers writing retry logic: + * - Call `client.auth.getStatus()` (`GET /workspace/auth/status`) + * before re-prompting on `persist_failed`. If the daemon + * reports an active credential for the provider, the previous + * persist committed and a retry would be redundant. + * - Operators can grep daemon stderr / audit log for + * `lost_success_after_timeout` to detect occurrences of the + * inconsistency window. */ + | 'persist_failed'; + +/** + * Phantom-branded opaque container for material that must never escape the + * registry boundary into HTTP responses, audit logs, or daemon events. + * + * **Why a frozen plain object, not `new String(value)`:** an earlier draft + * used a `String` wrapper with `toJSON` / `toString` overrides. Empirical + * test (and code-review pass): `"x=" + new String("foo")` evaluates to + * `"x=foo"` because `+` coerces via `Symbol.toPrimitive` → `valueOf` (which + * the `String` wrapper inherits and returns the raw primitive), NOT + * `toString`. Template literals (`${secret}`) take the same path. So a + * future commit that templated a `BrandedSecret` into a log line + * would silently leak the upstream device_code into stderr / journald. + * + * The current shape is a frozen plain object whose only string-coercion + * paths (`toString`, `toJSON`, `Symbol.toPrimitive`) all return + * `'[redacted]'`. The actual primitive is held in a module-level + * `WeakMap`, retrievable only via `unsafeRevealSecret`. Brand uses a `unique + * symbol` so other modules can't structurally satisfy it. + * + * Misuse paths and what they produce: + * `JSON.stringify({s: secret})` → `'{"s":"[redacted]"}'` + * `String(secret)` → `'[redacted]'` + * `'x=' + secret` → `'x=[redacted]'` + * `` `s=${secret}` `` → `'s=[redacted]'` + * `secret.length` → undefined (no String prototype) + * `+secret` → NaN + * `unsafeRevealSecret(secret)` → the original primitive (only path) + */ +const SECRET_BRAND: unique symbol = Symbol('DeviceFlowSecret'); + +export interface BrandedSecret { + readonly [SECRET_BRAND]: true; + /** All four string-coercion hooks return `'[redacted]'` so accidental + * serialization / interpolation cannot leak the underlying primitive. */ + toString(): '[redacted]'; + toJSON(): '[redacted]'; + [Symbol.toPrimitive](): '[redacted]'; + /** Phantom marker preserving the literal type at the type level so + * `BrandedSecret<'qwen-oauth'>` is distinguishable from + * `BrandedSecret` when a caller wants a narrower brand. */ + readonly _phantom?: T; +} + +const SECRETS = new WeakMap, string>(); + +export function brandSecret(value: T): BrandedSecret { + const wrapper: BrandedSecret = Object.freeze({ + [SECRET_BRAND]: true as const, + toString: () => '[redacted]' as const, + toJSON: () => '[redacted]' as const, + [Symbol.toPrimitive]: () => '[redacted]' as const, + }); + SECRETS.set(wrapper, value); + return wrapper; +} + +/** + * Reveal a branded secret. Callers must NOT pass the result back to event + * emitters, response bodies, or stderr without explicit redaction. The + * `unsafe`-prefixed name is intentional: greppable in code review, easy + * to allowlist in lint rules (`no-restricted-imports` / + * `no-restricted-syntax` keying off the identifier), and hard to + * invoke by accident or muscle memory. PR #4255 fold-in 5 review + * thread #2: renamed from `revealSecret` so the JSDoc-promised + * "greppable" property is actually the case in the codebase. + */ +export function unsafeRevealSecret( + secret: BrandedSecret, +): T { + const value = SECRETS.get(secret); + if (value === undefined) { + // The earlier message claimed "secret has been GC-evicted", but a + // `WeakMap` only evicts entries when the KEY object becomes + // unreachable — and if that happened, the caller couldn't hold a + // reference to pass in here. So the only path to `undefined` is + // an argument that was never registered (e.g. forged structural + // shape, mistakenly serialized + reparsed object that retained + // the public surface but lost the WeakMap binding). + throw new Error( + 'unsafeRevealSecret: argument is not a BrandedSecret (was never registered, or its WeakMap binding was lost via serialization)', + ); + } + return value as T; +} + +export interface DeviceFlowStartResult { + deviceCode: BrandedSecret; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + /** RFC 8628 §3.2 `expires_in` (seconds). */ + expiresIn: number; + /** Initial polling interval in seconds. RFC 8628 default = 5. */ + interval?: number; + pkceVerifier?: BrandedSecret; +} + +export type DeviceFlowPollResult = + | { kind: 'pending' } + | { kind: 'slow_down' } + | { + kind: 'success'; + /** The provider persists credentials and returns metadata for the + * `auth_device_flow_authorized` event. The registry passes its + * per-entry `cancelController.signal` so a slow disk I/O + * (NFS, encrypted volumes) honors `cancel()` / `dispose()`. + * + * PR #4255 review (post-fold-in-2 redirection): the earlier + * `unpersist()` companion was removed. When `persist()` succeeds + * AND a cancel/dispose transitioned the entry mid-await, the + * registry now FORCES the entry to `authorized` and keeps the + * on-disk credentials. Rationale: the user already approved on + * the IdP page (RFC 8628 device_code is single-use), so the + * microsecond cancel race shouldn't waste their approval. The + * audit trail records the race for incident response. + * + * @remarks + * **Provider-author contract — `signal` MUST be honored.** The + * registry races this promise against `DEVICE_FLOW_PERSIST_TIMEOUT_MS` + * (currently 30 s). When the timeout fires, the registry + * publishes `persist_failed` to SSE subscribers AND aborts + * `signal`. A non-cooperative provider that ignores `signal` + * and later commits credentials anyway leaves the daemon in a + * split-brain state: every SDK consumer sees `persist_failed` + * via SSE while the credentials are silently on disk. The + * registry detects this and emits a + * `lost_success_after_timeout` audit breadcrumb (PR #4255 + * fold-in 9 #7), but it cannot rescue the SDK consumers' + * view. The contract is therefore: every fs / network call + * inside `persist` MUST take `signal` as input AND propagate + * it down to abortable primitives (`fs.writeFile`, `fetch`, + * etc.). `cacheQwenCredentials({signal})` in + * `qwenDeviceFlowProvider` is the canonical example. */ + persist(opts: { signal: AbortSignal }): Promise<{ + expiresAt?: number; + accountAlias?: string; + }>; + } + | { + kind: 'error'; + errorKind: DeviceFlowErrorKind; + hint?: string; + }; + +export interface DeviceFlowProvider { + readonly providerId: DeviceFlowProviderId; + /** + * Begin a device-authorization grant against the IdP. Same SSE-leak + * sanitization rule as `poll()` applies to thrown error messages — + * see `poll()` `@remarks` below. + */ + start(opts: { signal: AbortSignal }): Promise; + /** + * Poll the upstream IdP for the user's authorization decision. The + * `signal` lets the registry abort an in-flight poll on `cancel()` + * or `dispose()` so the daemon doesn't keep consuming `device_code` + * quota after it's logically given up. Providers that pass `signal` + * to their `fetch` get cleanest tear-down; those that ignore it + * still see the post-`await` guard suppress the resolved frame. + * + * @remarks + * **Provider-author contract — sanitize before throwing.** The + * registry's `runPollTick` catch block forwards `err.message` + * verbatim into the `auth_device_flow_failed` event's `hint` + * field, which is workspace-broadcast over SSE to every subscriber + * (and durably stored in the registry's terminal entry). A naive + * provider that re-throws a `fetch` failure or upstream payload + * untouched will leak: (a) full IdP response bodies (HTML error + * pages from a reverse proxy / WAF can run into hundreds of + * kilobytes), (b) infrastructure detail (internal hostnames, proxy + * banners), (c) ANY embedded secret material the upstream + * accidentally echoed. + * + * Two equally-correct paths for new providers: + * 1. **Resolve to a typed `error` result** — return + * `{ kind: 'error', errorKind, hint }` with a *bounded + * static-or-pattern hint*. This is the preferred path; it + * keeps full structured-error fidelity and drops nothing. + * 2. **Throw, but only with a sanitized `Error.message`** — if + * the implementation finds it more natural to throw, + * construct the thrown `Error` with a *short bounded sentence + * that contains no IdP body / banner / secret*. Send the raw + * detail through `writeStderrLine` for operator audit; the + * thrown `message` is the SSE-visible surface. + * + * `qwenDeviceFlowProvider` is the canonical example — see PR #4255 + * review S2 + fold-in 3 #9 + fold-in 5 #4 for the historical + * regressions this contract prevents. + */ + poll( + state: { + deviceCode: BrandedSecret; + pkceVerifier?: BrandedSecret; + }, + opts: { signal: AbortSignal }, + ): Promise; +} + +/** Public, redacted view of a flow returned by GET /workspace/auth/device-flow/:id. */ +export interface DeviceFlowPublicView { + deviceFlowId: string; + providerId: DeviceFlowProviderId; + status: DeviceFlowStatus; + errorKind?: DeviceFlowErrorKind; + hint?: string; + /** Pending only: redisplayed on reconnect so the SDK can re-render the + * user_code prompt without persisting it client-side. Terminal entries + * drop these. */ + userCode?: string; + verificationUri?: string; + verificationUriComplete?: string; + expiresAt?: number; + intervalMs?: number; + lastPolledAt?: number; + createdAt: number; + initiatorClientId?: string; +} + +/** Outbound event-payload shapes (mirrors SDK `DaemonAuth*` data types). */ +export type DeviceFlowEventEmission = + | { + type: 'started'; + data: { + deviceFlowId: string; + providerId: DeviceFlowProviderId; + expiresAt: number; + }; + } + | { type: 'throttled'; data: { deviceFlowId: string; intervalMs: number } } + | { + type: 'authorized'; + data: { + deviceFlowId: string; + providerId: DeviceFlowProviderId; + expiresAt?: number; + accountAlias?: string; + }; + } + | { + type: 'failed'; + data: { + deviceFlowId: string; + errorKind: DeviceFlowErrorKind; + hint?: string; + }; + } + | { type: 'cancelled'; data: { deviceFlowId: string } }; + +export interface DeviceFlowEventSink { + /** Best-effort fan-out. The sink swallows its own internal errors so a + * misbehaving subscriber can't poison the registry's state machine. */ + publish(emission: DeviceFlowEventEmission, originatorClientId?: string): void; +} + +export interface DeviceFlowAuditSink { + /** Structured stderr audit breadcrumb. `mutate({strict:true})` doesn't + * carry an audit hook; PR 21 §8 #9 mandates a parallel log channel. */ + record(line: { + deviceFlowId: string; + providerId: DeviceFlowProviderId; + clientId?: string; + status: 'started' | 'authorized' | 'failed' | 'cancelled' | 'expired'; + errorKind?: DeviceFlowErrorKind; + expiresInMs?: number; + /** Free-form audit detail. Used by the C4 lost-success rollback + * path to capture rollback failures without polluting the + * user-facing event hint. */ + hint?: string; + }): void; +} + +interface DeviceFlowEntry { + deviceFlowId: string; + providerId: DeviceFlowProviderId; + deviceCode?: BrandedSecret; + pkceVerifier?: BrandedSecret; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + intervalMs: number; + expiresAt: number; + status: DeviceFlowStatus; + errorKind?: DeviceFlowErrorKind; + hint?: string; + initiatorClientId?: string; + /** + * Most-recent client id observed on a take-over POST (per-provider + * singleton). Initially `undefined`; populated only when a second + * caller's `initiatorClientId` differs from `entry.initiatorClientId`. + * Surfaced through the audit trail so incident response can see + * "client A started this flow, client B took it over at 12:34" — + * useful when two SDK processes race on the same Qwen account + * across hosts. Event-routing still uses the original + * `initiatorClientId` (events are workspace-broadcast; the + * originator field is metadata, and changing it mid-flow would + * break SDK reducers that key on it). PR #4255 fold-in 6 review + * thread #6. + */ + lastOriginatorClientId?: string; + lastPolledAt?: number; + createdAt: number; + terminalAt?: number; + pollHandle?: ReturnType; + cancelController: AbortController; + /** + * `true` while `provider.persist()` is awaiting on disk I/O. While + * set, `cancel()` and the sweeper SKIP transitioning + emitting — + * only the persist resolution finalizes the terminal state. This + * prevents the SDK-event-stream UX trap where direct subscribers + * would see `auth_device_flow_cancelled` followed by + * `auth_device_flow_authorized` for the same flow (reducer-state + * converges correctly via last-write-wins, but imperative event + * handlers — close-dialog / release-resource / log-telemetry — + * race onto an unmounted UI). PR #4255 fold-in 5 review thread #1. + */ + persistInFlight?: boolean; + /** + * Set by `cancel()` if it ran while `persistInFlight === true`. The + * persist resolution branch reads this to decide which terminal + * event to emit: + * - persist succeeded → `authorized` (IdP approval wins; the + * cancel-during-persist race resolves toward the user's + * completed browser approval per fold-in 3's C4 reversal). + * - persist failed (incl. abort fired by `cancel()`) → `cancelled` + * (the cancel got its way; no credentials on disk). + */ + cancelRequestedDuringPersist?: boolean; + /** + * Client id of the SDK caller that invoked `cancel()` (via + * `DELETE /workspace/auth/device-flow/:id`'s + * `X-Qwen-Client-Id`). Stamped only on the in-flight + * persist-defer path so the persist resolution branch's deferred + * event publish + audit can attribute the cancel back to the + * actual canceller, not the original initiator. PR #4255 fold-in + * 9 review thread #5. + */ + cancellerClientId?: string; +} + +export interface DeviceFlowRegistryDeps { + events: DeviceFlowEventSink; + audit?: DeviceFlowAuditSink; + /** Provider lookup. Tests stub a fake provider; production wires the + * Qwen-OAuth implementation. */ + resolveProvider( + providerId: DeviceFlowProviderId, + ): DeviceFlowProvider | undefined; + /** Inject a clock for deterministic tests. Defaults to `Date.now`. */ + now?: () => number; + /** Inject a scheduler. Defaults to `setTimeout`. */ + schedule?: (ms: number, cb: () => void) => ReturnType; + /** Inject a sweeper interval. Defaults to `setInterval`. */ + scheduleInterval?: ( + ms: number, + cb: () => void, + ) => ReturnType; + clearScheduled?: (handle: ReturnType) => void; + clearScheduledInterval?: (handle: ReturnType) => void; +} + +export interface DeviceFlowStartParams { + providerId: DeviceFlowProviderId; + initiatorClientId?: string; +} + +/** + * Thrown when `DeviceFlowRegistry.start()` cannot resolve a + * `DeviceFlowProvider` for the supplied `providerId`. + * + * **Reachability:** the route layer (`server.ts`) already screens + * unknown ids against `DEVICE_FLOW_SUPPORTED_PROVIDERS` and returns + * `400 invalid_request` BEFORE reaching the registry — so this error + * is reachable only on a daemon-internal invariant violation: + * `DEVICE_FLOW_SUPPORTED_PROVIDERS` declares an id but the runtime + * `resolveProvider` map doesn't carry an implementation for it + * (e.g. forgot to register a provider for a newly-added id, or a + * test harness omitted it). The `code` field stays + * `'unsupported_provider'` for backward-compat with any test that + * may have asserted on it; the route layer maps to `400` for + * symmetry with the user-input path even though this branch + * indicates a programmer error rather than user error. PR #4255 + * fold-in 4 review thread E. + */ +export class UnsupportedDeviceFlowProviderError extends Error { + readonly code = 'unsupported_provider'; + constructor(providerId: string) { + super( + `Unsupported device-flow provider (internal: declared but not registered): ${providerId}`, + ); + this.name = 'UnsupportedDeviceFlowProviderError'; + } +} + +export class TooManyActiveDeviceFlowsError extends Error { + readonly code = 'too_many_active_flows'; + constructor() { + super( + `Too many active device-flow attempts. Cancel one of the existing ` + + `flows or wait for them to expire.`, + ); + this.name = 'TooManyActiveDeviceFlowsError'; + } +} + +// PR #4255 review S3: `DeviceFlowNotFoundError` was exported but never +// imported anywhere — the route handlers handle the not-found case +// inline with `res.status(404).json(...)`. Removed to avoid dead-code +// rot. Future routes that prefer typed-error flow can re-introduce it. + +export class UpstreamDeviceFlowError extends Error { + readonly code = 'upstream_error'; + constructor(message: string) { + super(message); + this.name = 'UpstreamDeviceFlowError'; + } +} + +/** + * Typed accessors for parking the `DeviceFlowRegistry` on + * `express.Application['locals']`. The string key is shared between + * `createServeApp` (writer) and `runQwenServe`'s shutdown drain + * (reader); without typed setter/getter, a typo in either site + * would compile cleanly and the dispose call would silently no-op, + * leaving polling timers hanging until process `unref()`-driven + * exit. PR #4255 fold-in 4 review thread D. + */ +const DEVICE_FLOW_REGISTRY_LOCAL = 'deviceFlowRegistry' as const; + +interface DeviceFlowAppLocals { + [DEVICE_FLOW_REGISTRY_LOCAL]?: DeviceFlowRegistry; +} + +export function setDeviceFlowRegistry( + app: { locals: Record }, + registry: DeviceFlowRegistry, +): void { + (app.locals as DeviceFlowAppLocals)[DEVICE_FLOW_REGISTRY_LOCAL] = registry; +} + +export function getDeviceFlowRegistry(app: { + locals: Record; +}): DeviceFlowRegistry | undefined { + return (app.locals as DeviceFlowAppLocals)[DEVICE_FLOW_REGISTRY_LOCAL]; +} + +/** + * In-memory device-flow state holder. Single instance per daemon. + * + * Lifecycle: `runQwenServe` constructs one, hands it to `createServeApp`, + * and calls `dispose()` during shutdown drain so every pending poll timer + * is cancelled before the process exits. + */ +export class DeviceFlowRegistry { + private readonly byId = new Map(); + private readonly byProvider = new Map< + DeviceFlowProviderId, + DeviceFlowEntry + >(); + /** + * Coalesces concurrent `start()` calls for the same `providerId`. Two + * SDK clients posting `POST /workspace/auth/device-flow` in parallel + * would otherwise both pass the "no existing pending entry" check, + * each call `provider.start()` (a real IdP round-trip), and one's + * write to `byProvider` would clobber the other — leaving an orphan + * `byId` entry with a still-running poll timer that consumes IdP + * quota for nothing. Mirrors `SharedTokenManager`'s in-flight refresh + * coalescing pattern. + */ + private readonly inFlightStarts = new Map< + DeviceFlowProviderId, + Promise<{ view: DeviceFlowPublicView; attached: boolean }> + >(); + private sweeperHandle?: ReturnType; + private disposed = false; + private readonly now: () => number; + private readonly schedule: ( + ms: number, + cb: () => void, + ) => ReturnType; + private readonly scheduleInterval: ( + ms: number, + cb: () => void, + ) => ReturnType; + private readonly clearScheduled: ( + handle: ReturnType, + ) => void; + private readonly clearScheduledInterval: ( + handle: ReturnType, + ) => void; + + constructor(private readonly deps: DeviceFlowRegistryDeps) { + this.now = deps.now ?? (() => Date.now()); + this.schedule = deps.schedule ?? ((ms, cb) => setTimeout(cb, ms)); + this.scheduleInterval = + deps.scheduleInterval ?? ((ms, cb) => setInterval(cb, ms)); + this.clearScheduled = deps.clearScheduled ?? ((h) => clearTimeout(h)); + this.clearScheduledInterval = + deps.clearScheduledInterval ?? ((h) => clearInterval(h)); + // Sweeper is best-effort GC; never block process exit waiting for it. + this.sweeperHandle = this.scheduleInterval( + DEVICE_FLOW_SWEEP_INTERVAL_MS, + () => this.sweep(), + ); + if ( + this.sweeperHandle && + typeof (this.sweeperHandle as { unref?: () => void }).unref === 'function' + ) { + (this.sweeperHandle as unknown as { unref(): void }).unref(); + } + } + + /** + * Start a new device flow OR — under per-provider singleton semantics — + * return the existing pending entry (`attached: true`). The take-over + * branch deliberately does NOT re-call `provider.start()`; making the + * second POST a no-op (rather than a fresh IdP request) is the property + * that lets a reconnecting SDK pick up an in-flight login without + * burning IdP quota. + */ + async start( + params: DeviceFlowStartParams, + ): Promise<{ view: DeviceFlowPublicView; attached: boolean }> { + if (this.disposed) { + throw new Error('DeviceFlowRegistry disposed'); + } + const provider = this.deps.resolveProvider(params.providerId); + if (!provider) { + throw new UnsupportedDeviceFlowProviderError(params.providerId); + } + // Fast-path: an existing pending entry → idempotent take-over. + const existing = this.byProvider.get(params.providerId); + if (existing && existing.status === 'pending') { + this.recordTakeover(existing, params.initiatorClientId); + return { view: toPublicView(existing), attached: true }; + } + // Coalesce concurrent fresh starts for the same providerId. + const inFlight = this.inFlightStarts.get(params.providerId); + if (inFlight) { + const result = await inFlight; + // The first start created an entry; this caller is a take-over of + // the just-created flow (NOT a fresh IdP request). Recompute the + // shape so the second caller's `attached: true` is honest. PR + // #4255 fold-in 6 #6: also stamp the second caller's id on the + // entry's `lastOriginatorClientId` so audit shows the take-over. + const justCreated = this.byProvider.get(params.providerId); + if (justCreated) { + this.recordTakeover(justCreated, params.initiatorClientId); + } + return { view: result.view, attached: true }; + } + if (this.countActive() >= DEVICE_FLOW_MAX_CONCURRENT) { + throw new TooManyActiveDeviceFlowsError(); + } + const promise = this.doStart(params, provider); + this.inFlightStarts.set(params.providerId, promise); + try { + return await promise; + } finally { + // Whether `doStart` resolved or rejected, the in-flight slot + // releases so a follow-up caller observes the freshly-installed + // entry (or, on reject, can try again from scratch). + if (this.inFlightStarts.get(params.providerId) === promise) { + this.inFlightStarts.delete(params.providerId); + } + } + } + + private async doStart( + params: DeviceFlowStartParams, + provider: DeviceFlowProvider, + ): Promise<{ view: DeviceFlowPublicView; attached: boolean }> { + const cancelController = new AbortController(); + // PR #4255 fold-in 3 #2 + fold-in 7 #1: bound `provider.start()` + // with an authoritative registry-side timeout via `Promise.race`. + // The earlier shape only ABORTED the signal on timeout — but a + // provider that ignored the signal (non-abortable I/O, future + // implementer who forgot to thread `signal` to `fetch`) would + // leave the `await` hanging forever, pinning the `inFlightStarts` + // slot until daemon restart. Racing against a rejecting timer + // makes the timeout authoritative regardless of provider + // cooperation, while the abort still lets cooperative providers + // tear down their in-flight `fetch` for cleanup. + let startResult: DeviceFlowStartResult; + let startTimer: ReturnType | undefined; + try { + startResult = await new Promise( + (resolve, reject) => { + startTimer = this.schedule(DEVICE_FLOW_START_TIMEOUT_MS, () => { + try { + cancelController.abort(new Error('device-flow start timeout')); + } catch { + // best-effort + } + // PR #4255 fold-in 9 review thread #9: reject with the + // typed `UpstreamDeviceFlowError` so the route layer + // maps to `502 upstream_error` (the same envelope every + // other IdP start failure uses). A hung IdP is a + // textbook upstream-not-responding scenario from the + // SDK consumer's POV; surfacing it as a generic 500 via + // `sendBridgeError`'s default fall-through was + // misleading. + reject( + new UpstreamDeviceFlowError( + 'device-flow start timeout (upstream IdP unresponsive)', + ), + ); + }); + provider + .start({ signal: cancelController.signal }) + .then(resolve, reject); + }, + ); + } finally { + if (startTimer !== undefined) this.clearScheduled(startTimer); + } + // PR #4255 review S6: dispose() may have run while we awaited + // `provider.start()`. If we proceed past this point the resulting + // entry would land in `byId` / `byProvider` AFTER `dispose()` + // already cleared them, leaving an orphan that has no poll + // scheduled (because `schedulePoll` guards on `this.disposed`) + // and never transitions. Bail out — the secrets in `startResult` + // are inaccessible to the caller (we threw), and the IdP-issued + // device_code is left to expire upstream on its own clock. + if (this.disposed) { + throw new Error('DeviceFlowRegistry disposed during start'); + } + // PR #4255 fold-in 4 (review thread A): Provider's contract types + // `expiresIn: number`, but a misbehaving / future provider could + // hand us `undefined` / `NaN` / `Infinity`. `Math.max(0, NaN) * + // 1000` is `NaN`; `now() + NaN` is `NaN`; `now >= NaN` is always + // `false`, so the sweeper would NEVER evict the entry — pinning + // an upstream `device_code` slot until daemon restart. Reject + // non-finite-positive values and fall back to RFC 8628's + // suggested ceiling (10 min) so the entry still expires. + // + // PR #4255 fold-in 7 review thread #3: also clamp the upper end + // — an extreme finite value like `1e12` is finite-and-positive + // but would pin the singleton for ~30,000 years. Cap at + // `DEVICE_FLOW_MAX_EXPIRES_IN_SEC` so a malformed/malicious IdP + // can't tie up a per-provider slot beyond an operator-safe + // bound. + // PR #4255 round-12 #5 (Cy_ZF): symmetric upper + lower bounds. + // The `MAX` clamp defends against `1e12` (year-pinning); the + // new `MIN` floor defends against `0.5` (entry expires before + // the first poll fires). + const expiresInSec = + Number.isFinite(startResult.expiresIn) && startResult.expiresIn > 0 + ? Math.min( + DEVICE_FLOW_MAX_EXPIRES_IN_SEC, + Math.max(DEVICE_FLOW_MIN_EXPIRES_IN_SEC, startResult.expiresIn), + ) + : 600; + const expiresAt = this.now() + expiresInSec * 1000; + // Same defense for `interval`: a non-finite-positive value would + // schedule a `setTimeout(NaN)` (fires immediately) or + // `setTimeout(Infinity)` (scheduler clamps to TIMEOUT_MAX). RFC + // 8628 recommends a 5s default when the IdP omits `interval`. + // PR #4255 fold-in 7 review thread #3: also clamp upper bound — + // `interval: 1e12` is finite-and-positive but Node's scheduler + // would either clamp to TIMEOUT_MAX (≈24.8 d, never fires within + // the entry's expiresAt) or drop. Cap at + // `DEVICE_FLOW_MAX_INTERVAL_MS` so the poll fires within a + // reasonable window regardless of upstream input. + const intervalSec = + Number.isFinite(startResult.interval) && + (startResult.interval as number) > 0 + ? (startResult.interval as number) + : DEVICE_FLOW_DEFAULT_INTERVAL_MS / 1000; + const intervalMs = Math.min( + DEVICE_FLOW_MAX_INTERVAL_MS, + Math.max(1_000, intervalSec * 1000), + ); + const entry: DeviceFlowEntry = { + deviceFlowId: randomUUID(), + providerId: params.providerId, + deviceCode: startResult.deviceCode, + pkceVerifier: startResult.pkceVerifier, + userCode: startResult.userCode, + verificationUri: startResult.verificationUri, + verificationUriComplete: startResult.verificationUriComplete, + intervalMs, + expiresAt, + status: 'pending', + initiatorClientId: params.initiatorClientId, + createdAt: this.now(), + cancelController, + }; + this.byId.set(entry.deviceFlowId, entry); + this.byProvider.set(entry.providerId, entry); + this.deps.events.publish( + { + type: 'started', + data: { + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + expiresAt: entry.expiresAt, + }, + }, + entry.initiatorClientId, + ); + this.deps.audit?.record({ + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + clientId: entry.initiatorClientId, + status: 'started', + expiresInMs: entry.expiresAt - this.now(), + }); + this.schedulePoll(entry, provider); + return { view: toPublicView(entry), attached: false }; + } + + get(deviceFlowId: string): DeviceFlowPublicView | undefined { + const entry = this.byId.get(deviceFlowId); + if (!entry) return undefined; + return toPublicView(entry); + } + + /** + * Cancel a pending flow. Idempotent on terminal entries (returns + * `{ alreadyTerminal: true }` and does NOT re-emit `cancelled` — + * RFC 7231 §4.3.5: DELETE may still be a 204 even when nothing was + * removed). Returns `undefined` for unknown ids so the route layer + * can map it to 404. + */ + cancel( + deviceFlowId: string, + cancellerClientId?: string, + ): { alreadyTerminal: boolean } | undefined { + const entry = this.byId.get(deviceFlowId); + if (!entry) return undefined; + // PR #4255 fold-in 5 review thread #1: if `provider.persist()` is + // currently in flight, DEFER the transition + event emission to + // the persist resolution branch. Aborting the signal still gives + // `fs.writeFile` a chance to short-circuit; the persist resolution + // looks at `cancelRequestedDuringPersist` to decide whether to + // emit `cancelled` (persist aborted in time) or `authorized` + // (persist committed before the abort fired — IdP wins per + // fold-in 3 C4). This eliminates the cancelled→authorized event + // sequence that would have raced onto a listener that already + // closed its dialog. + if (entry.persistInFlight) { + entry.cancelRequestedDuringPersist = true; + // PR #4255 fold-in 9 review thread #5: stash the canceller's + // client id on the entry so the persist resolution branch + // (which actually emits the deferred event) can attribute it + // to the SDK that asked to cancel, not the original + // initiator. Without this, the cancellation event's + // `originatorClientId` was always `entry.initiatorClientId`, + // which broke any SSE consumer that suppresses self-emitted + // events to avoid double-handling. + if (cancellerClientId) { + entry.cancellerClientId = cancellerClientId; + } + try { + entry.cancelController.abort(new Error('cancel during persist')); + } catch { + // best-effort + } + // Audit the deferred cancel so operators can correlate. + this.deps.audit?.record({ + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + clientId: cancellerClientId, + status: 'cancelled', + hint: 'deferred (persist in flight; final state decided by persist resolution)', + }); + return { alreadyTerminal: false }; + } + if (!this.transitionTerminal(entry, 'cancelled')) { + return { alreadyTerminal: true }; + } + this.deps.events.publish( + { + type: 'cancelled', + data: { deviceFlowId: entry.deviceFlowId }, + }, + cancellerClientId, + ); + this.deps.audit?.record({ + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + clientId: cancellerClientId, + status: 'cancelled', + }); + return { alreadyTerminal: false }; + } + + /** + * Active = pending entries already installed in `byProvider` PLUS + * in-flight starts that haven't yet completed `provider.start()`. + * Terminal entries in grace don't count toward the cap. + * + * PR #4255 round-13 #1 (gpt-5.5 review C1gh0): including + * `inFlightStarts.size` here closes a workspace-wide cap bypass. + * Concurrent starts for `DEVICE_FLOW_MAX_CONCURRENT + 1` DISTINCT + * providers all run to their first await synchronously: each + * checks the cap before any has populated `byProvider`, and each + * passes (count = 0). All `MAX+1` then `await provider.start()`, + * eventually installing more than the documented four pending + * flows. Adding `inFlightStarts.size` makes the accounting + * include the not-yet-installed reservations — the second + * concurrent caller sees `count = 1`, the third `count = 2`, and + * so on. `byProvider` and `inFlightStarts` are disjoint by + * construction (the existing-pending-entry fast-path catches any + * provider with both), so simple addition cannot double-count. + */ + private countActive(): number { + let n = 0; + for (const entry of this.byProvider.values()) { + if (entry.status === 'pending') n += 1; + } + return n + this.inFlightStarts.size; + } + + private schedulePoll(entry: DeviceFlowEntry, provider: DeviceFlowProvider) { + if (entry.status !== 'pending') return; + if (entry.deviceCode === undefined) return; + if (this.disposed) return; + entry.pollHandle = this.schedule(entry.intervalMs, () => { + // Fire-and-forget; the poll handler does its own error containment. + void this.runPollTick(entry, provider); + }); + if ( + entry.pollHandle && + typeof (entry.pollHandle as { unref?: () => void }).unref === 'function' + ) { + (entry.pollHandle as unknown as { unref(): void }).unref(); + } + } + + private async runPollTick( + entry: DeviceFlowEntry, + provider: DeviceFlowProvider, + ): Promise { + if (entry.status !== 'pending') return; + if (this.disposed) return; + if (entry.deviceCode === undefined) return; + const now = this.now(); + if (now >= entry.expiresAt) { + this.expireEntry(entry); + return; + } + entry.lastPolledAt = now; + let result: DeviceFlowPollResult; + let rawProviderError: string | undefined; + try { + result = await provider.poll( + { + deviceCode: entry.deviceCode, + pkceVerifier: entry.pkceVerifier, + }, + { signal: entry.cancelController.signal }, + ); + } catch (err: unknown) { + // PR #4255 fold-in 9 review thread #1 (refines fold-in 8 #1): + // a non-conforming provider that violates the `@remarks` + // sanitization contract by throwing raw IdP detail must NOT + // leak ANY of that detail to SSE subscribers. fold-in 8 + // truncated to 256 chars but still forwarded the prefix; that + // prefix can still carry secret material (`device_code` if + // the provider templated it into the message, internal + // hostnames, etc.). Use a STATIC bounded hint here as the + // outermost defense layer; the full raw `err.message` flows + // through the audit channel (whose backing impl writes to + // stderr) for operator visibility. + rawProviderError = err instanceof Error ? err.message : String(err); + result = { + kind: 'error', + errorKind: 'upstream_error', + hint: 'provider.poll() failed; see daemon audit log for details', + }; + } + // PR #4255 round-12 #1 (gpt-5.5 review CzSpN): also re-check + // `this.disposed` after the await. `dispose()` clears + // `this.byId` / `this.byProvider` and aborts the entry's + // signal but doesn't mutate the captured `entry.status` (it + // wipes secrets but leaves the status field untouched). A + // provider that already resolved or doesn't honor abort can + // therefore enter the `success` branch below and call + // `result.persist({...})` — committing credentials on a + // shutting-down daemon. Same shape as the disposal guard + // already present at the top of the method (line 948); this + // closes the post-await window. + if (this.disposed) return; + if (entry.status !== 'pending') return; + switch (result.kind) { + case 'pending': + this.schedulePoll(entry, provider); + return; + case 'slow_down': + // PR #4255 round-12 #4 (Cy_Y9): re-clamp against + // `DEVICE_FLOW_MAX_INTERVAL_MS`. A misbehaving / malicious + // IdP that keeps returning `slow_down` would otherwise + // push `intervalMs` past the documented bound — eventually + // past Node's `TIMEOUT_MAX` (≈24.8 d) at which point the + // poll never fires within `expiresAt`. Each bump is only + // 5 s so reaching `TIMEOUT_MAX` is impractical, but the + // invariant `intervalMs <= MAX_INTERVAL_MS` is documented + // as load-bearing in `DEVICE_FLOW_MAX_INTERVAL_MS`'s + // JSDoc. Symmetric with the doStart clamp. + entry.intervalMs = Math.min( + DEVICE_FLOW_MAX_INTERVAL_MS, + entry.intervalMs + DEVICE_FLOW_SLOW_DOWN_BUMP_MS, + ); + this.deps.events.publish( + { + type: 'throttled', + data: { + deviceFlowId: entry.deviceFlowId, + intervalMs: entry.intervalMs, + }, + }, + entry.initiatorClientId, + ); + this.schedulePoll(entry, provider); + return; + case 'success': { + // PR #4255 review C3 + fold-in 5 #1 + fold-in 7 #2: bound + // persist() with both the entry's cancelController signal + // AND an authoritative registry-side timeout via + // `Promise.race`. The earlier shape only ABORTED the signal + // on timeout — but a provider whose `persist()` performs + // non-abortable I/O (a future provider that does `mkdir` / + // `chmod` / `mv` outside the abortable `fs.writeFile` + // pathway) would leave this `await` hanging until process + // restart, pinning the flow in `pending` and blocking + // same-provider starts. Racing against a rejecting timer + // makes the timeout authoritative regardless of provider + // cooperation; on rejection we fall through to the error + // branch which maps to `persist_failed`. + // + // Set `entry.persistInFlight` for the duration so `cancel()` + // and the sweeper SKIP transition+emit during this window — + // they just register intent (or no-op) and let the persist + // resolution decide the terminal state. + let metadata: { expiresAt?: number; accountAlias?: string } = {}; + let persistError: unknown; + let persistTimer: ReturnType | undefined; + let persistTimedOut = false; + entry.persistInFlight = true; + // PR #4255 fold-in 9 review thread #7: track the original + // `result.persist(...)` promise INDEPENDENTLY of the race + // wrapper so a non-cooperative provider that ignores + // `signal` can't silently commit credentials AFTER the + // registry already published `persist_failed`. Reachable + // scenario: provider's persist runs `mkdir`/`chmod`/`mv` + // outside the abortable `fs.writeFile` pathway and the + // disk write succeeds 100 ms after the 30 s timeout fires + // — daemon now has credentials on disk while every SSE + // subscriber thinks the login failed. + // + // The Qwen provider is signal-honoring (see fold-in 3 #10) + // so this is forward-defense for future providers. We + // can't pre-commit-rollback (`fs.unlink` would race with + // provider-internal state) so the contract stays + // "provider's persist MUST honor signal"; this tracker + // catches violations and emits a `lost_success_after_timeout` + // audit breadcrumb so operators see the inconsistency. + // PR #4255 round-12 #2 (gpt-5.5 review Cy_ZG): defensively + // wrap the `result.persist({signal})` call in a try/catch. + // The persist invocation happens BEFORE the surrounding + // `new Promise` constructor (the tracker is captured by + // reference inside the constructor), so a synchronous throw + // from a non-conforming provider — e.g. a top-of-function + // validation `if (!signal) throw …` — would NOT be caught + // by the outer try/catch around `await new Promise(...)`. + // `runPollTick` is invoked via `void this.runPollTick(...)` + // so the escaped throw becomes an `unhandledRejection`. The + // try/catch routes it through the same persistError path + // that handles a rejected-promise return. + let persistTracker: Promise<{ + expiresAt?: number; + accountAlias?: string; + }>; + try { + persistTracker = result.persist({ + signal: entry.cancelController.signal, + }); + } catch (syncErr) { + persistError = syncErr; + persistTracker = Promise.reject(syncErr); + // Suppress the unhandled-rejection warning on the + // synthetic rejected promise — we own the recovery via + // `persistError` and the lost_success branch below + // explicitly catches its rejection too. + persistTracker.catch(() => {}); + } + persistTracker.then( + (lateMeta) => { + // Only fire when the race was timed out AND the underlying + // persist later succeeded — that's the inconsistency + // window. Happy-path resolution (race accepted the value) + // leaves `persistTimedOut === false`. + if (!persistTimedOut) return; + this.deps.audit?.record({ + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + clientId: entry.initiatorClientId, + status: 'authorized', + hint: `lost_success_after_timeout (provider.persist ignored timeout signal; credentials on disk but registry already published persist_failed; expiresAt=${lateMeta.expiresAt ?? 'unknown'})`, + }); + }, + () => { + // Late rejection after the race already drove the + // terminal — no-op (persistError carries the original + // failure, the registry already published it). + }, + ); + if (persistError === undefined) { + try { + metadata = await new Promise<{ + expiresAt?: number; + accountAlias?: string; + }>((resolve, reject) => { + persistTimer = this.schedule( + DEVICE_FLOW_PERSIST_TIMEOUT_MS, + () => { + persistTimedOut = true; + try { + entry.cancelController.abort(new Error('persist timeout')); + } catch { + // best-effort + } + reject(new Error('persist timeout')); + }, + ); + persistTracker.then(resolve, reject); + }); + } catch (err: unknown) { + persistError = err; + } finally { + if (persistTimer !== undefined) this.clearScheduled(persistTimer); + entry.persistInFlight = false; + } + } else { + // Sync-throw branch: skip the race entirely (we already + // have persistError) but reset persistInFlight so the + // sweeper / cancel can resume their normal posture. + entry.persistInFlight = false; + } + if (this.disposed) return; + const cancelDuringPersist = entry.cancelRequestedDuringPersist === true; + if (persistError) { + // Persist failed (abort triggered by cancel/timeout, or a + // genuine fs error). Two terminal mappings: + // 1. cancelDuringPersist → `cancelled` (user cancel won) + // 2. otherwise → `error`/`persist_failed` (genuine disk + // fault — even if now >= expiresAt) + // + // PR #4255 fold-in 9 review thread #13: previously a + // persist-fail × past-`expiresAt` path classified as + // `expired`/`expired_token`, which routed operator + // remediation toward "user re-authenticates" (RFC 8628 + // expiry semantic) when the actual root cause was a disk + // I/O failure. `persist_failed` was specifically designed + // for this scenario (see DeviceFlowErrorKind JSDoc): + // distinct from `expired_token` so operators see "fix + // disk" rather than "tell user to retry." The + // past-expiresAt detail is preserved on the audit hint + // for incident-response visibility. + if (cancelDuringPersist) { + if (this.transitionTerminal(entry, 'cancelled')) { + // PR #4255 fold-in 9 review thread #5: emit on the + // canceller's client id (recorded by `cancel()` on + // the entry), falling back to the initiator only + // when no canceller id was supplied. SSE consumers + // that suppress self-emitted events can now + // attribute the cancel correctly. + this.deps.events.publish( + { + type: 'cancelled', + data: { deviceFlowId: entry.deviceFlowId }, + }, + entry.cancellerClientId ?? entry.initiatorClientId, + ); + } + } else if ( + this.transitionTerminal(entry, 'error', 'persist_failed') + ) { + // S1 sanitize: full err detail goes through stderr audit + // (debugLogger inside cacheQwenCredentials); only a + // bounded sentence flows to SSE subscribers. + const pastExpiry = this.now() >= entry.expiresAt; + this.deps.events.publish( + { + type: 'failed', + data: { + deviceFlowId: entry.deviceFlowId, + errorKind: 'persist_failed', + hint: 'credentials could not be written to the daemon filesystem — check disk space and permissions', + }, + }, + entry.initiatorClientId, + ); + this.deps.audit?.record({ + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + clientId: entry.initiatorClientId, + status: 'failed', + errorKind: 'persist_failed', + ...(pastExpiry + ? { + hint: 'persist_also_failed_past_expiry (root cause is disk I/O; entry was past expiresAt by the time persist resolved)', + } + : {}), + }); + } + return; + } + // Persist succeeded. Per fold-in 3 C4 (and fold-in 5 #1 + // refinement): IdP approval wins. Whether or not cancel was + // requested during persist, the disk write committed — + // honor it. + if (this.transitionTerminal(entry, 'authorized')) { + this.deps.events.publish( + { + type: 'authorized', + data: { + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + expiresAt: metadata.expiresAt, + accountAlias: metadata.accountAlias, + }, + }, + entry.initiatorClientId, + ); + this.deps.audit?.record({ + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + clientId: entry.initiatorClientId, + status: 'authorized', + ...(cancelDuringPersist + ? { + hint: 'lost_success_kept (cancel during persist; credentials kept per IdP approval)', + } + : {}), + }); + } + return; + } + case 'error': + entry.hint = result.hint; + if (this.transitionTerminal(entry, 'error', result.errorKind)) { + this.deps.events.publish( + { + type: 'failed', + data: { + deviceFlowId: entry.deviceFlowId, + errorKind: result.errorKind, + hint: result.hint, + }, + }, + entry.initiatorClientId, + ); + this.deps.audit?.record({ + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + clientId: entry.initiatorClientId, + status: 'failed', + errorKind: result.errorKind, + // PR #4255 fold-in 8 #1: when the catch above fired (a + // misbehaving provider threw), include the FULL raw + // err.message in the audit hint so operators can debug + // the contract violation. The SSE-broadcast hint stays + // truncated to DEVICE_FLOW_POLL_HINT_MAX_LEN. + ...(rawProviderError !== undefined + ? { + hint: `provider.poll() threw (raw): ${rawProviderError}`, + } + : {}), + }); + } + return; + default: { + const _exhaustive: never = result; + void _exhaustive; + } + } + } + + /** + * Record a take-over: a second SDK client posted + * `POST /workspace/auth/device-flow` for a provider that already has + * a pending entry (or one being created in `inFlightStarts`). When + * the second caller's `initiatorClientId` differs from the entry's, + * stamp it on `entry.lastOriginatorClientId` and emit an audit + * breadcrumb. No event publish — the per-provider singleton's + * `started` event was already broadcast workspace-wide, and emitting + * a second `started` would confuse SDK reducers (the `attached: + * true` HTTP response is the second caller's signal). PR #4255 + * fold-in 6 review thread #6. + */ + private recordTakeover( + entry: DeviceFlowEntry, + takeoverClientId: string | undefined, + ): void { + if (!takeoverClientId) return; + if (takeoverClientId === entry.initiatorClientId) return; + if (takeoverClientId === entry.lastOriginatorClientId) return; + entry.lastOriginatorClientId = takeoverClientId; + this.deps.audit?.record({ + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + clientId: takeoverClientId, + status: 'started', + hint: `take-over (per-provider singleton; original initiator=${entry.initiatorClientId ?? '(none)'})`, + }); + } + + /** + * Drive a pending entry to the time-based `expired` terminal: + * `transitionTerminal` + emit `failed`/`expired_token` event + + * audit. PR #4255 fold-in 9 review thread #3: extracted from + * the two identical sites (poll-tick top-of-loop + sweeper) so + * the event shape lives in one place. No-op if the entry has + * already transitioned (the transitionTerminal idempotence guard + * handles the sweeper × poll-tick race). + */ + private expireEntry(entry: DeviceFlowEntry): void { + if (!this.transitionTerminal(entry, 'expired', 'expired_token')) return; + this.deps.events.publish( + { + type: 'failed', + data: { + deviceFlowId: entry.deviceFlowId, + errorKind: 'expired_token', + hint: 'device-flow expired without authorization', + }, + }, + entry.initiatorClientId, + ); + this.deps.audit?.record({ + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + clientId: entry.initiatorClientId, + status: 'expired', + errorKind: 'expired_token', + }); + } + + /** + * Move a pending entry to terminal state. Returns **`true` exactly once** + * — the call site that successfully drove the transition. Subsequent + * calls (sweeper × poll-tick race, double cancel, etc.) return `false` + * so the caller can suppress duplicate event publish + audit log. + * + * On a successful transition: + * 1. clears any pending poll timer + * 2. wipes the secret material from `entry.deviceCode` / + * `entry.pkceVerifier`. The PRIMARY guard against secret leaks + * is the `entry.status !== 'pending'` check at the top of + * `runPollTick` — a stale timer that managed to fire post-clear + * bails out before touching the entry. Secret-clearing here is + * DEFENSE IN DEPTH: even if a future refactor weakens the + * status guard, the registry's in-memory state can no longer + * hand out the upstream `device_code` to a late-arriving + * logger / serializer. + * 3. records `terminalAt` for the sweeper to evict after grace + * 4. removes the per-provider singleton index so a new POST creates + * a fresh flow instead of taking over the terminal one + */ + private transitionTerminal( + entry: DeviceFlowEntry, + status: Exclude, + errorKind?: DeviceFlowErrorKind, + ): boolean { + if (entry.status !== 'pending') return false; + entry.status = status; + if (errorKind) entry.errorKind = errorKind; + entry.terminalAt = this.now(); + if (entry.pollHandle) { + this.clearScheduled(entry.pollHandle); + entry.pollHandle = undefined; + } + entry.deviceCode = undefined; + entry.pkceVerifier = undefined; + try { + entry.cancelController.abort(); + } catch { + // best-effort + } + if (this.byProvider.get(entry.providerId) === entry) { + this.byProvider.delete(entry.providerId); + } + return true; + } + + /** + * Periodic sweeper: + * (a) pending entries past `expiresAt` get a synthetic timeout event + * (the polling loop also handles this on its next tick, but a + * wedged poll path should not block expiry) + * (b) terminal entries past their grace window get evicted entirely + */ + private sweep() { + if (this.disposed) return; + const now = this.now(); + for (const entry of [...this.byId.values()]) { + // PR #4255 fold-in 5 review thread #1: skip entries with persist + // in flight — the persist resolution branch will handle the + // terminal transition + audit (and emit `expired` if the entry + // was past `expiresAt` when persist failed). Sweeping here would + // create the same `expired` → `authorized` event-stream UX trap + // that the cancel-during-persist case avoids. + if (entry.persistInFlight) continue; + if (entry.status === 'pending' && now >= entry.expiresAt) { + this.expireEntry(entry); + continue; + } + if ( + entry.status !== 'pending' && + entry.terminalAt !== undefined && + now - entry.terminalAt >= DEVICE_FLOW_TERMINAL_GRACE_MS + ) { + this.byId.delete(entry.deviceFlowId); + // byProvider was cleared at terminal transition; nothing else to do. + } + } + } + + /** + * For diagnostics / GET /workspace/auth/status: report only pending + * flows. Terminal entries are an implementation detail of the SDK + * reconnect path and shouldn't be enumerated to all bearer-token + * holders. + */ + listPending(): DeviceFlowPublicView[] { + const out: DeviceFlowPublicView[] = []; + for (const entry of this.byId.values()) { + if (entry.status === 'pending') out.push(toPublicView(entry)); + } + return out; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.sweeperHandle) { + this.clearScheduledInterval(this.sweeperHandle); + this.sweeperHandle = undefined; + } + for (const entry of this.byId.values()) { + if (entry.pollHandle) { + this.clearScheduled(entry.pollHandle); + entry.pollHandle = undefined; + } + try { + entry.cancelController.abort(); + } catch { + // best-effort + } + entry.deviceCode = undefined; + entry.pkceVerifier = undefined; + } + this.byId.clear(); + this.byProvider.clear(); + } +} + +function toPublicView(entry: DeviceFlowEntry): DeviceFlowPublicView { + const base: DeviceFlowPublicView = { + deviceFlowId: entry.deviceFlowId, + providerId: entry.providerId, + status: entry.status, + createdAt: entry.createdAt, + initiatorClientId: entry.initiatorClientId, + }; + if (entry.errorKind) base.errorKind = entry.errorKind; + if (entry.hint) base.hint = entry.hint; + if (entry.lastPolledAt !== undefined) base.lastPolledAt = entry.lastPolledAt; + if (entry.status === 'pending') { + base.userCode = entry.userCode; + base.verificationUri = entry.verificationUri; + base.verificationUriComplete = entry.verificationUriComplete; + base.expiresAt = entry.expiresAt; + base.intervalMs = entry.intervalMs; + } + return base; +} diff --git a/packages/cli/src/serve/auth/qwenDeviceFlowProvider.ts b/packages/cli/src/serve/auth/qwenDeviceFlowProvider.ts new file mode 100644 index 000000000..ed86236cb --- /dev/null +++ b/packages/cli/src/serve/auth/qwenDeviceFlowProvider.ts @@ -0,0 +1,304 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + cacheQwenCredentials, + generatePKCEPair, + isDeviceAuthorizationSuccess, + isDeviceTokenPending, + isDeviceTokenSuccess, + QwenOAuth2Client, + QwenOAuthPollError, + type DeviceTokenPendingData, + type IQwenOAuth2Client, + type QwenCredentials, +} from '@qwen-code/qwen-code-core'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { + brandSecret, + unsafeRevealSecret, + UpstreamDeviceFlowError, + type BrandedSecret, + type DeviceFlowErrorKind, + type DeviceFlowPollResult, + type DeviceFlowProvider, + type DeviceFlowProviderId, + type DeviceFlowStartResult, +} from './deviceFlow.js'; + +const QWEN_OAUTH_SCOPE = 'openid profile email model.completion'; + +/** + * Maximum length of raw IdP detail written to stderr for operator + * audit. PR #4255 fold-in 6 review thread #5: the raw `err.message` + * from `QwenOAuth2Client` embeds the full upstream response body, + * which on a misbehaving reverse proxy / WAF can be megabytes of + * HTML — and container log-aggregation pipelines (Loki, Fluent Bit, + * Stackdriver) typically truncate or reject lines past 4–32 KiB, + * meaning the *useful* prefix is lost downstream. Truncate here so + * the kept prefix is the part with the actual IdP error code / + * description, with a `[+N more]` tail so the reader knows how much + * was dropped. 2 KiB is comfortably below every aggregator's per-line + * cap and large enough to retain a structured JSON error envelope. + */ +const STDERR_DETAIL_MAX = 2_048; + +function truncateForStderr(detail: string): string { + if (detail.length <= STDERR_DETAIL_MAX) return detail; + const dropped = detail.length - STDERR_DETAIL_MAX; + return `${detail.slice(0, STDERR_DETAIL_MAX)}…[+${dropped} bytes truncated]`; +} + +/** + * Qwen-OAuth implementation of `DeviceFlowProvider` for `qwen serve`. + * + * Uses the lower-level `QwenOAuth2Client` primitives (`requestDeviceAuthorization` + * / `pollDeviceToken`) directly rather than the high-level + * `authWithQwenDeviceFlow` because that helper invokes `open(url)` to launch + * a browser on the daemon host. PR 21 design §8 #1 forbids browser-spawning + * from the daemon — only the SDK/user side may decide to open a URL. + */ +export class QwenOAuthDeviceFlowProvider implements DeviceFlowProvider { + readonly providerId: DeviceFlowProviderId = 'qwen-oauth'; + private readonly client: IQwenOAuth2Client; + + constructor(client?: IQwenOAuth2Client) { + this.client = client ?? new QwenOAuth2Client(); + } + + async start(opts: { signal: AbortSignal }): Promise { + const { code_verifier, code_challenge } = generatePKCEPair(); + let auth; + try { + // PR #4255 review W1: thread `signal` into the IdP fetch so a + // dispose / cancel during the device-authorization request + // aborts the in-flight socket immediately. Pre-existing CLI + // callers don't pass a signal; the optional second arg keeps + // them compatible. + auth = await this.client.requestDeviceAuthorization( + { + scope: QWEN_OAUTH_SCOPE, + code_challenge, + code_challenge_method: 'S256', + }, + { signal: opts.signal }, + ); + } catch (err: unknown) { + // Network / parse / non-2xx errors from the Qwen IdP. Wrap so the + // route layer maps to `502 upstream_error` rather than the generic + // `500` fall-through in `sendBridgeError`. + // + // PR #4255 fold-in 3 (#9): the raw `err.message` from the + // QwenOAuth2Client embeds the full IdP response body (which can + // be HTML from a reverse proxy / WAF — hundreds of bytes, + // potentially leaking infrastructure detail). Use a stable + // bounded message for the route response; the original err + // detail goes through stderr audit only via the registry's + // standard error path (qwenOAuth2.ts logs via `debugLogger` + // when needed). + const detail = err instanceof Error ? err.message : String(err); + writeStderrLine( + `[serve] qwen device-flow start failed (raw): ${truncateForStderr(detail)}`, + ); + throw new UpstreamDeviceFlowError( + 'Qwen IdP device authorization request failed', + ); + } + if (opts.signal.aborted) { + throw new UpstreamDeviceFlowError('device-flow start aborted'); + } + if (!isDeviceAuthorizationSuccess(auth)) { + // PR #4255 fold-in 3 (#9): same sanitization as the catch above + // — well-formed but unsuccessful IdP responses can carry + // arbitrary `error_description` text that we don't want in the + // SDK-visible 502 hint. Static message; raw envelope to stderr. + const errorData = auth as { error?: string; error_description?: string }; + writeStderrLine( + truncateForStderr( + `[serve] qwen device-flow start error envelope (raw): error=${ + errorData?.error ?? 'unknown' + } description=${errorData?.error_description ?? '(none)'}`, + ), + ); + throw new UpstreamDeviceFlowError( + 'Qwen IdP rejected the device authorization request', + ); + } + return { + deviceCode: brandSecret(auth.device_code), + pkceVerifier: brandSecret(code_verifier), + userCode: auth.user_code, + verificationUri: auth.verification_uri, + verificationUriComplete: auth.verification_uri_complete, + expiresIn: auth.expires_in, + // Qwen IdP doesn't return `interval`; registry falls back to the + // RFC 8628 default (5s) when this is undefined. + }; + } + + async poll( + state: { + deviceCode: BrandedSecret; + pkceVerifier?: BrandedSecret; + }, + opts: { signal: AbortSignal }, + ): Promise { + if (!state.pkceVerifier) { + // Qwen *requires* PKCE; missing verifier is a programmer error. + return { + kind: 'error', + errorKind: 'invalid_grant', + hint: 'Qwen device-flow requires a PKCE verifier', + }; + } + if (opts.signal.aborted) { + // Caller already gave up. Returning `pending` is the correct + // semantic — the registry's post-await guard will see entry.status + // !== 'pending' and skip emit/audit. + return { kind: 'pending' }; + } + let response: Awaited>; + try { + // Pass `signal` through to the IdP fetch so cancel / dispose + // during a slow upstream response aborts the in-flight socket + // immediately instead of waiting for the IdP's own timeout. + // The post-await abort check is still useful: an early cancel + // can land before fetch even starts, in which case the abort + // throws synchronously into our catch block below. + response = await this.client.pollDeviceToken( + { + device_code: unsafeRevealSecret(state.deviceCode), + code_verifier: unsafeRevealSecret(state.pkceVerifier), + }, + { signal: opts.signal }, + ); + } catch (err: unknown) { + // The class throws on non-OAuth error responses (network, malformed + // upstream payloads) and on RFC 8628 terminal errors that aren't + // `authorization_pending` or `slow_down`. Map RFC 8628 errors to + // structured terminal results; everything else is `upstream_error`. + // PR #4255 review S2: do NOT echo the raw thrown message into + // `hint` — `qwenOAuth2.ts` embeds the entire IdP responseText + // (which can be an HTML error page from a reverse proxy / WAF + // running into hundreds of bytes) into the message, and that + // would flow through `publishWorkspaceEvent` to every SSE + // subscriber. Use a stable bounded summary; full detail goes + // through the registry's stderr audit only. + // + // PR #4255 fold-in 5 (#4): branch on `instanceof + // QwenOAuthPollError` and read the structured `oauthError` + // field instead of substring-matching the message text. The + // earlier regex was a fragile cross-file string contract that + // would silently degrade to `upstream_error` if `qwenOAuth2.ts` + // ever changed its message format. The typed class makes the + // contract explicit + tsc-checkable. + const errorKind: DeviceFlowErrorKind = + err instanceof QwenOAuthPollError + ? mapRfc8628OAuthCode(err.oauthError) + : 'upstream_error'; + return { + kind: 'error', + errorKind, + hint: + errorKind === 'upstream_error' + ? 'unexpected response from identity provider' + : `Qwen IdP returned ${errorKind}`, + }; + } + if (isDeviceTokenSuccess(response)) { + const tokenData = response; + const credentials: QwenCredentials = { + access_token: tokenData.access_token!, + refresh_token: tokenData.refresh_token ?? undefined, + token_type: tokenData.token_type, + resource_url: tokenData.resource_url, + expiry_date: tokenData.expires_in + ? Date.now() + tokenData.expires_in * 1000 + : undefined, + }; + const expiresAt = credentials.expiry_date; + const client = this.client; + return { + kind: 'success', + // PR #4255 review C3 + fold-in 3 (#10): `persist({signal})` + // is now threaded end-to-end. The registry passes its + // per-entry `cancelController.signal`; we forward it to + // `cacheQwenCredentials({signal})` which forwards to + // `fs.writeFile(..., {signal})`. A wedged disk write aborts + // immediately when `cancel()` / `dispose()` / the + // 30s `DEVICE_FLOW_PERSIST_TIMEOUT_MS` fires, instead of + // hanging until the OS-level timeout. + async persist(persistOpts: { signal: AbortSignal }) { + // Order matters: write to disk FIRST. If `cacheQwenCredentials` + // throws (EACCES, EROFS, ENOSPC) we MUST NOT update the + // in-process client — otherwise the daemon enters a zombie + // state where this session "remembers" the token but a + // restart loses it. + await cacheQwenCredentials(credentials, { + signal: persistOpts.signal, + }); + try { + client.setCredentials(credentials); + } catch { + // ignore — disk file is the durable record; in-process + // refresh happens on next SharedTokenManager mtime poll + } + // PR #4255 review W3: `accountAlias` USED to be wired + // through events / reducer / audit but the Qwen IdP token + // response doesn't carry one (see DeviceTokenData shape in + // `qwenOAuth2.ts:152-160` — no `name` / `email` / `sub` + // field). Returning only `{expiresAt}` makes the field + // type-honestly absent rather than always-undefined. A + // future provider whose token response carries an alias + // can populate it; the type stays optional. + return { expiresAt }; + }, + // PR #4255 fold-in 3: `unpersist` was removed in favor of + // honoring the IdP's already-completed approval over a + // microsecond cancel/dispose race. See registry success + // branch for the rationale + audit hint. + }; + } + if (isDeviceTokenPending(response)) { + const pending = response as DeviceTokenPendingData; + return pending.slowDown ? { kind: 'slow_down' } : { kind: 'pending' }; + } + // The `QwenOAuth2Client.pollDeviceToken` implementation in + // `qwenOAuth2.ts:386-393` THROWS on every non-pending non-success + // response (it never returns a structured error envelope from the + // success path). So this fall-through is reached only if a future + // refactor changes that contract. Map defensively to + // `upstream_error` with a bounded hint (PR #4255 review S2 — never + // forward the raw IdP response body to SDK clients). + return { + kind: 'error', + errorKind: 'upstream_error', + hint: 'unexpected response from identity provider', + }; + } +} + +/** + * Map a structured RFC 8628 OAuth error code (from + * `QwenOAuthPollError.oauthError`) to the registry's + * `DeviceFlowErrorKind` taxonomy. Unknown / missing codes fall + * through to `upstream_error`. PR #4255 fold-in 5 (#4) replaced the + * earlier substring-regex match against the message text, which was + * an implicit string contract with `qwenOAuth2.ts` that would + * silently degrade if the message format changed. + */ +function mapRfc8628OAuthCode(code: string | undefined): DeviceFlowErrorKind { + switch (code) { + case 'expired_token': + return 'expired_token'; + case 'access_denied': + return 'access_denied'; + case 'invalid_grant': + return 'invalid_grant'; + default: + return 'upstream_error'; + } +} diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 786e2750e..21e8a2bfa 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -117,6 +117,15 @@ export const SERVE_CAPABILITY_REGISTRY = { // defaults (no flag) omit the tag, preserving the bit-for-bit shape // older clients expect. require_auth: { since: 'v1' }, + // Issue #4175 PR 21. Daemon exposes the device-flow auth surface + // (`POST /workspace/auth/device-flow`, GET/DELETE on `/:id`, and + // `GET /workspace/auth/status`). Advertised UNCONDITIONALLY: the + // routes themselves return `400 unsupported_provider` if the daemon + // can't satisfy a specific provider, so clients always probe via the + // route. The list of supported providers is surfaced through the + // status route (extension data on `/capabilities` would inflate the + // descriptor shape; we keep the registry uniform). + auth_device_flow: { since: 'v1' }, } as const satisfies Record; export type ServeFeature = keyof typeof SERVE_CAPABILITY_REGISTRY; diff --git a/packages/cli/src/serve/httpAcpBridge.ts b/packages/cli/src/serve/httpAcpBridge.ts index 04ffdff0a..784673a97 100644 --- a/packages/cli/src/serve/httpAcpBridge.ts +++ b/packages/cli/src/serve/httpAcpBridge.ts @@ -487,6 +487,24 @@ export interface HttpAcpBridge { /** Close all live child processes; called on daemon shutdown. */ shutdown(): Promise; + + /** + * Issue #4175 PR 21 — best-effort fan-out of a workspace-scoped event + * (no `sessionId`) to every live session bus. Used by routes that + * make workspace-level state changes — e.g. device-flow auth — so SSE + * subscribers attached to any session learn about the change. + * + * **Best-effort semantics:** swallowed bus failures (closed bus, + * subscriber overflow) do NOT throw. Workspace events are + * authoritative via the GET routes; SSE is the convenience path. + * + * Removed in PR #4255 fold-in 9: PR 16 (#4249) landed + * `publishWorkspaceEvent` with identical fan-out semantics; the + * closed-bus + all-failed-stderr operator-visibility features + * that PR 21 added here have been folded INTO + * `publishWorkspaceEvent`. Use that helper for all workspace- + * scoped fan-outs (memory, agents, auth device-flow, future). + */ } /** @@ -3264,8 +3282,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // (mid-shutdown, or evicted under load) is silently skipped, same // posture as `permission_resolved` at line 1717. // - // We deliberately do NOT track delivery success per session here: - // the route handler's contract is "read-after-write" and any SSE + // The route handler's contract is "read-after-write" and any SSE // subscriber that misses the event can re-fetch via the route's // GET sibling. Stage 5 PR 24 PermissionMediator can layer a // proper workspace event bus on top if adapters need stricter @@ -3280,10 +3297,32 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // route layer (200 OK) while SSE subscribers stop seeing // events. The shutdown gate keeps the common race noise out of // the production log without hiding actual bugs. - for (const entry of byId.values()) { + // + // PR #4255 fold-in 9: track per-session success/fail. A + // closed-bus return (`undefined` from `EventBus.publish` — + // see eventBus.ts:195-207) counts as a failure (operator + // signal), distinct from a thrown exception (regression + // signal). When zero sessions are active OR every active bus + // dropped the event, we elevate to unconditional stderr so + // monitoring catches the all-buses-dropped scenario. + // Inherited from the (now removed) `broadcastWorkspaceEvent` + // PR 21 added — PR 16's helper is now the single fan-out. + const sessions = Array.from(byId.values()); + let successCount = 0; + let failureCount = 0; + for (const entry of sessions) { try { - entry.events.publish(event); + const published = entry.events.publish(event); + if (published === undefined) { + failureCount += 1; + writeServeDebugLine( + `publishWorkspaceEvent: publish on session ${entry.sessionId} no-op (bus closed)`, + ); + } else { + successCount += 1; + } } catch (err) { + failureCount += 1; const detail = `publishWorkspaceEvent: bus publish failed for session ` + `${JSON.stringify(entry.sessionId)} (type=${event.type}): ` + @@ -3295,6 +3334,11 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { } } } + if (sessions.length > 0 && successCount === 0 && !shuttingDown) { + writeStderrLine( + `qwen serve: publishWorkspaceEvent type=${event.type} dropped on ALL ${failureCount} session bus(es); SSE subscribers will miss this event (GET fallback still authoritative)`, + ); + } }, knownClientIds() { diff --git a/packages/cli/src/serve/runQwenServe.ts b/packages/cli/src/serve/runQwenServe.ts index 097882f87..ac173164e 100644 --- a/packages/cli/src/serve/runQwenServe.ts +++ b/packages/cli/src/serve/runQwenServe.ts @@ -9,6 +9,7 @@ import { type Server } from 'node:http'; import * as path from 'node:path'; import { writeStderrLine, writeStdoutLine } from '../utils/stdioHelpers.js'; import type { BridgeEvent } from './eventBus.js'; +import { getDeviceFlowRegistry } from './auth/deviceFlow.js'; import { canonicalizeWorkspace, createHttpAcpBridge, @@ -320,6 +321,16 @@ export async function runQwenServe( boundWorkspace, fsFactory, }); + // Issue #4175 PR 21 — `createServeApp` parks the device-flow registry + // on `app.locals` when it constructs (or accepts) one. Pull it back + // out so the close hook can dispose it before `bridge.shutdown()`, + // ensuring polling timers + cancel controllers are torn down BEFORE + // we tell agent children to exit (otherwise a stuck IdP fetch could + // pin the drain). `unref()`'d timers mean the process WILL exit + // either way; explicit dispose is for cleanliness + audit + // visibility. Typed accessor (fold-in 4 review thread D) prevents + // a key-name typo from silently nulling out the dispose path. + const deviceFlowRegistry = getDeviceFlowRegistry(app); // Node's `app.listen()` wants the unbracketed IPv6 literal (`::1`) but // operators conventionally type `[::1]` (or copy/paste from URLs that @@ -542,6 +553,21 @@ export async function runQwenServe( else res(); }; + // PR 21: dispose the device-flow registry FIRST so any + // in-flight IdP poll is cancelled and timers are cleared + // before the bridge tear-down (which would otherwise race + // with the still-polling registry on shared HTTP agents). + if (deviceFlowRegistry) { + try { + deviceFlowRegistry.dispose(); + } catch (err) { + writeStderrLine( + `qwen serve: device-flow registry dispose error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } bridge .shutdown() .catch((err) => { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 2d07197fc..cf54758d4 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -114,17 +114,21 @@ const EXPECTED_STAGE1_FEATURES = [ // Issue #4175 PR 19. Always-on. Daemon exposes the read-only file // surface: `GET /file`, `GET /list`, `GET /glob`, `GET /stat`. 'workspace_file_read', + // Issue #4175 PR 21 — auth device-flow surface advertised unconditionally. + 'auth_device_flow', ] as const; // Issue #4175 PR 15. `require_auth` is registered but conditionally // advertised (only when `--require-auth` is set), so the registry list -// is a strict superset of the always-on list. Kept as a separate -// constant rather than appended to `EXPECTED_STAGE1_FEATURES` so the -// existing "advertised features" assertions stay tight against -// surprise additions. +// is a strict superset of the always-on list. The registry's source-of- +// truth ORDER puts `require_auth` between PR 11 (`session_metadata`) +// and PR 21 (`auth_device_flow`); reflect that here so the assertion +// matches the real ordering. const EXPECTED_REGISTERED_FEATURES = [ - ...EXPECTED_STAGE1_FEATURES, + // Same order as `SERVE_CAPABILITY_REGISTRY` declaration: + ...EXPECTED_STAGE1_FEATURES.filter((f) => f !== 'auth_device_flow'), 'require_auth', + 'auth_device_flow', ] as const; interface FakeBridgeOpts { @@ -4022,3 +4026,399 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { } }); }); + +// -- Issue #4175 PR 21 — auth device-flow integration tests ---------------- + +describe('auth device-flow routes', () => { + // Build a fake provider whose `start` returns deterministic values and + // whose `poll` is scripted per-test. Lives at the top of the suite so + // every `it()` can compose it with the registry. + function makeFakeProvider(): { + provider: import('./auth/deviceFlow.js').DeviceFlowProvider; + startCount: () => number; + } { + let starts = 0; + return { + provider: { + providerId: 'qwen-oauth' as const, + async start() { + starts += 1; + return { + deviceCode: + // Use the brandSecret helper so the secret follows the same + // redaction shape the production provider produces. + (await import('./auth/deviceFlow.js')).brandSecret( + `device-${starts}`, + ), + pkceVerifier: (await import('./auth/deviceFlow.js')).brandSecret( + `pkce-${starts}`, + ), + userCode: `USER-${starts}`, + verificationUri: 'https://idp.example/verify', + verificationUriComplete: 'https://idp.example/verify?u=AB12', + expiresIn: 600, + }; + }, + async poll(_state: unknown, _opts: { signal: AbortSignal }) { + // Stays pending forever — tests don't need the upstream to + // succeed for the route-layer assertions to be meaningful. + return { kind: 'pending' as const }; + }, + }, + startCount: () => starts, + }; + } + + function buildApp( + overrides: Partial = {}, + fakeProvider = makeFakeProvider(), + ) { + const bridge = fakeBridge(); + const app = createServeApp({ ...baseOpts, ...overrides }, undefined, { + bridge, + deviceFlowProviders: [fakeProvider.provider], + }); + return { app, bridge, fakeProvider }; + } + + it('POST /workspace/auth/device-flow returns 201 on fresh start with redacted body', async () => { + const { app, fakeProvider } = buildApp({ token: 'tkn' }); + const res = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'qwen-oauth' }); + expect(res.status).toBe(201); + expect(res.body.providerId).toBe('qwen-oauth'); + expect(res.body.userCode).toBe('USER-1'); + expect(res.body.attached).toBe(false); + expect(typeof res.body.deviceFlowId).toBe('string'); + // Critical: response body never contains device_code / pkce_verifier. + const json = JSON.stringify(res.body); + expect(json).not.toContain('device-1'); + expect(json).not.toContain('pkce-1'); + expect(fakeProvider.startCount()).toBe(1); + }); + + it('POST is rejected with 401 token_required on token-less loopback (strict gate)', async () => { + const { app } = buildApp({ token: undefined }); + const res = await request(app) + .post('/workspace/auth/device-flow') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'qwen-oauth' }); + expect(res.status).toBe(401); + expect(res.body.code).toBe('token_required'); + }); + + it('POST with unknown providerId returns 400 unsupported_provider', async () => { + const { app } = buildApp({ token: 'tkn' }); + const res = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'totally-fake' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('unsupported_provider'); + expect(res.body.supportedProviders).toContain('qwen-oauth'); + }); + + it('POST is idempotent take-over for the same providerId — second POST returns 200 + attached:true', async () => { + const { app, fakeProvider } = buildApp({ token: 'tkn' }); + const first = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'qwen-oauth' }); + expect(first.status).toBe(201); + const second = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'qwen-oauth' }); + expect(second.status).toBe(200); + expect(second.body.attached).toBe(true); + expect(second.body.deviceFlowId).toBe(first.body.deviceFlowId); + // Critical: provider.start is NOT called twice — the take-over is + // a daemon-internal operation, not a re-auth round trip. + expect(fakeProvider.startCount()).toBe(1); + }); + + it('GET /workspace/auth/device-flow/:id returns 200 for known + 404 for unknown', async () => { + const { app } = buildApp({ token: 'tkn' }); + const post = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'qwen-oauth' }); + const id = post.body.deviceFlowId as string; + const ok = await request(app) + .get(`/workspace/auth/device-flow/${id}`) + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(ok.status).toBe(200); + expect(ok.body.deviceFlowId).toBe(id); + expect(ok.body.status).toBe('pending'); + + const missing = await request(app) + .get('/workspace/auth/device-flow/nonexistent-id') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(missing.status).toBe(404); + expect(missing.body.code).toBe('device_flow_not_found'); + }); + + it('DELETE on pending → 204; idempotent on already-cancelled → 204; unknown → 404', async () => { + const { app } = buildApp({ token: 'tkn' }); + const post = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'qwen-oauth' }); + const id = post.body.deviceFlowId as string; + const first = await request(app) + .delete(`/workspace/auth/device-flow/${id}`) + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(first.status).toBe(204); + const second = await request(app) + .delete(`/workspace/auth/device-flow/${id}`) + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`); + // Idempotent: terminal entries return 204 no-op. + expect(second.status).toBe(204); + const missing = await request(app) + .delete('/workspace/auth/device-flow/nonexistent-id') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(missing.status).toBe(404); + }); + + it('GET /workspace/auth/status surfaces pending flows and supported providers', async () => { + const { app } = buildApp({ token: 'tkn' }); + const start = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'qwen-oauth' }); + const id = start.body.deviceFlowId as string; + const status = await request(app) + .get('/workspace/auth/status') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(status.status).toBe(200); + expect(status.body.v).toBe(1); + expect(status.body.supportedDeviceFlowProviders).toContain('qwen-oauth'); + expect(status.body.pendingDeviceFlows).toHaveLength(1); + expect(status.body.pendingDeviceFlows[0].deviceFlowId).toBe(id); + // Status payload MUST NOT echo userCode/verificationUri. + const json = JSON.stringify(status.body); + expect(json).not.toContain('USER-1'); + expect(json).not.toContain('idp.example'); + }); + + it('capability tag auth_device_flow is advertised unconditionally', async () => { + const { app } = buildApp({ token: 'tkn' }); + const res = await request(app) + .get('/capabilities') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body.features).toContain('auth_device_flow'); + }); + + it('upstream provider.start failure → 502 upstream_error, not 500', async () => { + // PR 21 fold-in 0 P1-14: provider throwing UpstreamDeviceFlowError + // must surface as 502 with code:'upstream_error' instead of falling + // through `sendBridgeError`'s generic 500 path. Build a fake + // provider whose start always throws. + const { UpstreamDeviceFlowError } = await import('./auth/deviceFlow.js'); + const failingProvider: import('./auth/deviceFlow.js').DeviceFlowProvider = { + providerId: 'qwen-oauth', + async start() { + throw new UpstreamDeviceFlowError('mocked upstream outage'); + }, + async poll() { + return { kind: 'pending' as const }; + }, + }; + const bridge = fakeBridge(); + const app = createServeApp({ ...baseOpts, token: 'tkn' }, undefined, { + bridge, + deviceFlowProviders: [failingProvider], + }); + const res = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'qwen-oauth' }); + expect(res.status).toBe(502); + expect(res.body.code).toBe('upstream_error'); + expect(res.body.error).toContain('mocked upstream outage'); + }); + + it('sweeper-driven auto-expiry transitions a stale entry to status:error and surfaces over GET', async () => { + // PR 21 fold-in 0 P1-13: cover the time-based expiry path via an + // injected registry with a controlled clock + manual sweeper trigger. + const { DeviceFlowRegistry, brandSecret } = await import( + './auth/deviceFlow.js' + ); + const fakeProvider: import('./auth/deviceFlow.js').DeviceFlowProvider = { + providerId: 'qwen-oauth', + async start() { + return { + deviceCode: brandSecret('device-1'), + pkceVerifier: brandSecret('pkce-1'), + userCode: 'USER-1', + verificationUri: 'https://idp.example/verify', + expiresIn: 60, // 60 seconds + }; + }, + async poll() { + // Stays pending; the sweeper drives terminal state via expiresAt. + return { kind: 'pending' as const }; + }, + }; + + let now = 1_700_000_000_000; + const intervalsRegistered: Array<{ cb: () => void }> = []; + const registry = new DeviceFlowRegistry({ + events: { publish: () => {} }, + resolveProvider: (id) => (id === 'qwen-oauth' ? fakeProvider : undefined), + now: () => now, + // Run polls forever-deferred; sweeper interval is what we drive. + schedule: (_ms, _cb) => ({ cancelled: false }) as never, + clearScheduled: () => {}, + scheduleInterval: (_ms, cb) => { + const handle = { cb, cancelled: false }; + intervalsRegistered.push(handle); + return handle as never; + }, + clearScheduledInterval: () => {}, + }); + + const bridge = fakeBridge(); + const app = createServeApp({ ...baseOpts, token: 'tkn' }, undefined, { + bridge, + deviceFlowRegistry: registry, + }); + + const startRes = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'qwen-oauth' }); + expect(startRes.status).toBe(201); + const id = startRes.body.deviceFlowId as string; + + // Drive the clock past expiresAt and trigger the sweeper. + now += 61_000; + for (const interval of intervalsRegistered) interval.cb(); + + const stateRes = await request(app) + .get(`/workspace/auth/device-flow/${id}`) + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(stateRes.status).toBe(200); + // Time-based expiry transitions to status='expired' with errorKind='expired_token'. + expect(stateRes.body.status).toBe('expired'); + expect(stateRes.body.errorKind).toBe('expired_token'); + registry.dispose(); + }); + + // PR #4255 fold-in 10 #4 — HTTP route contract coverage. Round-8 + // wenshao thread `Cvx93` flagged that the existing 4 it()'s + // covered the happy paths but missed the malformed-input, + // resource-cap, and strict-bearer error envelopes that SDK + // consumers depend on for retry / surface routing. Each case + // here is a supertest one-liner asserting status code + `code:` + // discriminator. + + it('POST with missing providerId returns 400 invalid_request', async () => { + // PR 21 fold-in W2 split the 400 envelope into `invalid_request` + // (caller-shape error: missing/non-string body field) vs + // `unsupported_provider` (well-shaped but the providerId isn't + // in the supported tuple). This pins that split. + const { app } = buildApp({ token: 'tkn' }); + const res = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); // no providerId at all + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_request'); + expect(res.body.error).toContain('providerId'); + }); + + it('POST with non-string providerId returns 400 invalid_request', async () => { + const { app } = buildApp({ token: 'tkn' }); + const res = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 42 }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_request'); + }); + + it('POST returns 409 too_many_active_flows when registry cap is reached', async () => { + // Inject a fake registry whose `start` always throws the cap error. + const { TooManyActiveDeviceFlowsError } = await import( + './auth/deviceFlow.js' + ); + const fakeRegistry = { + start: async () => { + throw new TooManyActiveDeviceFlowsError(); + }, + get: () => undefined, + cancel: () => undefined, + listPending: () => [], + dispose: () => {}, + } as unknown as import('./auth/deviceFlow.js').DeviceFlowRegistry; + + const bridge = fakeBridge(); + const app = createServeApp({ ...baseOpts, token: 'tkn' }, undefined, { + bridge, + deviceFlowRegistry: fakeRegistry, + }); + + const res = await request(app) + .post('/workspace/auth/device-flow') + .set('Authorization', 'Bearer tkn') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ providerId: 'qwen-oauth' }); + expect(res.status).toBe(409); + expect(res.body.code).toBe('too_many_active_flows'); + }); + + it('DELETE without bearer is rejected 401 token_required (strict-mutation gate)', async () => { + const { app } = buildApp({ token: undefined }); + const res = await request(app) + .delete('/workspace/auth/device-flow/some-id') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(401); + expect(res.body.code).toBe('token_required'); + }); + + it('GET /workspace/auth/device-flow/:id is strict-gated; GET /workspace/auth/status is read-only', async () => { + // The two GETs have ASYMMETRIC auth posture by design: + // - `GET /workspace/auth/device-flow/:id` returns `userCode` for + // pending entries, which is shoulder-surf-able if a peer process + // on the same host can read it. fold-in (round-4 #1) added + // `mutate({strict:true})` to close the info-disclosure + // asymmetry vs. the strict POST/DELETE. + // - `GET /workspace/auth/status` intentionally redacts userCode + // (lists only deviceFlowId/providerId/expiresAt) so it stays + // bearer-only (passthrough on loopback no-token default). + const { app } = buildApp({ token: undefined }); + const flowGet = await request(app) + .get('/workspace/auth/device-flow/no-such-id') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(flowGet.status).toBe(401); + expect(flowGet.body.code).toBe('token_required'); + // Status, by contrast, is reachable on loopback without a token. + const status = await request(app) + .get('/workspace/auth/status') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(status.status).toBe(200); + }); +}); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index c69af7fd7..23abc6461 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -14,6 +14,18 @@ import { denyBrowserOriginCors, hostAllowlist, } from './auth.js'; +import { + DeviceFlowRegistry, + setDeviceFlowRegistry, + TooManyActiveDeviceFlowsError, + UnsupportedDeviceFlowProviderError, + UpstreamDeviceFlowError, + type DeviceFlowEventSink, + type DeviceFlowProvider, + type DeviceFlowProviderId, + type DeviceFlowPublicView, +} from './auth/deviceFlow.js'; +import { QwenOAuthDeviceFlowProvider } from './auth/qwenDeviceFlowProvider.js'; import { isLoopbackBind } from './loopbackBinds.js'; import { canonicalizeWorkspace, @@ -119,6 +131,22 @@ export interface ServeAppDeps { * per-session EventBus. */ fsFactory?: WorkspaceFileSystemFactory; + /** + * Issue #4175 PR 21 — device-flow auth registry. Tests inject a fake + * (`now` / `schedule` overrides for deterministic timer control, + * stubbed providers, captured event sink). Production callers omit + * this and `createServeApp` constructs a default wired to the + * shipped Qwen provider, the bridge's `publishWorkspaceEvent`, + * and a stderr audit sink. + */ + deviceFlowRegistry?: DeviceFlowRegistry; + /** + * Issue #4175 PR 21 — extra device-flow providers for tests / future + * extensions. Production builds register only `QwenOAuthDeviceFlowProvider`; + * passing extra entries here registers them in addition to the default + * Qwen provider. Used by tests that stub the OAuth flow. + */ + deviceFlowProviders?: DeviceFlowProvider[]; } /** @@ -276,6 +304,90 @@ export function createServeApp( // and the bridge enforces — keeping every layer in agreement. (app.locals as { boundWorkspace?: string }).boundWorkspace = boundWorkspace; + // Issue #4175 PR 21 — wire the device-flow registry. Default builds + // a single Qwen provider; tests inject `deps.deviceFlowRegistry` + // wholesale (with controlled clock/scheduler) or + // `deps.deviceFlowProviders` to stub the OAuth client only. + const deviceFlowProviderMap = new Map< + DeviceFlowProviderId, + DeviceFlowProvider + >(); + for (const provider of deps.deviceFlowProviders ?? []) { + deviceFlowProviderMap.set(provider.providerId, provider); + } + if (!deviceFlowProviderMap.has('qwen-oauth')) { + deviceFlowProviderMap.set('qwen-oauth', new QwenOAuthDeviceFlowProvider()); + } + const deviceFlowEventSink: DeviceFlowEventSink = { + publish(emission, originatorClientId) { + // PR #4255 fold-in 9: PR 16 (#4249) landed + // `publishWorkspaceEvent` with the same fan-out semantics as + // PR 21's `broadcastWorkspaceEvent`. The closed-bus + + // all-failed-stderr operator-visibility features that PR 21 + // added have been folded INTO `publishWorkspaceEvent`; PR 21 + // now uses the canonical helper. + bridge.publishWorkspaceEvent({ + type: `auth_device_flow_${emission.type}`, + data: emission.data, + ...(originatorClientId ? { originatorClientId } : {}), + }); + }, + }; + const deviceFlowRegistry = + deps.deviceFlowRegistry ?? + new DeviceFlowRegistry({ + events: deviceFlowEventSink, + audit: { + record(line) { + // Structured stderr breadcrumb; deviceFlowId truncated to first + // 8 chars (mirrors PR 16 audit-event-stamp shape) so log + // skimmers can follow a flow without retaining full uuids. + const id = line.deviceFlowId.slice(0, 8); + const parts = [ + `[serve] auth.device-flow:`, + `provider=${line.providerId}`, + `deviceFlowId=${id}...`, + line.clientId ? `clientId=${line.clientId}` : 'clientId=-', + `status=${line.status}`, + ]; + if (line.errorKind) parts.push(`errorKind=${line.errorKind}`); + if (line.expiresInMs !== undefined) { + parts.push(`expiresInMs=${Math.max(0, line.expiresInMs)}`); + } + // PR #4255 round-12 #7 (gpt-5.5 review CzSpd): include + // `line.hint` in the production stderr line. The + // registry uses the hint slot for operator-only + // breadcrumbs that aren't surfaced over SSE: the static + // catch-all hint "provider.poll() threw (raw): ..." + // (round-8 #1), `lost_success_after_timeout` (round-8 + // #7's split-brain detector), `persist_also_failed_past_expiry` + // (round-8 #13), `take-over` audit on per-provider + // singleton, and `deferred (persist in flight; ...)` on + // cancel-during-persist. Without echoing here, the + // documented troubleshooting trail is invisible in + // production. Bound at 1 KiB so a misbehaving caller + // can't spam stderr. + if (line.hint) { + const STDERR_HINT_MAX = 1_024; + const hint = + line.hint.length > STDERR_HINT_MAX + ? `${line.hint.slice(0, STDERR_HINT_MAX)}…[+${line.hint.length - STDERR_HINT_MAX} bytes truncated]` + : line.hint; + // Quote the hint so multi-word values stay parseable. + parts.push(`hint=${JSON.stringify(hint)}`); + } + writeStderrLine(parts.join(' ')); + }, + }, + resolveProvider: (providerId) => deviceFlowProviderMap.get(providerId), + }); + // Park the registry on `app.locals` so request handlers can reach it + // without closure capture (and so future helper extracts can find it + // without threading it through their args). Typed accessor (fold-in 4 + // review thread D) prevents a string-key typo from silently + // detaching `runQwenServe`'s shutdown dispose call. + setDeviceFlowRegistry(app, deviceFlowRegistry); + // Order matters: rejection guards (CORS / Host allowlist / bearer auth) // run BEFORE the JSON body parser. Otherwise an unauthenticated POST // gets a full 10MB `JSON.parse` before the 401 fires — a trivially @@ -508,6 +620,170 @@ export function createServeApp( parseClientId: parseClientIdHeader, }); + // -- Issue #4175 PR 21 — auth device-flow routes ------------------------ + + app.post( + '/workspace/auth/device-flow', + mutate({ strict: true }), + async (req, res) => { + const body = safeBody(req); + const providerIdRaw = body['providerId']; + // PR #4255 review W2: split `invalid_request` (request shape is + // wrong — missing/non-string field) from `unsupported_provider` + // (the field is well-formed but its value isn't in the + // daemon's known set). Conflating the two surfaced misleading + // remediation hints to SDK consumers branching on `code` + // ("this provider isn't supported here" when the actual cause + // was a serializer dropping the field). + if (typeof providerIdRaw !== 'string' || providerIdRaw.length === 0) { + res.status(400).json({ + error: '`providerId` must be a non-empty string', + code: 'invalid_request', + }); + return; + } + // PR #4255 round-12 #3 (gpt-5.5 review CzSpe): validate + // against the runtime provider map, not the static + // `DEVICE_FLOW_SUPPORTED_PROVIDERS` tuple. The static tuple + // is the SDK-facing default; `deps.deviceFlowProviders` is + // the documented extension hook for tests / future + // providers. Hardcoding the static tuple here meant + // injected providers were rejected at the route while still + // being registered in `deviceFlowProviderMap` — easy to + // break when adding a second provider. + if (!deviceFlowProviderMap.has(providerIdRaw as DeviceFlowProviderId)) { + res.status(400).json({ + error: `Unsupported device-flow provider: ${providerIdRaw}`, + code: 'unsupported_provider', + supportedProviders: Array.from(deviceFlowProviderMap.keys()), + }); + return; + } + const providerId = providerIdRaw as DeviceFlowProviderId; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + try { + const { view, attached } = await deviceFlowRegistry.start({ + providerId, + ...(clientId !== undefined ? { initiatorClientId: clientId } : {}), + }); + // Idempotent take-over → 200 with `attached: true`. Fresh start → + // 201 + `attached: false`. The registry is the source of truth on + // which branch fired (it's the one that decided not to call + // `provider.start()` again). + res + .status(attached ? 200 : 201) + .json(toDeviceFlowStartResponseBody(view, attached, clientId)); + } catch (err) { + if (err instanceof UnsupportedDeviceFlowProviderError) { + res + .status(400) + .json({ error: err.message, code: 'unsupported_provider' }); + return; + } + if (err instanceof TooManyActiveDeviceFlowsError) { + res + .status(409) + .json({ error: err.message, code: 'too_many_active_flows' }); + return; + } + if (err instanceof UpstreamDeviceFlowError) { + // IdP-side failure (network / parse / non-2xx). 502 distinguishes + // "the upstream we depend on misbehaved" from a daemon bug (5xx + // generic) so SDK clients can branch on retry strategy. + res.status(502).json({ error: err.message, code: 'upstream_error' }); + return; + } + sendBridgeError(res, err, { + route: 'POST /workspace/auth/device-flow', + }); + } + }, + ); + + // PR #4255 fold-in 3: this GET surfaces `userCode` / + // `verificationUri` / `verificationUriComplete` for pending entries + // — material an attacker on the same loopback host could use to + // shoulder-surf the IdP approval flow. POST + DELETE are already + // strict; aligning GET to `mutate({ strict: true })` closes the + // information-disclosure asymmetry (the sibling + // `GET /workspace/auth/status` stays bearer-only because its + // pendingDeviceFlows entries intentionally omit `userCode`). + app.get( + '/workspace/auth/device-flow/:id', + mutate({ strict: true }), + async (req, res) => { + const id = req.params['id']; + if (!id) { + res.status(404).json({ + error: 'Device-flow id required', + code: 'device_flow_not_found', + }); + return; + } + const view = deviceFlowRegistry.get(id); + if (!view) { + res.status(404).json({ + error: `Device-flow ${id} not found`, + code: 'device_flow_not_found', + }); + return; + } + res.status(200).json(toDeviceFlowStateBody(view)); + }, + ); + + app.delete( + '/workspace/auth/device-flow/:id', + mutate({ strict: true }), + (req, res) => { + const id = req.params['id']; + if (!id) { + res.status(404).json({ + error: 'Device-flow id required', + code: 'device_flow_not_found', + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const result = deviceFlowRegistry.cancel(id, clientId); + if (result === undefined) { + res.status(404).json({ + error: `Device-flow ${id} not found`, + code: 'device_flow_not_found', + }); + return; + } + // Both freshly-cancelled and already-terminal are 204 (idempotent). + res.status(204).end(); + }, + ); + + app.get('/workspace/auth/status', (_req, res) => { + const pending = deviceFlowRegistry.listPending(); + res.status(200).json({ + v: 1, + workspaceCwd: boundWorkspace, + // GET /workspace/auth/status read-side intentionally minimal in + // this PR: a future PR can broaden the per-provider view (e.g. + // by reading SharedTokenManager.getCachedSnapshot for an `ok` / + // `expired` cell), but landing the additive route shape now + // unblocks SDK clients that need to know "is there a flow + // running?" without subscribing to SSE. + providers: [], + pendingDeviceFlows: pending.map((view) => ({ + deviceFlowId: view.deviceFlowId, + providerId: view.providerId, + ...(view.expiresAt !== undefined ? { expiresAt: view.expiresAt } : {}), + })), + // PR #4255 round-12 #3: derive from runtime provider map so + // injected providers are surfaced. Single source of truth + // matches the POST validation above. + supportedDeviceFlowProviders: Array.from(deviceFlowProviderMap.keys()), + }); + }); + app.post('/session', mutate(), async (req, res) => { const body = safeBody(req); // #3803 §02: 1 daemon = 1 workspace. Three input shapes: @@ -1434,6 +1710,75 @@ function parseOptionalWorkspaceCwd( return cwd; } +/** + * PR 21 — translate the registry's redacted `DeviceFlowPublicView` into + * the wire shape declared by `DaemonDeviceFlowStartResult`. Splitting + * "start response" from "state body" preserves the `attached` field + * the start route needs without polluting the GET shape. + */ +function toDeviceFlowStartResponseBody( + view: DeviceFlowPublicView, + attached: boolean, + callerClientId?: string, +): Record { + const body: Record = { + deviceFlowId: view.deviceFlowId, + providerId: view.providerId, + status: view.status, + userCode: view.userCode ?? '', + verificationUri: view.verificationUri ?? '', + expiresAt: view.expiresAt ?? 0, + intervalMs: view.intervalMs ?? 0, + attached, + }; + if (view.verificationUriComplete) { + body['verificationUriComplete'] = view.verificationUriComplete; + } + // PR #4255 round-12 #6 (gpt-5.5 review CzHOK): minor info-leak + // close-out — only echo `initiatorClientId` back to a take-over + // POST when the caller is the same client that started the flow + // (or when the take-over caller explicitly identified + // themselves and matches the original starter). An anonymous + // take-over caller (no `X-Qwen-Client-Id`) gets no echo of the + // original starter's id; this preserves the symmetry "the + // daemon respects the absence of `X-Qwen-Client-Id` as a + // privacy signal." Bearer-gated already, so the blast radius + // was small, but the asymmetry is now closed. + if ( + view.initiatorClientId && + callerClientId !== undefined && + callerClientId === view.initiatorClientId + ) { + body['initiatorClientId'] = view.initiatorClientId; + } + return body; +} + +function toDeviceFlowStateBody( + view: DeviceFlowPublicView, +): Record { + const body: Record = { + deviceFlowId: view.deviceFlowId, + providerId: view.providerId, + status: view.status, + createdAt: view.createdAt, + }; + if (view.errorKind) body['errorKind'] = view.errorKind; + if (view.hint) body['hint'] = view.hint; + if (view.userCode) body['userCode'] = view.userCode; + if (view.verificationUri) body['verificationUri'] = view.verificationUri; + if (view.verificationUriComplete) { + body['verificationUriComplete'] = view.verificationUriComplete; + } + if (view.expiresAt !== undefined) body['expiresAt'] = view.expiresAt; + if (view.intervalMs !== undefined) body['intervalMs'] = view.intervalMs; + if (view.lastPolledAt !== undefined) body['lastPolledAt'] = view.lastPolledAt; + if (view.initiatorClientId) { + body['initiatorClientId'] = view.initiatorClientId; + } + return body; +} + function parseClientIdHeader( req: import('express').Request, res: import('express').Response, diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 867f5eafb..165f9e460 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -110,6 +110,11 @@ vi.mock('node:fs', () => ({ writeFile: vi.fn(), unlink: vi.fn(), mkdir: vi.fn().mockResolvedValue(undefined), + // PR #4255 round-11 #2 (gpt-5.5 review): atomic write uses + // temp-file → chmod → rename. Tests need chmod + rename in the + // mocked fs surface; both default to no-op success. + chmod: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), }, })); diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index 45038d9a7..d6a5fd97d 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -109,6 +109,44 @@ export class CredentialsClearRequiredError extends Error { } } +/** + * Typed error thrown by `QwenOAuth2Client.pollDeviceToken` for upstream + * RFC 8628 errors that aren't `authorization_pending` / `slow_down`. + * + * Earlier the class threw a plain `Error` with the OAuth code embedded + * in the message text; downstream callers (notably PR #4255's + * device-flow registry provider) had to substring-match the message + * to extract the error code, an implicit cross-file contract that + * silently degrades to `upstream_error` if the message format ever + * changes. The structured `oauthError` / `description` / `status` + * fields make the contract explicit + type-checked. + * + * The thrown `message` keeps the same `"Device token poll failed: + * ${error} - ${description}"` shape so existing log-parsing / + * substring-matching code continues to work; new code should branch + * on `instanceof QwenOAuthPollError` + read fields directly. + */ +export class QwenOAuthPollError extends Error { + readonly status?: number; + readonly oauthError?: string; + readonly description?: string; + constructor(opts: { + oauthError?: string; + description?: string; + status?: number; + }) { + super( + `Device token poll failed: ${opts.oauthError ?? 'Unknown error'} - ${ + opts.description ?? '(no description)' + }`, + ); + this.name = 'QwenOAuthPollError'; + this.oauthError = opts.oauthError; + this.description = opts.description; + this.status = opts.status; + } +} + /** * Qwen OAuth2 credentials interface */ @@ -237,15 +275,21 @@ export interface IQwenOAuth2Client { setCredentials(credentials: QwenCredentials): void; getCredentials(): QwenCredentials; getAccessToken(): Promise<{ token?: string }>; - requestDeviceAuthorization(options: { - scope: string; - code_challenge: string; - code_challenge_method: string; - }): Promise; - pollDeviceToken(options: { - device_code: string; - code_verifier: string; - }): Promise; + requestDeviceAuthorization( + options: { + scope: string; + code_challenge: string; + code_challenge_method: string; + }, + fetchOpts?: { signal?: AbortSignal }, + ): Promise; + pollDeviceToken( + options: { + device_code: string; + code_verifier: string; + }, + fetchOpts?: { signal?: AbortSignal }, + ): Promise; refreshAccessToken(): Promise; } @@ -287,11 +331,14 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { } } - async requestDeviceAuthorization(options: { - scope: string; - code_challenge: string; - code_challenge_method: string; - }): Promise { + async requestDeviceAuthorization( + options: { + scope: string; + code_challenge: string; + code_challenge_method: string; + }, + fetchOpts?: { signal?: AbortSignal }, + ): Promise { const bodyData = { client_id: QWEN_OAUTH_CLIENT_ID, scope: options.scope, @@ -307,6 +354,12 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { 'x-request-id': randomUUID(), }, body: objectToUrlEncoded(bodyData), + // PR #4255 — daemon device-flow registry passes its + // `cancelController.signal` so dispose / cancel during a slow + // device-authorization request actually aborts the in-flight + // socket immediately. Pre-existing CLI callers omit it; the + // optional shape preserves backward compatibility. + ...(fetchOpts?.signal ? { signal: fetchOpts.signal } : {}), }); if (!response.ok) { @@ -317,7 +370,28 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { } const result = (await response.json()) as DeviceAuthorizationResponse; - debugLogger.debug('Device authorization result:', result); + // PR #4255 fold-in 9 review thread #12: do NOT log the full + // result. `device_code` is an RFC 8628 bearer-equivalent + // credential — anyone holding it within the grant's lifetime + // can complete the token exchange. The daemon device-flow + // registry's `BrandedSecret` keeps `device_code` out of HTTP + // bodies / events / logs, but a debug-mode `console.log(result)` + // here would write the raw `device_code` to stderr / journald, + // bypassing the entire redaction layer. Log only the + // operationally-useful timing fields (size + presence of error + // envelope + lifetimes); secrets stay in memory. + if (isDeviceAuthorizationSuccess(result)) { + debugLogger.debug('Device authorization result (sanitized):', { + ok: true, + expires_in: result.expires_in, + }); + } else { + const errorData = result as ErrorData; + debugLogger.debug('Device authorization result (sanitized):', { + ok: false, + error: errorData?.error, + }); + } // Check if the response indicates success if (!isDeviceAuthorizationSuccess(result)) { @@ -330,10 +404,13 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { return result; } - async pollDeviceToken(options: { - device_code: string; - code_verifier: string; - }): Promise { + async pollDeviceToken( + options: { + device_code: string; + code_verifier: string; + }, + fetchOpts?: { signal?: AbortSignal }, + ): Promise { const bodyData = { grant_type: QWEN_OAUTH_GRANT_TYPE, client_id: QWEN_OAUTH_CLIENT_ID, @@ -348,6 +425,11 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { Accept: 'application/json', }, body: objectToUrlEncoded(bodyData), + // PR #4255 — daemon device-flow registry passes its per-entry + // `cancelController.signal` so cancel() / dispose() during a + // slow IdP response actually aborts the in-flight socket + // instead of waiting for the upstream timeout. + ...(fetchOpts?.signal ? { signal: fetchOpts.signal } : {}), }); if (!response.ok) { @@ -386,12 +468,16 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { // Handle other 400 errors (access_denied, expired_token, etc.) as real errors - // For other errors, throw with proper error information - const error = new Error( - `Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description}`, - ); - (error as Error & { status?: number }).status = response.status; - throw error; + // For other errors, throw a typed `QwenOAuthPollError` so + // downstream callers (PR #4255 device-flow registry) can branch + // on `instanceof` + structured fields instead of substring- + // matching the message text. The message format is preserved + // for log-readers + any pre-existing substring matchers. + throw new QwenOAuthPollError({ + oauthError: errorData.error, + description: errorData.error_description, + status: response.status, + }); } return (await response.json()) as DeviceTokenResponse; @@ -816,22 +902,13 @@ async function authWithQwenDeviceFlow( client.setCredentials(credentials); - // Cache the new tokens + // Cache the new tokens. `cacheQwenCredentials` itself folds + // in `SharedTokenManager.clearCache()` (PR #4255 review D1) so + // we no longer need a paired call here — the previous explicit + // post-cache clear was a duplicate that fired clearCache twice + // on the success path. await cacheQwenCredentials(credentials); - // IMPORTANT: - // SharedTokenManager maintains an in-memory cache and throttles file checks. - // If we only write the creds file here, a subsequent `getQwenOAuthClient()` - // call in the same process (within the throttle window) may not re-read the - // updated file and could incorrectly re-trigger device auth. - // Clearing the cache forces the next call to reload from disk. - try { - SharedTokenManager.getInstance().clearCache(); - } catch { - // In unit tests we sometimes mock SharedTokenManager.getInstance() with a - // minimal stub; cache invalidation is best-effort and should not break auth. - } - emitAuthProgress( 'success', 'Authentication successful! Access token obtained.', @@ -973,13 +1050,120 @@ async function authWithQwenDeviceFlow( } } -async function cacheQwenCredentials(credentials: QwenCredentials) { +// PR 21 (#4175 Wave 4): exported so the `qwen serve` device-flow registry can +// persist credentials acquired through the daemon's HTTP route. Mode 0o600 +// matches opencode's `auth.json` to keep tokens unreadable by other users on +// shared hosts. The constant is exported so tests/auditors can assert intent +// rather than re-deriving it from a raw octal literal. +export const QWEN_CREDENTIAL_FILE_MODE = 0o600; + +export async function cacheQwenCredentials( + credentials: QwenCredentials, + opts?: { signal?: AbortSignal }, +) { const filePath = getQwenCachedCredentialPath(); try { await fs.mkdir(path.dirname(filePath), { recursive: true }); const credString = JSON.stringify(credentials, null, 2); - await fs.writeFile(filePath, credString); + // PR #4255 round-11 #2 (gpt-5.5 review): atomic write with + // permission hardening BEFORE the secret payload becomes + // accessible at the canonical filename. The earlier shape was + // 1. fs.writeFile(filePath, creds, {mode: 0o600}) ← creates + // with 0o600 OR retains existing broader perms + // 2. fs.chmod(filePath, 0o600) ← post-hoc + // tightening + // which left a window where, if `oauth_creds.json` already + // existed with broader perms (operator pre-creation, prior + // version's looser write), the freshly-written tokens were + // momentarily readable by other principals before the chmod + // closed the gap. A chmod failure on POSIX previously degraded + // to a warning while the broadly-readable tokens stayed. + // + // New shape: write to a temp file (created with 0o600 atomically + // via the `mode` flag — which DOES apply on creation since the + // path didn't exist), verify perms, then `rename` over the + // canonical filename. `fs.rename` is atomic on POSIX (within a + // filesystem) and on Windows. The canonical filename never + // contains the new tokens until they're already at 0o600. + // + // PR #4255 fold-in 3 (#10): `signal` threading is preserved — + // both `writeFile` AND the temp-file path honor the registry's + // persist-timeout + cancelController. + const tempPath = `${filePath}.tmp.${process.pid}.${randomUUID()}`; + try { + await fs.writeFile(tempPath, credString, { + mode: QWEN_CREDENTIAL_FILE_MODE, + ...(opts?.signal ? { signal: opts.signal } : {}), + }); + // Defensive: if the platform ignored `mode` on creation + // (some Windows FSes), explicit chmod tightens the temp BEFORE + // it's renamed into place. Failure here is a HARD ERROR — we + // refuse to publish broadly-readable tokens to the canonical + // path. A non-cooperative FS that can't tighten a 0o600 file + // shouldn't be serving credentials anyway. + try { + await fs.chmod(tempPath, QWEN_CREDENTIAL_FILE_MODE); + } catch (chmodErr) { + if (process.platform !== 'win32') { + throw new Error( + `cacheQwenCredentials: refusing to publish credentials — chmod 0o${QWEN_CREDENTIAL_FILE_MODE.toString(8)} on temp file failed: ${ + chmodErr instanceof Error ? chmodErr.message : String(chmodErr) + }`, + ); + } + // Windows: chmod's a no-op on most NTFS volumes; permissions + // there go through ACLs which we don't manage from here. + // Surface a debug breadcrumb for operators on exotic Windows + // filesystems but allow the rename to proceed. + debugLogger.warn( + `cacheQwenCredentials: chmod 0o${QWEN_CREDENTIAL_FILE_MODE.toString(8)} on Windows temp file ${tempPath} failed; relying on NTFS ACL: ${ + chmodErr instanceof Error ? chmodErr.message : String(chmodErr) + }`, + ); + } + // Atomic rename. Replaces any existing file at `filePath` in + // a single inode swap; readers either see the old creds or + // the new creds, never a partial mix. + await fs.rename(tempPath, filePath); + } catch (writeErr) { + // Best-effort cleanup of the temp file — if rename succeeded + // there's nothing to clean (path no longer points anywhere); + // if it failed there's a leftover .tmp.. file we + // shouldn't leave on disk. Swallow ENOENT (already-renamed) + // and any other unlink errors since they're not user-actionable. + try { + await fs.unlink(tempPath); + } catch { + /* best-effort */ + } + throw writeErr; + } + // SharedTokenManager throttles file checks and serves an in-memory cache; + // without an explicit invalidation a follow-up `getValidCredentials` in + // the same process can stay on the previous (often empty) cache and + // re-trigger device auth despite the just-written file. The original + // device-flow site (L820+L829) paired write+clear; folding the clear + // here keeps every caller (#4255 daemon device-flow registry included) + // correct without re-pairing the call. + try { + SharedTokenManager.getInstance().clearCache(); + } catch (clearErr) { + // In production, a failed cache clear means subsequent + // `getValidCredentials` reads in the same process may serve + // stale (pre-write) credentials until the SharedTokenManager + // mtime watcher catches up. That's a recoverable degradation + // (worst case: device auth re-prompts), but the silent swallow + // it used to be made the symptom invisible. Warn so logs show + // it. Unit tests stubbing `SharedTokenManager.getInstance()` + // with a minimal shape will also flow through here — acceptable + // noise for the production-visibility win. + debugLogger.warn( + `cacheQwenCredentials: SharedTokenManager.clearCache failed; in-process callers may serve stale credentials until the next mtime poll: ${ + clearErr instanceof Error ? clearErr.message : String(clearErr) + }`, + ); + } } catch (error: unknown) { // Handle file system errors (e.g., EACCES permission denied) const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/packages/sdk-typescript/src/daemon/DaemonAuthFlow.ts b/packages/sdk-typescript/src/daemon/DaemonAuthFlow.ts new file mode 100644 index 000000000..ac3519a8f --- /dev/null +++ b/packages/sdk-typescript/src/daemon/DaemonAuthFlow.ts @@ -0,0 +1,339 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DaemonHttpError, type DaemonClient } from './DaemonClient.js'; +import type { DaemonAuthProviderId, DaemonDeviceFlowState } from './types.js'; + +/** + * Grace period added past the daemon-stated `expiresAt` before + * `awaitCompletion` gives up. Covers (a) clock skew between SDK and + * daemon, (b) the daemon's own sweep interval (so we don't bail one + * tick before the daemon would surface a synthetic `expired` + * terminal), and (c) per-poll network latency. + * + * **Why 30 s, and which daemon constant it relates to.** The relevant + * daemon-side constant is `DEVICE_FLOW_SWEEP_INTERVAL_MS` (the + * interval at which the registry's sweeper RUNS — currently 30 s), + * NOT `DEVICE_FLOW_TERMINAL_GRACE_MS` (the 5-minute window during + * which terminal entries remain GET-able before eviction). One sweep + * cycle past `expiresAt` is enough to flip the entry to a synthetic + * `expired`/`expired_token` terminal state; once that happens the + * SDK's GET poll will return it immediately. Waiting any longer + * client-side just delays the inevitable. PR #4255 fold-in 6 review + * thread #3. + * + * **Not** to be confused with `TERMINAL_GRACE_MS` — terminal entries + * remain queryable for 5 minutes after they go terminal, but that's + * a reconnect-affordance for SDK clients that want to *re-read* a + * settled state, not a window `awaitCompletion` needs to wait + * through. Keep this aligned with `SWEEP_INTERVAL_MS`; if the daemon + * ever raises its sweep cadence, raise this in lockstep. + */ +export const DEVICE_FLOW_EXPIRY_GRACE_MS = 30_000; + +/** + * High-level convenience wrapper around the four `client.*DeviceFlow*` HTTP + * helpers. SDK users should normally write: + * + * const flow = await client.auth.start({ providerId: 'qwen-oauth' }); + * console.log(`Open ${flow.verificationUri}\nCode: ${flow.userCode}`); + * const result = await flow.awaitCompletion({ signal }); + * + * `awaitCompletion` polls `client.getDeviceFlow(...)` at the daemon- + * supplied `intervalMs`, honors `slow_down`-driven interval bumps via + * `getDeviceFlow`'s response, and terminates when the daemon's view + * reaches a terminal status (`authorized`, `expired`, `error`, + * `cancelled`). The same `auth_device_flow_*` SSE events are emitted + * by the daemon for clients that ARE already subscribed to a session + * stream — those provide a real-time hint, but `awaitCompletion` + * itself does not require an SSE subscription and works against any + * client that can hit the GET endpoint. + * + * Issue #4175 PR 21. + */ +export interface DaemonAuthFlowHandle { + deviceFlowId: string; + providerId: DaemonAuthProviderId; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresAt: number; + intervalMs: number; + /** True iff the daemon returned an existing pending entry rather than + * starting a fresh IdP request. */ + attached: boolean; + /** Block until the daemon settles the flow into a terminal state, then + * return the final state. The promise rejects on `signal.abort()`. */ + awaitCompletion( + opts?: AwaitCompletionOptions, + ): Promise; + /** Cancel the in-flight device flow on the daemon. Idempotent. */ + cancel(): Promise; +} + +export interface AwaitCompletionOptions { + /** Aborts both SSE consumption and GET-fallback polling. */ + signal?: AbortSignal; + /** Called whenever the daemon reports an upstream `slow_down` (mirroring + * the `auth_device_flow_throttled` event). The new effective interval + * is the value the SDK will use for the next GET poll. */ + onThrottled?: (intervalMs: number) => void; + /** Optional override of the GET-fallback interval. Defaults to the + * daemon-supplied `intervalMs` from `start(...)` and respects bumps + * from `slow_down`. */ + pollOverrideMs?: number; + /** Hard ceiling on `awaitCompletion`'s wall-clock duration, in ms. + * When omitted, `awaitCompletion` runs until the daemon-stated + * `expiresAt` plus `DEVICE_FLOW_EXPIRY_GRACE_MS` (default 30s), + * which lets the daemon's own sweeper surface the authoritative + * terminal state instead of timing out client-side. Set explicitly + * to clamp the wait shorter; values past `expiresAt` will still see + * the daemon return `expired` once its sweeper fires. */ + timeoutMs?: number; +} + +const TERMINAL_STATUSES: ReadonlySet = new Set( + ['authorized', 'expired', 'error', 'cancelled'], +); + +export class DaemonAuthFlow { + constructor(private readonly client: DaemonClient) {} + + async start(opts: { + providerId: DaemonAuthProviderId; + clientId?: string; + }): Promise { + const initial = await this.client.startDeviceFlow(opts); + const handleClient = this.client; + const handle: DaemonAuthFlowHandle = { + deviceFlowId: initial.deviceFlowId, + providerId: initial.providerId, + userCode: initial.userCode, + verificationUri: initial.verificationUri, + verificationUriComplete: initial.verificationUriComplete, + expiresAt: initial.expiresAt, + intervalMs: initial.intervalMs, + attached: initial.attached, + cancel: () => + handleClient.cancelDeviceFlow(initial.deviceFlowId, { + clientId: opts.clientId, + }), + awaitCompletion: async (waitOpts = {}) => { + const finalState = await awaitCompletion( + handleClient, + initial, + opts.clientId, + waitOpts, + ); + return finalState; + }, + }; + return handle; + } + + status(deviceFlowId: string, opts?: { clientId?: string }) { + return this.client.getDeviceFlow(deviceFlowId, opts); + } + + cancel(deviceFlowId: string, opts?: { clientId?: string }) { + return this.client.cancelDeviceFlow(deviceFlowId, opts); + } +} + +async function awaitCompletion( + client: DaemonClient, + start: { + deviceFlowId: string; + intervalMs: number; + expiresAt: number; + providerId: DaemonAuthProviderId; + }, + clientId: string | undefined, + opts: AwaitCompletionOptions, +): Promise { + // Workspace-scoped events fan out through whatever session buses + // happen to be live, but `awaitCompletion` is workspace-level (no + // session id) — so attaching to a single SSE stream isn't a stable + // contract here. GET polling against the daemon's authoritative + // device-flow state is the universal path; `auth_device_flow_*` + // events remain a real-time hint for clients that ARE already + // subscribed to a session stream. + return await pollUntilTerminal(client, start, clientId, opts); +} + +/** + * Read the daemon's view of a device flow, mapping a 404 from the + * GET endpoint to a synthetic terminal `error`/`not_found_or_evicted` + * state instead of letting `DaemonHttpError(404)` escape. PR #4255 + * fold-in 7 review thread #4: extracted from the inline catch in + * `pollUntilTerminal` so the timeout-ceiling final read uses the same + * logic — without this, the ceiling read would reject with a raw + * `DaemonHttpError` if the daemon evicted the entry exactly at the + * boundary, breaking `awaitCompletion`'s "always returns a settled + * `DaemonDeviceFlowState`" contract. + */ +async function getDeviceFlowOrSynthetic404( + client: DaemonClient, + start: { + deviceFlowId: string; + providerId: DaemonAuthProviderId; + }, + clientId: string | undefined, + signal: AbortSignal | undefined, +): Promise { + try { + return await client.getDeviceFlow(start.deviceFlowId, { + clientId, + signal, + }); + } catch (err: unknown) { + if (err instanceof DaemonHttpError && err.status === 404) { + // PR #4255 fold-in 3 (#4): a 404 here can mean (a) the entry + // expired and the sweeper reaped it past the terminal grace + // window, (b) the daemon was restarted and lost the registry, + // (c) the deviceFlowId was wrong / spoofed. The earlier + // synthetic `'expired'` status conflated all three. Surface + // `status: 'error'` + `errorKind: 'not_found_or_evicted'` so + // SDK consumers can distinguish "your flow expired during your + // disconnect" from "this id was never valid on this daemon." + return { + deviceFlowId: start.deviceFlowId, + providerId: start.providerId, + status: 'error', + errorKind: 'not_found_or_evicted', + hint: 'device-flow not found on daemon (evicted past terminal grace, daemon restart, or unknown deviceFlowId)', + createdAt: Date.now(), + }; + } + throw err; + } +} + +/** + * Validate an `AwaitCompletionOptions` numeric field. PR #4255 + * fold-in 7 review thread #5: `NaN` / `Infinity` from a misbehaving + * caller would otherwise produce a `ceiling` of `NaN` (so `now >= + * ceiling` is always `false` — the loop runs forever) or a + * `setTimeout(NaN)` (Node clamps to a 1 ms delay — tight polling + * loop). Reject non-finite-positive values; when the caller's intent + * was sloppy ("a long timeout") they fall back to the documented + * default rather than getting a pathological loop. + */ +function sanitizePositiveMs( + raw: number | undefined, + opts: { allowZero?: boolean } = {}, +): number | undefined { + if (raw === undefined) return undefined; + if (!Number.isFinite(raw)) return undefined; + // PR #4255 fold-in 9 review thread #6: `timeoutMs: 0` is the + // documented "settle immediately, return current daemon view" + // contract — must be honored, not collapsed to falsy. Opt-in via + // `allowZero` so `pollOverrideMs: 0` still falls back to the + // default (a 0 ms poll interval is a tight loop, not a useful + // contract). + if (opts.allowZero ? raw < 0 : raw <= 0) return undefined; + return raw; +} + +async function pollUntilTerminal( + client: DaemonClient, + start: { + deviceFlowId: string; + intervalMs: number; + expiresAt: number; + /** Carried through from the parent `start` so the synthetic 404 + * fallback below reports the actual provider rather than the + * hardcoded `'qwen-oauth'` (PR #4255 review C1). */ + providerId: DaemonAuthProviderId; + }, + clientId: string | undefined, + opts: AwaitCompletionOptions, +): Promise { + const signal = opts.signal; + // PR #4255 fold-in 7 review thread #5: validate caller-supplied + // numeric inputs BEFORE composing the ceiling / interval. NaN / + // Infinity slip past the original `?? default` form (they're + // truthy-ish) and break the loop's wall-clock guard. + const sanitizedTimeoutMs = sanitizePositiveMs(opts.timeoutMs, { + allowZero: true, + }); + const sanitizedPollOverrideMs = sanitizePositiveMs(opts.pollOverrideMs); + // PR #4255 fold-in 9 review thread #6: use `!== undefined` (not + // truthy check) so `timeoutMs: 0` produces a `ceiling = Date.now()` + // — which the loop's `now >= ceiling` guard will satisfy on the + // very first iteration, returning the daemon's current snapshot + // immediately. The earlier `?` form treated 0 as falsy and + // silently fell back to the default. + const ceiling = + sanitizedTimeoutMs !== undefined + ? Date.now() + sanitizedTimeoutMs + : start.expiresAt + DEVICE_FLOW_EXPIRY_GRACE_MS; + let interval = Math.max( + 1_000, + sanitizedPollOverrideMs ?? start.intervalMs ?? 5_000, + ); + let lastIntervalMs = interval; + while (true) { + if (signal?.aborted) { + throw signalAbortError(signal); + } + const now = Date.now(); + if (now >= ceiling) { + // PR #4255 fold-in 7 #4: route the ceiling read through the + // same 404-aware helper as the loop body. A 404 at the + // boundary is a settled state, not a throw. + return await getDeviceFlowOrSynthetic404(client, start, clientId, signal); + } + const snapshot = await getDeviceFlowOrSynthetic404( + client, + start, + clientId, + signal, + ); + if (snapshot.intervalMs && snapshot.intervalMs !== lastIntervalMs) { + lastIntervalMs = snapshot.intervalMs; + interval = snapshot.intervalMs; + opts.onThrottled?.(snapshot.intervalMs); + } + if (TERMINAL_STATUSES.has(snapshot.status)) return snapshot; + await waitFor(interval, signal); + } +} + +async function waitFor(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) throw signalAbortError(signal); + await new Promise((resolve, reject) => { + // PR #4255 review C5: do NOT `unref()` this timer. The earlier + // version did, which on a standalone Node CLI/script that does + // `await client.auth.start().awaitCompletion()` and nothing else + // could leave Node with no remaining ref'd handles between polls + // and exit the process before the user finishes authorization. + // This sleep is foreground work the caller explicitly awaits; + // unref'ing it broke the contract. + const handle = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const onAbort = () => { + cleanup(); + reject(signalAbortError(signal)); + }; + function cleanup() { + clearTimeout(handle); + signal?.removeEventListener('abort', onAbort); + } + if (signal) { + signal.addEventListener('abort', onAbort, { once: true }); + } + }); +} + +function signalAbortError(signal: AbortSignal | undefined): Error { + const reason = signal?.reason; + if (reason instanceof Error) return reason; + if (typeof reason === 'string') return new Error(reason); + return new Error('aborted'); +} diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 68c5ac4c7..54e002d59 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -4,11 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { DaemonAuthFlow } from './DaemonAuthFlow.js'; import { parseSseStream } from './sse.js'; import type { DaemonAgentMutationResult, + DaemonAuthProviderId, + DaemonAuthStatusSnapshot, DaemonCapabilities, DaemonCreateAgentRequest, + DaemonDeviceFlowStartResult, + DaemonDeviceFlowState, DaemonEvent, DaemonSessionContextStatus, DaemonRestoredSession, @@ -175,6 +180,21 @@ export class DaemonClient { private readonly token: string | undefined; private readonly _fetch: typeof globalThis.fetch; private readonly fetchTimeoutMs: number; + // Lazy singleton so clients that never touch auth pay no allocation cost. + // Exposed via the readonly `auth` accessor below. + private _authFlow?: DaemonAuthFlow; + + /** + * High-level auth helper (issue #4175 PR 21). Wraps the four + * `*DeviceFlow*` methods with a `start(...).awaitCompletion()` shape + * for the common "log in remotely" UX. Lazy-constructed. + */ + get auth(): DaemonAuthFlow { + if (!this._authFlow) { + this._authFlow = new DaemonAuthFlow(this); + } + return this._authFlow; + } constructor(opts: DaemonClientOptions) { this.baseUrl = stripTrailingSlashes(opts.baseUrl); @@ -1038,6 +1058,115 @@ export class DaemonClient { ); } + // -- Auth device-flow (issue #4175 PR 21) ------------------------------- + + /** + * Start an OAuth device-flow login for the given provider. The daemon + * polls the IdP in the background and emits typed `auth_device_flow_*` + * SSE events; callers can also poll `getDeviceFlow(...)`. + * + * Per-provider singleton: a repeat call while a flow is already pending + * for the same provider is an idempotent take-over and returns the + * existing entry rather than starting a fresh IdP request. The + * `attached` field on the result distinguishes the two cases. + */ + async startDeviceFlow(opts: { + providerId: DaemonAuthProviderId; + clientId?: string; + }): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/auth/device-flow`, + { + method: 'POST', + headers: this.headers( + { 'Content-Type': 'application/json' }, + opts.clientId, + ), + body: JSON.stringify({ providerId: opts.providerId }), + }, + async (res) => { + if (res.status !== 200 && res.status !== 201) { + throw await this.failOnError(res, 'POST /workspace/auth/device-flow'); + } + return (await res.json()) as DaemonDeviceFlowStartResult; + }, + ); + } + + async getDeviceFlow( + deviceFlowId: string, + opts: { clientId?: string; signal?: AbortSignal } = {}, + ): Promise { + // PR #4255 fold-in 7 review thread #6: forward `signal` into + // `fetchWithTimeout`, which composes it with the per-request + // `fetchTimeoutMs` controller. Without this, an `awaitCompletion` + // caller that aborts mid-poll could not cancel the in-flight GET + // — only the post-await guard would notice, but that runs only + // after the body is already settled (or the daemon-side + // `fetchTimeoutMs` fires, which can be 30s+). + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/auth/device-flow/${encodeURIComponent(deviceFlowId)}`, + { headers: this.headers({}, opts.clientId), signal: opts.signal }, + async (res) => { + if (!res.ok) { + throw await this.failOnError( + res, + 'GET /workspace/auth/device-flow/:id', + ); + } + return (await res.json()) as DaemonDeviceFlowState; + }, + ); + } + + /** + * Cancel a pending device-flow. Idempotent: terminal entries return + * 204 (no-op); unknown ids return 404 — both resolve here, matching + * the SDK's `closeSession` shape. + */ + async cancelDeviceFlow( + deviceFlowId: string, + opts: { clientId?: string } = {}, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/auth/device-flow/${encodeURIComponent(deviceFlowId)}`, + { + method: 'DELETE', + headers: this.headers({}, opts.clientId), + }, + async (res) => { + if (res.status === 204 || res.status === 404) { + try { + await res.body?.cancel(); + } catch { + /* body already consumed or no body */ + } + return; + } + throw await this.failOnError( + res, + 'DELETE /workspace/auth/device-flow/:id', + ); + }, + ); + } + + /** Snapshot of persisted auth credentials + currently pending device-flows. */ + async getAuthStatus( + opts: { clientId?: string } = {}, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/auth/status`, + { headers: this.headers({}, opts.clientId) }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /workspace/auth/status'); + } + return (await res.json()) as DaemonAuthStatusSnapshot; + }, + ); + } + // -- Session metadata ---------------------------------------------------- /** diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index aa7e0266c..025482408 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -25,6 +25,15 @@ const DAEMON_KNOWN_EVENT_TYPE_VALUES = [ // updated" toasts. Read-after-write remains the correctness contract. 'memory_changed', 'agent_changed', + // Issue #4175 PR 21 — workspace-scoped auth device-flow events. + // These are NOT session-keyed; the session reducer no-ops on them + // and `reduceDaemonAuthEvent` projects them into a workspace-level + // state shape (one entry per provider). + 'auth_device_flow_started', + 'auth_device_flow_throttled', + 'auth_device_flow_authorized', + 'auth_device_flow_failed', + 'auth_device_flow_cancelled', ] as const; const DAEMON_KNOWN_EVENT_TYPES: ReadonlySet = new Set( @@ -158,6 +167,77 @@ export interface DaemonAgentChangedData { [key: string]: unknown; } +/** Issue #4175 PR 21 — auth device-flow event payloads. */ + +/** Provider id. Open string union for forward-compatible providers; `qwen-oauth` + * is the only value v1 currently emits. */ +export type DaemonAuthDeviceFlowProviderId = 'qwen-oauth' | (string & {}); + +export type DaemonAuthDeviceFlowStatus = + | 'pending' + | 'authorized' + | 'expired' + | 'error' + | 'cancelled'; + +/** + * Known errorKind values surfaced on `auth_device_flow_failed`. The + * trailing `(string & {})` keeps this as an OPEN union so a daemon + * adding a new errorKind doesn't get its event silently dropped by an + * older SDK's type guard — consumers branching exhaustively on the + * known literals get the same narrowing as before, while unknown + * future kinds fall through to a `string` fallback rather than failing + * `isAuthDeviceFlowFailedData` and being filtered out by + * `asKnownDaemonEvent` (PR #4255 review C2). + */ +export type DaemonAuthDeviceFlowErrorKind = + | 'expired_token' + | 'access_denied' + | 'invalid_grant' + | 'upstream_error' + /** Disk-write / `provider.persist()` failure path. The IdP-side token + * exchange succeeded but the daemon couldn't durably store credentials + * (EACCES, EROFS, ENOSPC, etc.). Distinct from `upstream_error`. */ + | 'persist_failed' + | (string & {}); + +export interface DaemonAuthDeviceFlowStartedData { + deviceFlowId: string; + providerId: DaemonAuthDeviceFlowProviderId; + /** Daemon-clock epoch ms when the flow's `device_code` expires. */ + expiresAt: number; + [key: string]: unknown; +} + +export interface DaemonAuthDeviceFlowThrottledData { + deviceFlowId: string; + /** Bumped polling interval after the daemon honored an upstream `slow_down`. */ + intervalMs: number; + [key: string]: unknown; +} + +export interface DaemonAuthDeviceFlowAuthorizedData { + deviceFlowId: string; + providerId: DaemonAuthDeviceFlowProviderId; + /** Credential expiry, daemon clock. Undefined when the IdP omitted `expires_in`. */ + expiresAt?: number; + /** Best-effort non-PII account label (nickname / uid hash); never email/phone. */ + accountAlias?: string; + [key: string]: unknown; +} + +export interface DaemonAuthDeviceFlowFailedData { + deviceFlowId: string; + errorKind: DaemonAuthDeviceFlowErrorKind; + hint?: string; + [key: string]: unknown; +} + +export interface DaemonAuthDeviceFlowCancelledData { + deviceFlowId: string; + [key: string]: unknown; +} + export type DaemonSessionUpdateEvent = DaemonEventEnvelope< 'session_update', DaemonSessionUpdateData @@ -215,6 +295,34 @@ export type DaemonAgentChangedEvent = DaemonEventEnvelope< DaemonAgentChangedData >; +export type DaemonAuthDeviceFlowStartedEvent = DaemonEventEnvelope< + 'auth_device_flow_started', + DaemonAuthDeviceFlowStartedData +>; +export type DaemonAuthDeviceFlowThrottledEvent = DaemonEventEnvelope< + 'auth_device_flow_throttled', + DaemonAuthDeviceFlowThrottledData +>; +export type DaemonAuthDeviceFlowAuthorizedEvent = DaemonEventEnvelope< + 'auth_device_flow_authorized', + DaemonAuthDeviceFlowAuthorizedData +>; +export type DaemonAuthDeviceFlowFailedEvent = DaemonEventEnvelope< + 'auth_device_flow_failed', + DaemonAuthDeviceFlowFailedData +>; +export type DaemonAuthDeviceFlowCancelledEvent = DaemonEventEnvelope< + 'auth_device_flow_cancelled', + DaemonAuthDeviceFlowCancelledData +>; + +export type DaemonAuthEvent = + | DaemonAuthDeviceFlowStartedEvent + | DaemonAuthDeviceFlowThrottledEvent + | DaemonAuthDeviceFlowAuthorizedEvent + | DaemonAuthDeviceFlowFailedEvent + | DaemonAuthDeviceFlowCancelledEvent; + export type DaemonSessionEvent = | DaemonSessionUpdateEvent | DaemonModelSwitchedEvent @@ -246,7 +354,8 @@ export type KnownDaemonEvent = | DaemonSessionEvent | DaemonControlEvent | DaemonStreamLifecycleEvent - | DaemonWorkspaceMutationEvent; + | DaemonWorkspaceMutationEvent + | DaemonAuthEvent; export interface DaemonSessionViewState { lastEventId?: number; @@ -397,6 +506,26 @@ export function asKnownDaemonEvent( return isAgentChangedData(event.data) ? (event as DaemonAgentChangedEvent) : undefined; + case 'auth_device_flow_started': + return isAuthDeviceFlowStartedData(event.data) + ? (event as DaemonAuthDeviceFlowStartedEvent) + : undefined; + case 'auth_device_flow_throttled': + return isAuthDeviceFlowThrottledData(event.data) + ? (event as DaemonAuthDeviceFlowThrottledEvent) + : undefined; + case 'auth_device_flow_authorized': + return isAuthDeviceFlowAuthorizedData(event.data) + ? (event as DaemonAuthDeviceFlowAuthorizedEvent) + : undefined; + case 'auth_device_flow_failed': + return isAuthDeviceFlowFailedData(event.data) + ? (event as DaemonAuthDeviceFlowFailedEvent) + : undefined; + case 'auth_device_flow_cancelled': + return isAuthDeviceFlowCancelledData(event.data) + ? (event as DaemonAuthDeviceFlowCancelledEvent) + : undefined; default: return undefined; } @@ -551,6 +680,16 @@ export function reduceDaemonSessionEvent( lastWorkspaceMutation: event.data, lastWorkspaceMutationType: 'agent_changed', }; + // Auth device-flow events are workspace-scoped; the session reducer + // is a no-op (consume `lastEventId` via `base` and otherwise pass + // state through). Workspace-level state lives in `DaemonAuthState` + // and is projected by `reduceDaemonAuthEvent`. + case 'auth_device_flow_started': + case 'auth_device_flow_throttled': + case 'auth_device_flow_authorized': + case 'auth_device_flow_failed': + case 'auth_device_flow_cancelled': + return base; default: { const _exhaustive: never = event; return _exhaustive; @@ -567,6 +706,227 @@ export function reduceDaemonSessionEvents( return state; } +/** Issue #4175 PR 21 — workspace-scoped auth device-flow state. One entry + * per provider; the registry's per-provider singleton constraint is + * reflected here so adapters can render `state.flows[providerId]` without + * worrying about concurrent flows for the same provider. */ +export interface DaemonDeviceFlowReducerState { + deviceFlowId: string; + status: DaemonAuthDeviceFlowStatus; + errorKind?: DaemonAuthDeviceFlowErrorKind; + hint?: string; + /** Most recent `intervalMs` reported by `auth_device_flow_throttled`. */ + intervalMs?: number; + /** Most recent SSE event id observed for this flow (NOT a wall-clock + * timestamp). Used as a monotonic counter so out-of-order delivery + * doesn't let a stale frame overwrite a newer one. `undefined` if + * the underlying envelope omitted `id` (synthetic / SDK-internal + * frames). PR #4255 round-9 #6: changed from `number` (defaulting + * to 0) to `number | undefined` — the daemon-side EventBus assigns + * ids ≥ 1, so `0` is a sentinel that has no meaning in real + * traffic, but the monotonic gate (`rawEventId <= lastSeenEventId`) + * would reject any future synthetic frame using `id: 0`. The gate + * already short-circuits on `existing.lastSeenEventId !== undefined`, + * so undefined is safe. */ + lastSeenEventId: number | undefined; + /** Set on `authorized` to the credential's expiry, when known. */ + authorizedExpiresAt?: number; + /** Best-effort non-PII account label echoed from `authorized`. */ + accountAlias?: string; +} + +export interface DaemonAuthState { + flows: Partial< + Record + >; +} + +export function createDaemonAuthState( + seed: Partial = {}, +): DaemonAuthState { + return { flows: { ...(seed.flows ?? {}) } }; +} + +/** + * Apply a single auth device-flow event to a workspace-scoped auth state. + * Non-auth events (sessions, control, lifecycle) pass through unchanged so + * adapters can fan one event stream into both `reduceDaemonSessionEvent` + * (per session) and `reduceDaemonAuthEvent` (workspace-wide) without + * filtering ahead of time. + * + * Edge cases: + * - `throttled` / `authorized` / `failed` / `cancelled` for a deviceFlowId + * not matching the current `flows[providerId]` are dropped: by the time + * they arrive, that flow's terminal-grace window has already expired or + * the SDK has rebased onto a newer flow. Silently ignoring stale events + * is the correct behavior here (events are non-authoritative; the + * daemon's GET .../device-flow/:id is the source of truth). + */ +export function reduceDaemonAuthEvent( + state: DaemonAuthState, + rawEvent: DaemonEvent, +): DaemonAuthState { + const event = asKnownDaemonEvent(rawEvent); + if (!event) return state; + switch (event.type) { + case 'auth_device_flow_started': { + // PR #4255 fold-in 8 review thread #2: gate stale `started` + // frames the same way as the matching-flow handlers. SSE + // reconnect with `Last-Event-ID < started.id` would otherwise + // replay an old started for the SAME deviceFlowId after the + // SDK reducer already advanced to a terminal state, resetting + // the visible status to 'pending'. A stale started for an + // OLDER flow (different deviceFlowId, lower id than the + // current flow's lastSeenEventId) similarly gets ignored. + const providerId = event.data.providerId; + const existing = state.flows[providerId]; + if ( + existing !== undefined && + rawEvent.id !== undefined && + existing.lastSeenEventId !== undefined && + rawEvent.id <= existing.lastSeenEventId + ) { + return state; + } + return { + flows: { + ...state.flows, + [providerId]: { + deviceFlowId: event.data.deviceFlowId, + status: 'pending', + lastSeenEventId: rawEvent.id ?? existing?.lastSeenEventId, + }, + }, + }; + } + case 'auth_device_flow_throttled': { + const updated = updateMatchingFlow( + state, + event.data.deviceFlowId, + rawEvent.id, + (flow) => ({ + ...flow, + intervalMs: event.data.intervalMs, + lastSeenEventId: rawEvent.id ?? flow.lastSeenEventId, + }), + ); + return updated ?? state; + } + case 'auth_device_flow_authorized': { + const providerId = event.data.providerId; + const existing = state.flows[providerId]; + if (!existing || existing.deviceFlowId !== event.data.deviceFlowId) { + return state; + } + // PR #4255 fold-in 8 review thread #2: enforce monotonicity + // here too. The deviceFlowId equality check above narrows to + // "this frame is for the current flow"; the id gate then + // refuses out-of-order replay (e.g. a delayed `authorized` + // arriving after a more recent `failed` for the same flow, + // which the daemon's transitionTerminal would never produce + // but a malformed/synthetic stream could). + if ( + rawEvent.id !== undefined && + existing.lastSeenEventId !== undefined && + rawEvent.id <= existing.lastSeenEventId + ) { + return state; + } + const next: DaemonDeviceFlowReducerState = { + ...existing, + status: 'authorized', + authorizedExpiresAt: event.data.expiresAt, + accountAlias: event.data.accountAlias, + errorKind: undefined, + lastSeenEventId: rawEvent.id ?? existing.lastSeenEventId, + }; + return { flows: { ...state.flows, [providerId]: next } }; + } + case 'auth_device_flow_failed': { + // The daemon's status machine reserves 'expired' for the time-based + // path (now >= expiresAt). Upstream RFC 8628 errors — including + // `expired_token` — go to 'error' with `errorKind` carrying the + // distinction. Earlier drafts collapsed `errorKind: 'expired_token'` + // to status 'expired', which gave SDK consumers a different + // status than the daemon's GET endpoint reported. Code-reviewer + // P1-9 / silent-failure D2: align with daemon, surface errorKind + // separately. + const updated = updateMatchingFlow( + state, + event.data.deviceFlowId, + rawEvent.id, + (flow) => ({ + ...flow, + status: 'error', + errorKind: event.data.errorKind, + hint: event.data.hint, + lastSeenEventId: rawEvent.id ?? flow.lastSeenEventId, + }), + ); + return updated ?? state; + } + case 'auth_device_flow_cancelled': { + const updated = updateMatchingFlow( + state, + event.data.deviceFlowId, + rawEvent.id, + (flow) => ({ + ...flow, + status: 'cancelled', + lastSeenEventId: rawEvent.id ?? flow.lastSeenEventId, + }), + ); + return updated ?? state; + } + default: + return state; + } +} + +export function reduceDaemonAuthEvents( + events: Iterable, + initialState: DaemonAuthState = createDaemonAuthState(), +): DaemonAuthState { + let state = initialState; + for (const event of events) state = reduceDaemonAuthEvent(state, event); + return state; +} + +function updateMatchingFlow( + state: DaemonAuthState, + deviceFlowId: string, + rawEventId: number | undefined, + patch: (flow: DaemonDeviceFlowReducerState) => DaemonDeviceFlowReducerState, +): DaemonAuthState | undefined { + const entries = Object.entries(state.flows) as Array< + [DaemonAuthDeviceFlowProviderId, DaemonDeviceFlowReducerState | undefined] + >; + for (const [providerId, flow] of entries) { + if (flow && flow.deviceFlowId === deviceFlowId) { + // PR #4255 fold-in 8 review thread #2: enforce the + // monotonicity guarantee that `lastSeenEventId`'s JSDoc + // documents. Out-of-order delivery (SSE replay-then-live + // mixing) could otherwise let a stale frame overwrite a + // newer terminal state. Synthetic frames without an + // envelope `id` (rawEventId === undefined) bypass the + // gate — they originate inside the SDK reducer machinery + // (e.g. fallback paths) and aren't subject to replay + // ordering. + if ( + rawEventId !== undefined && + flow.lastSeenEventId !== undefined && + rawEventId <= flow.lastSeenEventId + ) { + return state; + } + return { + flows: { ...state.flows, [providerId]: patch(flow) }, + }; + } + } + return undefined; +} + function isKnownDaemonEventTypeName( type: string, ): type is DaemonKnownEventType { @@ -731,6 +1091,70 @@ function isAgentChangedData(value: unknown): value is DaemonAgentChangedData { ); } +function isAuthDeviceFlowStartedData( + value: unknown, +): value is DaemonAuthDeviceFlowStartedData { + return ( + isRecord(value) && + isNonEmptyString(value['deviceFlowId']) && + isNonEmptyString(value['providerId']) && + isFiniteNumber(value['expiresAt']) + ); +} + +function isAuthDeviceFlowThrottledData( + value: unknown, +): value is DaemonAuthDeviceFlowThrottledData { + return ( + isRecord(value) && + isNonEmptyString(value['deviceFlowId']) && + isFiniteNumber(value['intervalMs']) + ); +} + +function isAuthDeviceFlowAuthorizedData( + value: unknown, +): value is DaemonAuthDeviceFlowAuthorizedData { + return ( + isRecord(value) && + isNonEmptyString(value['deviceFlowId']) && + isNonEmptyString(value['providerId']) && + isOptionalNumber(value['expiresAt']) && + isOptionalStringOrNull(value['accountAlias']) + ); +} + +function isAuthDeviceFlowFailedData( + value: unknown, +): value is DaemonAuthDeviceFlowFailedData { + return ( + isRecord(value) && + isNonEmptyString(value['deviceFlowId']) && + isAuthDeviceFlowErrorKind(value['errorKind']) && + isOptionalStringOrNull(value['hint']) + ); +} + +function isAuthDeviceFlowCancelledData( + value: unknown, +): value is DaemonAuthDeviceFlowCancelledData { + return isRecord(value) && isNonEmptyString(value['deviceFlowId']); +} + +function isAuthDeviceFlowErrorKind( + value: unknown, +): value is DaemonAuthDeviceFlowErrorKind { + // Forward-compat: accept ANY non-empty string. The earlier closed + // allowlist would silently drop a daemon-emitted `failed` event with + // a future errorKind (e.g. `rate_limited`) — `asKnownDaemonEvent` + // would treat it as malformed and `reduceDaemonAuthEvent` never + // transitions the flow's status, leaving SDK consumers stuck on + // `pending` (PR #4255 review C2). The known literals still narrow + // exhaustively in consumer `switch` statements; unknown kinds fall + // into the `(string & {})` arm of the union for graceful handling. + return typeof value === 'string' && value.length > 0; +} + function isPermissionOption(value: unknown): value is DaemonPermissionOption { return isRecord(value) && isNonEmptyString(value['optionId']); } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index d2ed7ccfb..80a7047ca 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -13,6 +13,12 @@ export { type RestoreSessionRequest, type SubscribeOptions, } from './DaemonClient.js'; +export { + DaemonAuthFlow, + DEVICE_FLOW_EXPIRY_GRACE_MS, + type AwaitCompletionOptions, + type DaemonAuthFlowHandle, +} from './DaemonAuthFlow.js'; export { DaemonSessionClient, type DaemonSessionClientOptions, @@ -20,9 +26,12 @@ export { } from './DaemonSessionClient.js'; export { asKnownDaemonEvent, + createDaemonAuthState, createDaemonSessionViewState, isDaemonEventType, isKnownDaemonEvent, + reduceDaemonAuthEvent, + reduceDaemonAuthEvents, reduceDaemonSessionEvent, reduceDaemonSessionEvents, } from './events.js'; @@ -70,6 +79,22 @@ export type { DaemonStreamErrorEvent, DaemonStreamLifecycleEvent, DaemonWorkspaceMutationEvent, + DaemonAuthDeviceFlowProviderId, + DaemonAuthDeviceFlowStatus, + DaemonAuthDeviceFlowErrorKind, + DaemonAuthDeviceFlowStartedData, + DaemonAuthDeviceFlowStartedEvent, + DaemonAuthDeviceFlowThrottledData, + DaemonAuthDeviceFlowThrottledEvent, + DaemonAuthDeviceFlowAuthorizedData, + DaemonAuthDeviceFlowAuthorizedEvent, + DaemonAuthDeviceFlowFailedData, + DaemonAuthDeviceFlowFailedEvent, + DaemonAuthDeviceFlowCancelledData, + DaemonAuthDeviceFlowCancelledEvent, + DaemonAuthEvent, + DaemonDeviceFlowReducerState, + DaemonAuthState, KnownDaemonEvent, } from './events.js'; export type { @@ -90,6 +115,13 @@ export type { DaemonProtocolVersions, DaemonRestoredSession, DaemonSession, + DaemonAuthProviderId, + DaemonAuthDeviceFlowSdkStatus, + DaemonAuthDeviceFlowSdkErrorKind, + DaemonAuthProviderStatus, + DaemonAuthStatusSnapshot, + DaemonDeviceFlowStartResult, + DaemonDeviceFlowState, DaemonSessionContextStatus, DaemonSessionState, DaemonSessionSummary, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index c6c4110ff..35b0b88af 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -621,6 +621,85 @@ export interface HeartbeatResult { lastSeenAt: number; } +/** Issue #4175 PR 21 — auth device-flow wire types. */ + +export type DaemonAuthProviderId = 'qwen-oauth' | (string & {}); + +// PR #4255 review S4: Sdk-prefixed aliases USED to be parallel literal +// unions, which silently diverged from the canonical event-side types +// the moment one was extended. Single-source the canonical definitions +// from `./events.js` so a single source of truth governs both layers +// (event payloads + REST wire shapes). TypeScript handles the +// circular type-only import cleanly because there is no runtime +// dependency direction. Local `type X = ...` aliases (rather than a +// re-export) make the symbols usable INSIDE this module too — required +// by `DaemonDeviceFlowState` / `DaemonAuthProviderStatus` below. +import type { + DaemonAuthDeviceFlowStatus, + DaemonAuthDeviceFlowErrorKind, +} from './events.js'; +export type DaemonAuthDeviceFlowSdkStatus = DaemonAuthDeviceFlowStatus; +export type DaemonAuthDeviceFlowSdkErrorKind = DaemonAuthDeviceFlowErrorKind; + +/** Returned from `POST /workspace/auth/device-flow`. */ +export interface DaemonDeviceFlowStartResult { + deviceFlowId: string; + providerId: DaemonAuthProviderId; + status: DaemonAuthDeviceFlowSdkStatus; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresAt: number; + intervalMs: number; + /** True iff the daemon returned an existing pending entry rather than + * starting a fresh flow (per-provider singleton take-over). */ + attached: boolean; + initiatorClientId?: string; +} + +/** Returned from `GET /workspace/auth/device-flow/:id`. */ +export interface DaemonDeviceFlowState { + deviceFlowId: string; + providerId: DaemonAuthProviderId; + status: DaemonAuthDeviceFlowSdkStatus; + errorKind?: DaemonAuthDeviceFlowSdkErrorKind; + hint?: string; + userCode?: string; + verificationUri?: string; + verificationUriComplete?: string; + expiresAt?: number; + intervalMs?: number; + lastPolledAt?: number; + createdAt: number; + initiatorClientId?: string; +} + +export interface DaemonAuthProviderStatus extends DaemonStatusCell { + kind: 'auth_provider'; + providerId: DaemonAuthProviderId; + expiresAt?: number; + /** Best-effort non-PII account label. Never email/phone/username. */ + accountAlias?: string; +} + +/** Returned from `GET /workspace/auth/status`. */ +export interface DaemonAuthStatusSnapshot { + v: 1; + workspaceCwd: string; + /** Currently registered providers and their auth status. */ + providers: DaemonAuthProviderStatus[]; + /** Pending flows; userCode/verificationUri intentionally redacted (the + * full record is fetched via GET /workspace/auth/device-flow/:id). */ + pendingDeviceFlows: Array<{ + deviceFlowId: string; + providerId: DaemonAuthProviderId; + expiresAt: number; + }>; + /** Provider ids the daemon advertises support for under + * `POST /workspace/auth/device-flow`. */ + supportedDeviceFlowProviders: DaemonAuthProviderId[]; +} + /** A frame in the SSE event stream. */ export interface DaemonEvent { /** diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 9366752d6..7b0ff4a67 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -108,6 +108,44 @@ export { type SubscribeOptions, } from './daemon/index.js'; +// PR #4255 fold-in 9 review thread #11 — Issue #4175 PR 21 auth +// surface. These were re-exported from `./daemon/index.js` but the +// public SDK entry (this file) never re-exported them, so an +// `import { DaemonAuthFlow } from '@qwen-code/sdk'` resolved to +// undefined. The PR description lists `reduceDaemonAuthEvent` as +// SDK surface and `client.auth.start()` works only because +// `DaemonClient` (already exported above) constructs `DaemonAuthFlow` +// internally; every other API path was unreachable. +export { + DaemonAuthFlow, + DEVICE_FLOW_EXPIRY_GRACE_MS, + createDaemonAuthState, + reduceDaemonAuthEvent, + reduceDaemonAuthEvents, + type AwaitCompletionOptions, + type DaemonAuthDeviceFlowAuthorizedData, + type DaemonAuthDeviceFlowAuthorizedEvent, + type DaemonAuthDeviceFlowCancelledData, + type DaemonAuthDeviceFlowCancelledEvent, + type DaemonAuthDeviceFlowErrorKind, + type DaemonAuthDeviceFlowFailedData, + type DaemonAuthDeviceFlowFailedEvent, + type DaemonAuthDeviceFlowProviderId, + type DaemonAuthDeviceFlowStartedData, + type DaemonAuthDeviceFlowStartedEvent, + type DaemonAuthDeviceFlowStatus, + type DaemonAuthDeviceFlowThrottledData, + type DaemonAuthDeviceFlowThrottledEvent, + type DaemonAuthEvent, + type DaemonAuthFlowHandle, + type DaemonAuthProviderId, + type DaemonAuthState, + type DaemonAuthStatusSnapshot, + type DaemonDeviceFlowReducerState, + type DaemonDeviceFlowStartResult, + type DaemonDeviceFlowState, +} from './daemon/index.js'; + // SDK MCP Server exports export { tool } from './mcp/tool.js'; export { createSdkMcpServer } from './mcp/createSdkMcpServer.js'; diff --git a/packages/sdk-typescript/test/unit/DaemonAuthFlow.test.ts b/packages/sdk-typescript/test/unit/DaemonAuthFlow.test.ts new file mode 100644 index 000000000..bdc253e34 --- /dev/null +++ b/packages/sdk-typescript/test/unit/DaemonAuthFlow.test.ts @@ -0,0 +1,411 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { DaemonAuthFlow } from '../../src/daemon/DaemonAuthFlow.js'; +import { + DaemonHttpError, + type DaemonClient, +} from '../../src/daemon/DaemonClient.js'; +import type { + DaemonDeviceFlowStartResult, + DaemonDeviceFlowState, +} from '../../src/daemon/types.js'; + +// PR #4255 fold-in 10 #2: covers `DaemonAuthFlow`'s `start()` + +// `awaitCompletion()` state machine end-to-end. The class is the +// primary SDK entry point in PR 21's user-facing surface +// (`client.auth.start({providerId}).awaitCompletion()`); this file +// exercises the production paths the round-8 reviewer flagged as +// untested: +// - happy path polling → `authorized` +// - `slow_down` interval bumping + `onThrottled` callback +// - `AbortSignal` propagation through both polling and the GET +// - `timeoutMs` ceiling (incl. the round-9 #6 `0` honor) +// - 404 → synthetic `error`/`not_found_or_evicted` (loop AND ceiling) +// - `sanitizePositiveMs` edge cases (NaN / Infinity fallback) +// - `cancel()` wrapper forwards to `client.cancelDeviceFlow` + +interface FakeClientCalls { + start: number; + get: Array<{ + deviceFlowId: string; + clientId?: string; + signal?: AbortSignal; + }>; + cancel: Array<{ deviceFlowId: string; clientId?: string }>; +} + +function makeFakeClient(opts: { + startResult?: DaemonDeviceFlowStartResult; + /** Sequenced replies for `getDeviceFlow`. The Nth call returns the + * Nth entry; if the list runs out, the LAST entry is repeated. + * Either a `DaemonDeviceFlowState` or a thrown error. */ + getReplies: Array; +}): { client: DaemonClient; calls: FakeClientCalls } { + const calls: FakeClientCalls = { start: 0, get: [], cancel: [] }; + const startResult: DaemonDeviceFlowStartResult = opts.startResult ?? { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + userCode: 'USER-1', + verificationUri: 'https://idp.example/verify', + expiresAt: Date.now() + 60_000, + intervalMs: 50, // tests use small intervals so polling is fast + attached: false, + }; + const replies = [...opts.getReplies]; + const fake = { + async startDeviceFlow(_opts: { + providerId: string; + clientId?: string; + }): Promise { + calls.start += 1; + return startResult; + }, + async getDeviceFlow( + deviceFlowId: string, + callOpts: { clientId?: string; signal?: AbortSignal } = {}, + ): Promise { + calls.get.push({ + deviceFlowId, + ...(callOpts.clientId !== undefined + ? { clientId: callOpts.clientId } + : {}), + ...(callOpts.signal !== undefined ? { signal: callOpts.signal } : {}), + }); + const reply = replies.length > 1 ? replies.shift()! : replies[0]; + if (reply instanceof Error) throw reply; + return reply; + }, + async cancelDeviceFlow( + deviceFlowId: string, + callOpts: { clientId?: string } = {}, + ): Promise { + calls.cancel.push({ + deviceFlowId, + ...(callOpts.clientId !== undefined + ? { clientId: callOpts.clientId } + : {}), + }); + }, + }; + return { client: fake as unknown as DaemonClient, calls }; +} + +describe('DaemonAuthFlow.start (fold-in 10 #2)', () => { + it('returns a handle pinned to the daemon-supplied start result', async () => { + const { client } = makeFakeClient({ + getReplies: [ + // Will only be called if awaitCompletion runs; this test only + // exercises start, so reply shape is irrelevant. + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + expect(handle.deviceFlowId).toBe('flow-A'); + expect(handle.providerId).toBe('qwen-oauth'); + expect(handle.userCode).toBe('USER-1'); + expect(handle.attached).toBe(false); + expect(handle.intervalMs).toBe(50); + }); +}); + +describe('DaemonAuthFlow.awaitCompletion (fold-in 10 #2)', () => { + it('polls until the daemon reports a terminal state (authorized)', async () => { + const expiresAt = Date.now() + 5_000; + const { client, calls } = makeFakeClient({ + getReplies: [ + // First two GETs: still pending. + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + createdAt: Date.now(), + }, + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + createdAt: Date.now(), + }, + // Third GET: terminal authorized. + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'authorized', + expiresAt, + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + const final = await handle.awaitCompletion({ pollOverrideMs: 1_000 }); + expect(final.status).toBe('authorized'); + expect(final.expiresAt).toBe(expiresAt); + expect(calls.get.length).toBeGreaterThanOrEqual(3); + }); + + it('honors `slow_down`-driven intervalMs bumps via onThrottled callback', async () => { + const observedIntervals: number[] = []; + const { client } = makeFakeClient({ + getReplies: [ + // First GET: daemon reports a bumped interval (slow_down). + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + intervalMs: 10_000, // bumped from start's 50 + createdAt: Date.now(), + }, + // Second GET: terminal so the loop exits. + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'authorized', + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + await handle.awaitCompletion({ + onThrottled: (ms) => observedIntervals.push(ms), + pollOverrideMs: 1_000, + }); + expect(observedIntervals).toContain(10_000); + }); + + it('rejects when opts.signal is aborted mid-poll', async () => { + // Replies stream forever as `pending` — caller's abort must be the + // exit path. + const { client } = makeFakeClient({ + getReplies: [ + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + const ctrl = new AbortController(); + const completion = handle.awaitCompletion({ + signal: ctrl.signal, + pollOverrideMs: 1_000, + }); + // Fire abort on the very next microtask so the loop's signal check + // sees it before issuing another GET. + queueMicrotask(() => ctrl.abort(new Error('test-cancel'))); + await expect(completion).rejects.toThrowError(/test-cancel/); + }); + + it('forwards opts.signal into client.getDeviceFlow on every GET (fold-in 7 #6)', async () => { + const { client, calls } = makeFakeClient({ + getReplies: [ + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'authorized', + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + const ctrl = new AbortController(); + await handle.awaitCompletion({ signal: ctrl.signal }); + expect(calls.get.length).toBeGreaterThanOrEqual(1); + expect(calls.get[0]?.signal).toBe(ctrl.signal); + }); + + it('returns the final GET snapshot at the timeoutMs ceiling', async () => { + const { client, calls } = makeFakeClient({ + getReplies: [ + // Stays pending forever; timeoutMs ceiling is what exits. + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + const final = await handle.awaitCompletion({ + timeoutMs: 60, // very short; ceiling fires after a few ticks + pollOverrideMs: 1_000, + }); + expect(final.status).toBe('pending'); + expect(calls.get.length).toBeGreaterThanOrEqual(1); + }); + + it('honors timeoutMs:0 — returns the daemon snapshot immediately (round-9 #6)', async () => { + const { client, calls } = makeFakeClient({ + getReplies: [ + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + const final = await handle.awaitCompletion({ timeoutMs: 0 }); + expect(final.status).toBe('pending'); + // Exactly one GET — the immediate ceiling-read path. + expect(calls.get.length).toBe(1); + }); + + it('falls back to default ceiling when timeoutMs is NaN (sanitizePositiveMs)', async () => { + // NaN was the bug fold-in 7 #5 fixed: previously `?? default` + // accepted NaN and produced `ceiling = NaN`, looping forever + // (`now >= NaN` is always false). The sanitized form drops NaN + // to undefined which falls back to `expiresAt + GRACE`. + // + // Test pins the contract by using a start result whose + // `expiresAt` is FAR in the past — `expiresAt + GRACE` is then + // also in the past, so the ceiling check on iteration 1 fires + // immediately and the test bails fast. If sanitization broke + // and NaN slipped through, the loop would never exit. + const { client } = makeFakeClient({ + startResult: { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + userCode: 'USER-1', + verificationUri: 'https://idp.example/verify', + expiresAt: Date.now() - 60_000, // ceiling = -30s ago → bail + intervalMs: 50, + attached: false, + }, + getReplies: [ + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + const final = await handle.awaitCompletion({ timeoutMs: NaN }); + expect(final.status).toBe('pending'); + }); + + it('synthesizes error/not_found_or_evicted on a 404 from getDeviceFlow (fold-in 3 #4)', async () => { + const { client } = makeFakeClient({ + getReplies: [new DaemonHttpError(404, null, 'not found')], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + const final = await handle.awaitCompletion({ pollOverrideMs: 1_000 }); + expect(final.status).toBe('error'); + expect(final.errorKind).toBe('not_found_or_evicted'); + expect(final.providerId).toBe('qwen-oauth'); + }); + + it('routes the timeoutMs:0 ceiling read through the same 404 helper (fold-in 7 #4)', async () => { + // Pre-fold-in-7 #4, the ceiling read called getDeviceFlow + // directly and a 404 there would reject `awaitCompletion` with + // `DaemonHttpError(404)` instead of returning the structured + // synthetic state. With timeoutMs:0 the FIRST read is the + // ceiling read — verify the 404 still synthesizes. + const { client } = makeFakeClient({ + getReplies: [new DaemonHttpError(404, null, 'evicted')], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + const final = await handle.awaitCompletion({ timeoutMs: 0 }); + expect(final.status).toBe('error'); + expect(final.errorKind).toBe('not_found_or_evicted'); + }); + + it('rethrows non-404 DaemonHttpErrors so the SDK consumer sees the daemon-side failure', async () => { + const { client } = makeFakeClient({ + getReplies: [new DaemonHttpError(500, null, 'daemon exploded')], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ providerId: 'qwen-oauth' }); + await expect( + handle.awaitCompletion({ pollOverrideMs: 1_000 }), + ).rejects.toBeInstanceOf(DaemonHttpError); + }); +}); + +describe('DaemonAuthFlow.cancel (fold-in 10 #2)', () => { + it('forwards to client.cancelDeviceFlow with the captured deviceFlowId + clientId', async () => { + const { client, calls } = makeFakeClient({ + getReplies: [ + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + const handle = await auth.start({ + providerId: 'qwen-oauth', + clientId: 'sdk-client-X', + }); + await handle.cancel(); + expect(calls.cancel).toEqual([ + { deviceFlowId: 'flow-A', clientId: 'sdk-client-X' }, + ]); + }); + + it('top-level cancel(deviceFlowId) wrapper also forwards to the client', async () => { + const { client, calls } = makeFakeClient({ + getReplies: [ + { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + await auth.cancel('flow-Z', { clientId: 'admin-1' }); + expect(calls.cancel).toEqual([ + { deviceFlowId: 'flow-Z', clientId: 'admin-1' }, + ]); + }); + + it('top-level status(deviceFlowId) wrapper forwards to client.getDeviceFlow', async () => { + const { client, calls } = makeFakeClient({ + getReplies: [ + { + deviceFlowId: 'flow-Q', + providerId: 'qwen-oauth', + status: 'authorized', + createdAt: Date.now(), + }, + ], + }); + const auth = new DaemonAuthFlow(client); + const result = await auth.status('flow-Q', { clientId: 'admin-1' }); + expect(result.status).toBe('authorized'); + expect(calls.get).toEqual([ + { deviceFlowId: 'flow-Q', clientId: 'admin-1' }, + ]); + }); +}); diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index c18a88253..4a4d89595 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1576,4 +1576,203 @@ describe('DaemonClient', () => { } }); }); + + // PR #4255 fold-in 10 #3 — device-flow HTTP method coverage. The + // round-8 reviewer flagged that `startDeviceFlow` / + // `getDeviceFlow` / `cancelDeviceFlow` / `getAuthStatus` plus the + // `client.auth` lazy getter had zero unit tests; this block + // exercises route paths, method codes, signal forwarding (fold-in + // 7 #6), and the `failOnError` → `DaemonHttpError` mapping. + describe('device-flow methods (fold-in 10 #3)', () => { + it('startDeviceFlow POSTs /workspace/auth/device-flow + forwards body / clientId header', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(201, { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + userCode: 'USER-1', + verificationUri: 'https://idp.example/verify', + expiresAt: 1_700_000_000_000, + intervalMs: 5_000, + attached: false, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const res = await client.startDeviceFlow({ + providerId: 'qwen-oauth', + clientId: 'sdk-X', + }); + expect(res.deviceFlowId).toBe('flow-A'); + expect(res.attached).toBe(false); + const call = calls[0]; + expect(call?.url).toBe('http://daemon/workspace/auth/device-flow'); + expect(call?.method).toBe('POST'); + expect(call?.headers['x-qwen-client-id']).toBe('sdk-X'); + expect(JSON.parse(call?.body ?? '{}')).toEqual({ + providerId: 'qwen-oauth', + }); + }); + + it('startDeviceFlow accepts 200 (take-over branch) and 201 (fresh) identically', async () => { + const body = { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + userCode: 'USER-1', + verificationUri: 'https://idp.example/verify', + expiresAt: 1_700_000_000_000, + intervalMs: 5_000, + attached: true, + }; + for (const status of [200, 201]) { + const { fetch } = recordingFetch(() => jsonResponse(status, body)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.startDeviceFlow({ providerId: 'qwen-oauth' }), + ).resolves.toMatchObject({ attached: true }); + } + }); + + it('startDeviceFlow throws DaemonHttpError on non-2xx (e.g. 502 upstream_error)', async () => { + const { fetch } = recordingFetch(() => + jsonResponse(502, { error: 'upstream', code: 'upstream_error' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.startDeviceFlow({ providerId: 'qwen-oauth' }), + ).rejects.toBeInstanceOf(DaemonHttpError); + }); + + it('getDeviceFlow GETs /workspace/auth/device-flow/:id with URL-encoded id', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + deviceFlowId: 'flow with space', + providerId: 'qwen-oauth', + status: 'authorized', + createdAt: 1_700_000_000_000, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const res = await client.getDeviceFlow('flow with space'); + expect(res.status).toBe('authorized'); + // RFC 3986 / encodeURIComponent — `' '` → `%20`. + expect(calls[0]?.url).toBe( + 'http://daemon/workspace/auth/device-flow/flow%20with%20space', + ); + expect(calls[0]?.method).toBe('GET'); + }); + + it('getDeviceFlow forwards opts.signal into fetch (fold-in 7 #6)', async () => { + const ctrl = new AbortController(); + let observedSignal: AbortSignal | undefined; + const fetchImpl = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => { + observedSignal = init?.signal ?? undefined; + return jsonResponse(200, { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + status: 'pending', + createdAt: 1_700_000_000_000, + }); + }, + ) as unknown as typeof globalThis.fetch; + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch: fetchImpl, + }); + await client.getDeviceFlow('flow-A', { signal: ctrl.signal }); + // The fetched signal is COMPOSED with the per-request timeout + // controller (composeAbortSignals), so we can't assert + // identity. Instead verify that aborting the caller's signal + // propagates to fetch's signal. + expect(observedSignal).toBeDefined(); + expect(observedSignal!.aborted).toBe(false); + ctrl.abort(new Error('caller-cancel')); + expect(observedSignal!.aborted).toBe(true); + }); + + it('getDeviceFlow throws DaemonHttpError(404) on missing/evicted id', async () => { + const { fetch } = recordingFetch(() => + jsonResponse(404, { + error: 'not found', + code: 'device_flow_not_found', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const err = await client + .getDeviceFlow('nonexistent') + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(DaemonHttpError); + expect((err as DaemonHttpError).status).toBe(404); + }); + + it('cancelDeviceFlow DELETEs /workspace/auth/device-flow/:id and resolves on 204', async () => { + const { fetch, calls } = recordingFetch( + () => + new Response(null, { + status: 204, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.cancelDeviceFlow('flow-A', { clientId: 'sdk-Y' }), + ).resolves.toBeUndefined(); + expect(calls[0]?.method).toBe('DELETE'); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('sdk-Y'); + }); + + it('cancelDeviceFlow swallows 404 idempotently (matches closeSession contract)', async () => { + // Per `cancelDeviceFlow`'s JSDoc + the daemon's DELETE route: + // both 204 (terminal-grace no-op) and 404 (unknown / evicted) + // resolve to undefined so retries from a SDK that's lost track + // are safe. Non-404/204 statuses are the only error envelope. + const { fetch } = recordingFetch(() => + jsonResponse(404, { + error: 'not found', + code: 'device_flow_not_found', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect(client.cancelDeviceFlow('nope')).resolves.toBeUndefined(); + }); + + it('cancelDeviceFlow throws DaemonHttpError on non-204/404 (e.g. 500)', async () => { + const { fetch } = recordingFetch(() => + jsonResponse(500, { error: 'daemon exploded' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect(client.cancelDeviceFlow('flow-A')).rejects.toBeInstanceOf( + DaemonHttpError, + ); + }); + + it('getAuthStatus GETs /workspace/auth/status and returns the snapshot', async () => { + const snapshot = { + v: 1 as const, + workspaceCwd: '/work/bound', + providers: [], + pendingDeviceFlows: [], + supportedDeviceFlowProviders: ['qwen-oauth' as const], + }; + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, snapshot), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const res = await client.getAuthStatus(); + expect(res).toEqual(snapshot); + expect(calls[0]?.url).toBe('http://daemon/workspace/auth/status'); + expect(calls[0]?.method).toBe('GET'); + }); + + it('client.auth is a lazy DaemonAuthFlow instance (constructed on first access, then cached)', async () => { + const { fetch } = recordingFetch(() => + jsonResponse(200, { status: 'ok' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const a = client.auth; + const b = client.auth; + // Same instance on subsequent reads — singleton allocation. + expect(a).toBe(b); + }); + }); }); diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts index c669f614a..0d3fc8d23 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -100,4 +100,17 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => { expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); }); + + it('exposes the PR 21 auth device-flow surface at the public entry', () => { + // PR #4255 fold-in 9 review thread #11: the auth surface had + // been re-exported from `src/daemon/index.ts` but never from + // the published `src/index.ts`, so SDK consumers got + // `undefined` for everything except `client.auth.start()` + // (which traveled through the already-exported `DaemonClient`). + expect(typeof Public.DaemonAuthFlow).toBe('function'); + expect(typeof Public.reduceDaemonAuthEvent).toBe('function'); + expect(typeof Public.reduceDaemonAuthEvents).toBe('function'); + expect(typeof Public.createDaemonAuthState).toBe('function'); + expect(typeof Public.DEVICE_FLOW_EXPIRY_GRACE_MS).toBe('number'); + }); }); diff --git a/packages/sdk-typescript/test/unit/daemonEvents.test.ts b/packages/sdk-typescript/test/unit/daemonEvents.test.ts index a3c328753..db5e0e1c4 100644 --- a/packages/sdk-typescript/test/unit/daemonEvents.test.ts +++ b/packages/sdk-typescript/test/unit/daemonEvents.test.ts @@ -7,8 +7,11 @@ import { describe, expect, it } from 'vitest'; import { asKnownDaemonEvent, + createDaemonAuthState, createDaemonSessionViewState, isDaemonEventType, + reduceDaemonAuthEvent, + reduceDaemonAuthEvents, reduceDaemonSessionEvent, reduceDaemonSessionEvents, } from '../../src/daemon/events.js'; @@ -872,3 +875,326 @@ describe('daemon event schema', () => { }); }); }); + +describe('PR 21 — auth device-flow events', () => { + it('narrows the 5 device-flow event types', () => { + const types = [ + 'auth_device_flow_started', + 'auth_device_flow_throttled', + 'auth_device_flow_authorized', + 'auth_device_flow_failed', + 'auth_device_flow_cancelled', + ] as const; + const datas: Record<(typeof types)[number], unknown> = { + auth_device_flow_started: { + deviceFlowId: 'flow-1', + providerId: 'qwen-oauth', + expiresAt: 1_700_000_000_000, + }, + auth_device_flow_throttled: { + deviceFlowId: 'flow-1', + intervalMs: 10_000, + }, + auth_device_flow_authorized: { + deviceFlowId: 'flow-1', + providerId: 'qwen-oauth', + expiresAt: 1_700_000_900_000, + accountAlias: 'user-A', + }, + auth_device_flow_failed: { + deviceFlowId: 'flow-1', + errorKind: 'access_denied', + }, + auth_device_flow_cancelled: { + deviceFlowId: 'flow-1', + }, + }; + for (const [i, type] of types.entries()) { + const event: DaemonEvent = { + id: i + 1, + v: 1, + type, + data: datas[type], + }; + expect(isDaemonEventType(event, type)).toBe(true); + expect(asKnownDaemonEvent(event)?.type).toBe(type); + } + }); + + it('rejects malformed device-flow data via type guards', () => { + expect( + asKnownDaemonEvent({ + id: 1, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'x', + providerId: 'qwen-oauth' /* missing expiresAt */, + }, + }), + ).toBeUndefined(); + // PR #4255 fold-in 2 (C2): unknown errorKind is no longer a + // narrowing failure — the open `(string & {})` arm of the + // DaemonAuthDeviceFlowErrorKind union accepts ANY non-empty + // string so a daemon adding a new kind isn't silently dropped. + // The data IS valid; consumers branching on the known literals + // still narrow exhaustively, with unknown kinds falling into the + // string fallback arm. + const futureKind = asKnownDaemonEvent({ + id: 2, + v: 1, + type: 'auth_device_flow_failed', + data: { deviceFlowId: 'x', errorKind: 'rate_limited' }, + }); + expect(futureKind).toBeDefined(); + expect(futureKind?.type).toBe('auth_device_flow_failed'); + // Empty string still rejected (truly malformed). + expect( + asKnownDaemonEvent({ + id: 3, + v: 1, + type: 'auth_device_flow_failed', + data: { deviceFlowId: 'x', errorKind: '' }, + }), + ).toBeUndefined(); + }); + + it('reduceDaemonAuthEvent: started → throttled → authorized projects per-provider state', () => { + const events: DaemonEvent[] = [ + { + id: 1, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + expiresAt: 1_700_000_900_000, + }, + }, + { + id: 2, + v: 1, + type: 'auth_device_flow_throttled', + data: { deviceFlowId: 'flow-A', intervalMs: 10_000 }, + }, + { + id: 3, + v: 1, + type: 'auth_device_flow_authorized', + data: { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + expiresAt: 1_700_000_999_000, + accountAlias: 'user-A', + }, + }, + ]; + const state = reduceDaemonAuthEvents(events); + const flow = state.flows['qwen-oauth']; + expect(flow).toBeDefined(); + expect(flow?.status).toBe('authorized'); + expect(flow?.intervalMs).toBe(10_000); + expect(flow?.authorizedExpiresAt).toBe(1_700_000_999_000); + expect(flow?.accountAlias).toBe('user-A'); + }); + + it('reduceDaemonAuthEvent: failed event always projects status:error + errorKind (aligned with daemon)', () => { + // Issue #4175 PR 21 fold-in 0 P1-10: SDK reducer now mirrors the + // daemon's status machine — every `failed` event resolves to + // `status: 'error'`, regardless of `errorKind`. The error nature + // (expired vs denied vs persist failure) lives in `errorKind`, + // not `status`. Earlier drafts collapsed `expired_token` to + // `status: 'expired'`, diverging from the daemon's GET response. + const expired = reduceDaemonAuthEvent( + reduceDaemonAuthEvent(createDaemonAuthState(), { + id: 1, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'flow-X', + providerId: 'qwen-oauth', + expiresAt: 0, + }, + }), + { + id: 2, + v: 1, + type: 'auth_device_flow_failed', + data: { deviceFlowId: 'flow-X', errorKind: 'expired_token' }, + }, + ); + expect(expired.flows['qwen-oauth']?.status).toBe('error'); + expect(expired.flows['qwen-oauth']?.errorKind).toBe('expired_token'); + + const denied = reduceDaemonAuthEvent( + reduceDaemonAuthEvent(createDaemonAuthState(), { + id: 3, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'flow-Y', + providerId: 'qwen-oauth', + expiresAt: 0, + }, + }), + { + id: 4, + v: 1, + type: 'auth_device_flow_failed', + data: { deviceFlowId: 'flow-Y', errorKind: 'access_denied' }, + }, + ); + expect(denied.flows['qwen-oauth']?.status).toBe('error'); + expect(denied.flows['qwen-oauth']?.errorKind).toBe('access_denied'); + + // P1-10 cousin: new `persist_failed` errorKind also lands as + // `status: 'error'`, with the kind preserved. + const persistFailed = reduceDaemonAuthEvent( + reduceDaemonAuthEvent(createDaemonAuthState(), { + id: 5, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'flow-Z', + providerId: 'qwen-oauth', + expiresAt: 0, + }, + }), + { + id: 6, + v: 1, + type: 'auth_device_flow_failed', + data: { deviceFlowId: 'flow-Z', errorKind: 'persist_failed' }, + }, + ); + expect(persistFailed.flows['qwen-oauth']?.status).toBe('error'); + expect(persistFailed.flows['qwen-oauth']?.errorKind).toBe('persist_failed'); + }); + + it('reduceDaemonAuthEvent ignores stale events that do not match the current flow', () => { + const seeded = reduceDaemonAuthEvent(createDaemonAuthState(), { + id: 1, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + expiresAt: 100, + }, + }); + const stale = reduceDaemonAuthEvent(seeded, { + id: 2, + v: 1, + type: 'auth_device_flow_authorized', + data: { + deviceFlowId: 'flow-OTHER', + providerId: 'qwen-oauth', + expiresAt: 200, + }, + }); + expect(stale.flows['qwen-oauth']?.status).toBe('pending'); + }); + + it('reduceDaemonAuthEvent rejects out-of-order frames (fold-in 8 #2 monotonicity)', () => { + // Live: started(id=5) → authorized(id=10). Replay then injects a + // stale `failed` (id=7) for the same flow — without monotonicity + // it would overwrite `authorized` back to `error`/`upstream_error`. + let state = reduceDaemonAuthEvent(createDaemonAuthState(), { + id: 5, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + expiresAt: 1_700_000_900_000, + }, + }); + state = reduceDaemonAuthEvent(state, { + id: 10, + v: 1, + type: 'auth_device_flow_authorized', + data: { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + expiresAt: 1_700_001_000_000, + }, + }); + expect(state.flows['qwen-oauth']?.status).toBe('authorized'); + expect(state.flows['qwen-oauth']?.lastSeenEventId).toBe(10); + + const replayedStale = reduceDaemonAuthEvent(state, { + id: 7, // stale: less than the current lastSeenEventId (10) + v: 1, + type: 'auth_device_flow_failed', + data: { + deviceFlowId: 'flow-A', + errorKind: 'upstream_error', + }, + }); + // Stale frame must NOT overwrite the authorized terminal. + expect(replayedStale.flows['qwen-oauth']?.status).toBe('authorized'); + expect(replayedStale.flows['qwen-oauth']?.lastSeenEventId).toBe(10); + expect(replayedStale.flows['qwen-oauth']?.errorKind).toBeUndefined(); + + // A fresh `started` (id=4 < 10) for a NEW flow under the same + // providerId is also rejected as stale — the SDK has already + // observed the newer flow's authorized state and the lower-id + // started must be a replay of an old flow that gave way. + const replayedStartedStale = reduceDaemonAuthEvent(state, { + id: 4, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'flow-OLD', + providerId: 'qwen-oauth', + expiresAt: 1_700_000_500_000, + }, + }); + expect(replayedStartedStale.flows['qwen-oauth']?.deviceFlowId).toBe( + 'flow-A', + ); + expect(replayedStartedStale.flows['qwen-oauth']?.status).toBe('authorized'); + }); + + it('reduceDaemonAuthEvent passes synthetic frames (no envelope id) through the gate', () => { + // Synthetic frames originate inside SDK reducer machinery and + // aren't subject to replay ordering — gate must let them + // through even when state's lastSeenEventId is set. + let state = reduceDaemonAuthEvent(createDaemonAuthState(), { + id: 5, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + expiresAt: 1_700_000_900_000, + }, + }); + state = reduceDaemonAuthEvent(state, { + // No `id`: synthetic / fallback path. + v: 1, + type: 'auth_device_flow_cancelled', + data: { deviceFlowId: 'flow-A' }, + }); + expect(state.flows['qwen-oauth']?.status).toBe('cancelled'); + }); + + it('reduceDaemonSessionEvent no-ops on auth events (workspace-scoped)', () => { + const initial = createDaemonSessionViewState(); + const next = reduceDaemonSessionEvent(initial, { + id: 1, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'flow-A', + providerId: 'qwen-oauth', + expiresAt: 1_700_000_900_000, + }, + }); + // Only `lastEventId` advanced; everything else is the seeded zero state. + expect(next.lastEventId).toBe(1); + expect(next.alive).toBe(true); + expect(next.terminalEvent).toBeUndefined(); + expect(next.unrecognizedKnownEventCount).toBe(0); + }); +});