Files
deepseek-harness/packages/credentials/authorization/README.md
T
Yichen Jiang 9eaaeaeb96 fix(credentials,authorization,llm-pi-ai): harden the auth seams per review
Review findings on #2509, all confirmed:

- Every writer of .credentials.yaml now waits out the record-mutation
  lock (DOCUMENT_LOCK_WAIT_MS): refs and records share one file and one
  lock, so a reference write or record delete contending with an OAuth
  refresh must not fail at the 2s file-work default.
- api-key records are admitted before they are rendered: an empty key,
  a non-POSIX env name, or an empty env value is refused at the write
  instead of persisting a document the next boot rejects wholesale.
- llm-pi-ai no longer lets the credential-key grammar reject legal
  route ids: reads answer "nothing stored" via isCredentialKeySegment
  (new dsh-credentials export), deletes have nothing to remove, and only
  a write refuses, as LlmError UNSTORABLE_PROVIDER_ID; flow registration
  skips a future catalog id outside the grammar instead of failing the
  mount.
- authorization/settled fans out with contained listener failures on
  the credentials seam's terms (INVARIANT still rethrows), so a broken
  watcher can never turn a finished attempt into a failure.
- notify() is fire-and-forget at the seam: a surface that cannot render
  a notice loses the notice, never the attempt.
- A declined prompt is an outcome: interactions reject with the new
  AuthorizationDeclinedError and the attempt settles cancelled instead
  of failed.
- NOT_COMMITTED now confirms a commit observed during the attempt
  (credentials/record-updated for the flow's key), so a re-auth cannot
  pass a stale record off as fresh; a flow that deletes its record is
  refused on the same code.

READMEs, the subsystem/event/config catalogs, and the Agent Note follow
the shipped behavior; memory.ts carries the dedup TODO.
2026-08-20 17:58:38 +08:00

6.4 KiB

dsh-authorization

English | 中文

Authorization Service Definition (ctx.authorization). Some credentials cannot be configured, only obtained: getting one means a conversation with a human — open this page, paste that code, pick an account. This seam owns that conversation and the lifecycle around it, and never the protocol.

A flow is a plugin's knowledge of how to get its own credential. It is registered under the CredentialKey it writes, so a flow says which record it produces and, through that key's scope, which plugin answers for the format inside it. A second authorization protocol arrives as another flow, not as another seam.

The flow owns the write. run() resolving means the record is already committed through ctx.credentials; the seam confirms a commit it observed during the attempt — presence alone would let a re-authorization pass a stale record off as fresh — and refuses a flow that resolved without one. Committing inside the flow is what lets a library that persists through its own store adapter stay the single writer instead of being copied back out and written twice.

The interaction travels with the request, not a registry. Whoever starts an authorization is the one who can talk to the human about it, so prompts reach exactly the surface that asked and a headless caller supplies an interaction that declines. There is no ambient provider to be absent, and no question about which of two open pages a prompt belongs to.

Surface

import type { Context } from '@deepseek-ai/cordis'
import { AuthorizationDeclinedError, type AuthorizationSession } from '@deepseek-ai/dsh-authorization'
import { credentialKey } from '@deepseek-ai/dsh-credentials'

declare const ctx: Context
declare const exchange: (signal: AbortSignal) => Promise<void>

const key = credentialKey('llm-pi-ai', 'openai-codex')

const dispose = ctx.authorization.registerFlow({
  key,
  label: 'ChatGPT (Codex)',
  methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
  async run(session: AuthorizationSession) {
    session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
    const code = await session.prompt({ kind: 'text', message: 'Paste the code' })
    // Commits the record through ctx.credentials before resolving.
    await exchange(session.signal)
    void code
  },
})

ctx.authorization.list()                    // [{ key, label, methods, inFlight }]
ctx.authorization.describe(key)             // the same entry, or undefined
await ctx.authorization.begin({             // { status: 'authorized' | 'cancelled' }
  key,
  interaction: { notify: () => {}, prompt: () => Promise.reject(new AuthorizationDeclinedError()) },
})
ctx.authorization.cancel(key)               // withdraw whatever is running for the key
dispose()

One attempt per key at a time. A second caller is refused with ALREADY_IN_FLIGHT rather than joined, because the two would be prompting different humans through one flow and the second would be answering questions the first was asked. inFlight is on the entry so a surface renders the button disabled instead of discovering this by error.

cancel(key) exists beside the request's own signal because a request/response transport answers a Cancel button on a second call, holding no handle on the first one's signal. A flow whose registration is disposed mid-attempt is withdrawn the same way: its runner belongs to a plugin that is going away.

An attempt whose caller has already withdrawn never claims the key and never starts the flow — relying on each flow to check its signal before the first await would let one that does not hang holding the key. Validation still runs first, so a caller naming a key or method that does not exist hears about it whether or not it also gave up.

A human's "no" is an outcome, not a breakage. An interaction that declines rejects its prompt with AuthorizationDeclinedError, and an attempt that fails after a declined prompt settles as cancelled, exactly as a withdrawn signal does; any other prompt rejection stays a flow failure that reaches the caller. A notice is fire-and-forget on the same principle, held at the seam: a surface that cannot render one loses the notice, never the attempt.

authorization/settled (key, settlement) fires after the key is released, for every terminal outcome. settlement adds failed to the two statuses begin() can return: a failure reaches its own caller as a thrown error, so the event stream is the only place a watcher that did not start the attempt can tell a refusal from a breakage. Listener failures are contained: every listener runs, a throw or rejection is logged without changing the finished attempt's outcome, and only an INVARIANT-coded failure rethrows after the rest ran.

The interaction vocabulary

A notice is one-way and never carries a secret: a message, optionally the page the human must open and the code they must enter there. A prompt is a question the flow cannot answer — text, secret, or select — and secret differs from text only in presentation. A prompt carries its own signal so a flow that races a typed code against a browser callback can withdraw the losing question while the attempt continues; the request's signal withdraws the whole attempt instead.

The vocabulary is deliberately smaller than any one provider's: it describes what a surface must render, so a surface that renders one flow renders all of them.

Model Experience

None, as authorization is a configuration-time conversation with a human and no flow, notice, or prompt reaches a model request.

KV Cache effect

No invalidation; no authorization state enters a request prefix.

Known Limitations and Deferred Work

  • No flow is resumable — an attempt lives in the process that started it, so a browser reload during a login abandons it and the human starts over. Durable attempts need a store this seam does not have.
  • Nothing revokes — signing out is ctx.credentials.deleteRecord(key), which forgets the local record without telling the issuer. A provider that needs a server-side revoke has no place to declare it yet.
  • A key with no flow is inert — the seam reports what is registered, so a record left by an uninstalled plugin can be deleted but not re-authorized. Recognizing that orphan is the caller's join, as it is for listRecords().