pi-ai's auth model reaches this adapter through three translations, all of which live here: a CredentialStore over the harness credential records, an AuthContext over the credential plane and the host filesystem, and one authorization flow per installed provider that ships a login. The seams they consume name nothing from pi-ai, so a second adapter family can arrive with a different auth model and share them. Every collection is now built with the store and the context rather than with nothing, which is what makes a signed-in provider stay signed in across the collection rebuild a configuration change causes. With a posture that works, the configurable-provider directory no longer withholds OAuth-only routes and `openai-codex` is offered again; the predicate that withheld it is gone. The credential plane stays optional. Reads answer "nothing stored" without a credentials service because such a composition genuinely holds no credential, while writes refuse by name — a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam, so a headless or ACP composition mounts with no sign-in and everything else unchanged. Two fixes found while wiring this up: pre-release credential fixtures in the llm suites still used the flat document the record work replaced, and a flow that ignores its cancellation signal would have held its key for the life of the process — withdrawal now settles the attempt either way. |
||
|---|---|---|
| .. | ||
| src | ||
| tests | ||
| package.json | ||
| README.i18n.yaml | ||
| README.md | ||
| README.zh.md | ||
| tsconfig.json | ||
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 it 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 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 Error('headless')) },
})
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.
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.
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().