|
|
@@ -0,0 +1,66 @@
|
|
|
+# Agent Note: Credential records and authorization flows
|
|
|
+
|
|
|
+Status: implemented
|
|
|
+
|
|
|
+English | [中文](2026-08-13-credential-records-and-authorization-flows.zh.md)
|
|
|
+
|
|
|
+## Problem
|
|
|
+
|
|
|
+The harness credential plane could only express one kind of secret: a value behind an environment-variable name. `CredentialRef` is a POSIX identifier, resolution layers the process environment over a managed file and `.env` fallbacks, and every consumer reads it per operation. That covers an API key exactly and covers nothing else.
|
|
|
+
|
|
|
+Some credentials are not values a deployment can be told to store. They are obtained — by a conversation with a human who opens a page, approves an account, and pastes a code back — and what comes out is a token document with a refresh half that rotates behind the user's back. pi-ai models this directly (`Credential = ApiKeyCredential | OAuthCredential`, an app-owned `CredentialStore`, `Models.login()`), and the harness had nowhere to put any of it. `PiAiAdapter` built its collection with `createModels()` and no options, so the store was pi-ai's in-memory default: empty at every boot, discarded on every configuration change. `openai-codex`, whose only method is OAuth, therefore failed every request with `Provider is not configured` — [withheld from the directory](../bug-fix/2026-08-13-oauth-only-providers-withheld.md) as a release fix, which removed the broken offer without adding the capability.
|
|
|
+
|
|
|
+Two further gaps followed from the same missing plane. A provider's own ambient discovery ran against the raw process environment, so a key held by the credential seam was invisible to it and a local credential file was never even looked for. And a login had no surface to run from, because nothing in the harness could ask a human a question on a plugin's behalf.
|
|
|
+
|
|
|
+## Decision
|
|
|
+
|
|
|
+Three seams, each owning one question, and every pi-ai concept behind an adapter inside `llm-pi-ai`.
|
|
|
+
|
|
|
+**`dsh-credentials` grows a second key space.** A `CredentialRef` answers *what is behind this environment-variable name*; a `CredentialKey` answers *what credential does this plugin hold for this id*. The record union is `{ kind: 'api-key', key?, env? } | { kind: 'grant', payload }` — the api-key half structural because the seam can describe it, the grant half opaque because a library that owns a token format keeps owning it. The only constraint on a payload is that it survives a JSON round trip, enforced on the way in and on the way out.
|
|
|
+
|
|
|
+The key is `<scope>/<id>` where the scope is the **owning plugin's registered name**, not the provider's. A user knows `openai-codex`; which adapter family answers for the bytes inside that record is exactly what a bare provider name loses. Two plugins serving the same provider name would read each other's payload, and a record left by an uninstalled plugin could not be told from a live one. The `/` also keeps the two grammars disjoint, so the key spaces cannot collide. This assumes one adapter registers a given provider route, which the LLM registry already enforces.
|
|
|
+
|
|
|
+Records do not layer. There is no environment an authorization grant could be read from, so presence of the record is the whole fact, and the empty-value rule that governs references does not apply: an `api-key` record carrying neither a key nor env states that its owner confirmed ambient authentication, which is configured.
|
|
|
+
|
|
|
+**`dsh-authorization` owns the conversation, never the protocol.** A plugin that knows how to obtain its own credential registers a flow under the `CredentialKey` that flow writes. The seam runs one attempt per key, routes a neutral vocabulary of notices and prompts, and settles. A second authorization protocol arrives as another flow rather than as another seam, and a surface that renders one flow renders all of them.
|
|
|
+
|
|
|
+Two choices carry the weight:
|
|
|
+
|
|
|
+- **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. This is what lets `Models.login()` — which persists through the store adapter as part of logging in — stay the single writer, instead of the credential being copied back out and written a second time.
|
|
|
+- **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 page that asked, a headless caller supplies an interaction that declines, and there is no ambient provider to be absent or ambiguous between two open tabs.
|
|
|
+
|
|
|
+**`llm-pi-ai` holds all three translations.** `credentialStoreFrom` maps pi-ai's `CredentialStore` onto records; `authContextFrom` answers pi-ai's ambient questions from the credential seam, then the launch environment, with file existence checked against the host process's filesystem; `registerPiAiFlows` restates pi-ai's `AuthEvent`/`AuthPrompt` in the neutral vocabulary and runs `Models.login()`. Every collection is built with the first two, which is what keeps a signed-in provider signed in across the collection rebuild a configuration change causes. With a posture that works, the directory stops withholding OAuth-only routes and `openai-codex` is offered again.
|
|
|
+
|
|
|
+The credential plane stays optional, as it already was for reference resolution. Reads answer "nothing stored" without a credentials service, because such a composition genuinely holds no credential; writes refuse by name, because a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam through `ctx.inject`, so a headless or ACP composition mounts with no sign-in and nothing else changed.
|
|
|
+
|
|
|
+### Two mechanisms the seams needed underneath
|
|
|
+
|
|
|
+`withFileLock` takes a per-call wait limit. pi-ai runs an OAuth refresh *inside* `credentials.modify()`, so the record write path holds the lock across a network round trip; the 2s default was chosen for a render-and-rename and would fail every other writer of the document. The retry cadence stays fixed — that is a protocol constant — while how long to wait becomes a property of the operation holding the lock.
|
|
|
+
|
|
|
+Withdrawal settles an attempt whether or not its flow reacts to the signal. A flow is supposed to stop when its signal fires, but one that does not would hold its key for the life of the process, and a wedged key is indistinguishable from a busy one from outside. The orphaned run is left to finish on its own.
|
|
|
+
|
|
|
+## Alternatives considered
|
|
|
+
|
|
|
+- **Putting the pi-ai `CredentialStore` shape into the seam itself.** It is the shape that works and it is already designed. It also names `api_key`/`oauth` as the world's two credential kinds and keys by provider id, which is the ownership loss above; a second adapter family would have to pretend to be pi-ai to participate. The record union is deliberately one step more abstract in exactly two places — the key, and the opacity of a grant.
|
|
|
+- **A dedicated login-interaction seam beside `user-questions`.** Authorization prompts look like questions, and reusing `ctx.userQuestions` was tempting. But that seam is built for a model's tool call to pause on an agent's behalf: it validates the calling agent, refuses a delegated caller, and has one ambient UI provider. An authorization prompt has no agent, must reach the configuration page that started it, and can be withdrawn per prompt by a browser callback winning a race. The vocabularies overlap; the lifecycles do not.
|
|
|
+- **Reading `~/.codex/auth.json` into a store.** It makes Codex work without any of this, and pi-ai would own the refresh. It also binds the harness to another tool's private file format for one provider, and leaves every other login unbuilt.
|
|
|
+- **Joining a second `begin()` to the attempt already running.** Friendlier than refusing, until two humans are answering the same flow's questions. Refusal with `inFlight` on the entry lets a surface disable the button rather than discover the state by error.
|
|
|
+- **Keeping the OAuth-only withholding as a safety net.** It would now hide a provider that works. The predicate is deleted rather than left inert; `docs/subsystems/credentials.md` and the package READMEs carry what replaced it.
|
|
|
+
|
|
|
+## Consequences
|
|
|
+
|
|
|
+`.credentials.yaml` gains a version and two sections, and the pre-release flat layout is refused by name with the migration stated in the message — pre-release stance, no compatibility shim. Every fixture in the repo that wrote the flat document was rewritten; the llm suites' fixtures were missed by the record change itself and fixed here.
|
|
|
+
|
|
|
+`openai-codex` returns to the provider picker and to the Models page directory. Signing in is offered for every installed provider that ships a login, which today is all 38 — 31 collect a key through pi-ai's own prompt, six offer that beside a subscription login, and Codex offers only the subscription login.
|
|
|
+
|
|
|
+What this does not yet include is the surface: the wire contract that carries notices and prompts to the browser, and the Models-page control that starts a login. Until that lands, the flows are reachable only in-process, and a deployment still configures a key by typing it into the settings form.
|
|
|
+
|
|
|
+Two limits are recorded in the package READMEs rather than fixed. An attempt is not durable, so reloading the page mid-login abandons it. And signing out is `deleteRecord`, which forgets the record locally without telling the issuer; a provider needing a server-side revoke has nowhere to declare it.
|
|
|
+
|
|
|
+## Testing
|
|
|
+
|
|
|
+The seam's suite pins the lifecycle it owns: single-flight refusal and release, withdrawal before the flow starts and during it, a flow that ignores its signal, the commit confirmation, and the settlement event including the `failed` case a caller sees as a thrown error. The invariant companion pins that a settled key is a free key, because a wedged one is otherwise invisible.
|
|
|
+
|
|
|
+`llm-pi-ai` covers the three translations against a real `$DSH_HOME` document — an api-key credential field by field, an OAuth credential verbatim including its refresh half, a foreign plugin's record skipped by scope, and the write refusal without a credentials service — plus every `AuthEvent` and `AuthPrompt` member restated, with `Models.login()` mocked at the collection boundary since a real one opens a browser. Two real-composition tests boot the plugin with and without the authorization seam.
|
|
|
+
|
|
|
+The `models-settings` and `onboarding-usable-provider` web e2e goldens regain exactly the `openai-codex` option line they lost when it was withheld — the whole assembled-application difference this change makes today, because the Models page has no login control yet to record.
|