Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
Tianyi Cui a2d0f7f411 refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
2026-08-13 00:54:38 +08:00

14 KiB

Agent Note: the web configuration plane

Status: implemented

English | 中文

Scope: the wire face and web UI deferred from the request-level LLM configuration note — the settings.*/credentials.*/llm.* RPC domains with pushed invalidations, layered+redacted describe(), the local settings-document handoff, the llm configurable-provider directory and topology event, the standalone dsh-client-schema-form model layer, and the Models settings page with its hand-written provider editor. The deepseekdeepseek-official provider-route rename rides along as the enabling breaking change.

Problem

The request-level configuration seam made LLM adapter configuration restart-free, but the only writer was a text editor on settings.yaml: the web client had no wire access to settings, credentials, or provider topology, so "store a key, prompt again" still meant leaving the product. Three gaps blocked a config page rather than one: describe() returned only the merged effective value (a form cannot tell a user override from a composition default, and serializing it would have shipped role('secret') values to every browser), nothing enumerated the providers an adapter could run (a bare-mounted llm-pi-ai was invisible until configured), and the two adapters both wanted a deepseek route key, so the directory could not attribute routes to owning namespaces unambiguously. Hand-maintaining a form per provider was rejected outright — the schemas already exist as schemastery Config values, and a second source of field truth drifts.

Decision

Wire domains on the compiled RPC map, rejections as codes, owner events forwarded verbatim. settings.describe/openDocument/update/replace/mutate, credentials.describe/set/unset, llm.providers, and llm.models join RpcMethodMap, so the compiler-locked wiring sites keep schema, handler, and client in lockstep. Seam rejections fold into settings-rejected {ns} / credential-rejected {ref} business errors, while clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling (forwarded Remote events). Settings reads, native actions, and writes join pickDirectory/openPath in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin.

describe() grows layers and structural secret redaction. SettingsDescriptor carries base/user beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override equal to the base is still an override). describe({ redactSecrets: true }) — mandatory at every wire face — strips role('secret') subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as {path, set}, so a page can render write-only inputs without ever receiving a value.

The Host identifies and opens the local settings document. The settings seam exposes optional documentPath provider metadata and a prepareDocument() operation; settings-file returns its fully resolved custom or $DSH_HOME/settings.yaml filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base undefined. The loopback-only settings.describe response carries only the boolean hasDocument capability beside the redacted namespace views. ui-settings-general registers a settings.action entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless settings.openDocument; the Host resolves the provider path again before a text-document handoff (open -t on macOS so an arbitrary YAML file association cannot redirect the gesture, xdg-open on desktop Linux, Invoke-Item on Windows, and wslpath -w followed by that Windows handoff on WSL). Generic workspace paths retain the default intent, including its browser preference for browser-renderable documents. The browser neither derives $DSH_HOME nor receives a filesystem target; remote pages make no privileged settings read for this action.

The llm seam declares configurability and announces topology. registerConfigurableProviders() is an all-or-nothing, fiber-scoped directory of {provider, displayName, settingsNs, settingsPath} — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; listConfigurableProviders() merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload 'llm/adapters-updated' event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. llm-deepseek's route renamed to deepseek-official because the pi-ai catalog legitimately owns deepseek as an aggregator entry; pre-release stance, no alias.

A hand-written editor over a schema model layer. dsh-client-schema-form rehydrates the wire's toJSON() envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the retryPolicy unsupported-fallback in the main flow). The hand-written direction won over adding a hint/grouping system, and a further simplification removed the reference input entirely: the card's primary field is one API key input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (baseURL for both families, reasoningEffort for deepseek / reasoning for pi-ai, plus direct DeepSeek model rows with id, name, and contextWindow). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by settings.yaml. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the --dsw-alias-* design tokens; it had named --border/--surface/--text-*, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging. Every field keeps the indexed aria-label that names it. Both capacities are text fields reading a decimal K/M suffix (1M is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite 1000 to 1K mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry only declared token spellings: --dsw-alias-border-subtle, --dsw-alias-text-tertiary, and --dsw-alias-text-primary are undeclared, so naming them resolves to the light-mode literals in their fallback slots. A styles test now rejects any --dsw-* name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface.

The Models page is a three-domain join with service-shaped apply semantics. Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores write-only through credentials.set under the profile's apiKeyEnv, deriving <ROUTE>_API_KEY when none exists (the pi-ai profile records the derivation only when a key is entered), so settings.yaml never carries a key value; a blank pi-ai key materializes a reference-free profile and preserves provider-native authentication. Profile edits and removals land as minimal path-addressed settings.mutate operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized confirmation dialog whose row actions, title, description, and final action identify the same provider; confirmation removes an exact configured+writable derived credential before the profile, while custom, environment, and unidentified targets remain untouched. Both stages are idempotent and a partial failure stays in the dialog for retry. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. The partial-commit and credential-ownership rationale lives in the provider credential lifecycle note.

Alternatives considered

  • Serving JSON Schema over the wire — schemastery's toJSON() envelope round-trips role()/meta and rehydrates into the validator the client already ships for drafts; converting to JSON Schema loses exactly the role annotations the credential control and secret redaction key on.
  • A generic schema-driven form renderer — implemented first, then replaced: field truth without visual hierarchy produced an ugly, unusable card, and making it good meant building a hint vocabulary (primary/advanced grouping, per-field descriptions, array item cards) rivaling the hand-written editor in cost while still fitting no mockup exactly. Two schemas exist today (the deepseek Config and the shared pi-ai profile), so hand-writing is two thin namespace-keyed layouts; the drift risk is bounded by save-time schema validation and by unknown fields staying untouched in the document.
  • Masking secrets per-field with sentinel backfill on replace — the request-level seam decision (secrets are references) already deleted the stored-literal case for the product default; structural redaction plus a write-only credential path handles the residue without teaching every writer a sentinel protocol.
  • Storing the typed key as a literal apiKey setting — the v1 "one API key input" requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the redacted layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping settings.yaml secret-free and every replace safe.
  • A models bridge plugin owning provider configuration — same rejection as in the request-level seam note: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection.
  • Page-side polling instead of pushed frames — the mux already carries host/commands-changed; three more frames cost one shape each and make a second tab, an external settings.yaml edit, and a settings-born route converge at event speed.
  • Hard-coding $DSH_HOME/settings.yaml or returning documentPath through host.openPath in the browser — rejected because settings-file.path may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener.

Consequences

The whole loop is pinned keyless in the browser lane (apps/web/tests/models-settings.e2e.ts): the add card offers the dormant pi-ai catalog, adding minimax-cn with a typed key writes the reference-only profile into settings.yaml, stores the value into the harness home's .env under the derived MINIMAX_CN_API_KEY, registers the route live on the topology frame, and the customized fold merges reasoning beside the reference — zero model calls, ARIA goldens for the add-card, configured, and identified delete-confirmation states, plus a scaffold harnessHome so tests never touch a real ~/.dsh (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; Service Definition, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves both profile and key intact, then confirmation removes both the profile and its identified managed credential. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) with no compatibility alias. The renderer replacement needed no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models) and a page address for live routes that never declared configurability.