Files
deepseek-harness/packages/host/apiproxy
Yichen Jiang 9f996be8e3 fix(web-config): close the wire boundary, the redacted-replace data loss, and three P2s
Five findings from the #939 review, each reproduced before being fixed.

**Configuration reads are as privileged as writes.** `settings.describe`
returns every exposed namespace's configuration and `credentials.describe`
reports whether an arbitrary environment-variable name is configured and from
where — reconnaissance no anonymous caller should have. Both join
PRIVILEGED_METHODS, so the whole configuration plane is loopback-only until
real authentication exists; `trustedHosts` was never authentication. The model
catalog stays reachable: it carries no endpoints or key state, and a LAN
client's model picker legitimately needs it. Asserted over a real HTTP server,
because the Host header a browser actually sends is what decides this.

**The proxy serves only namespaces a registered model provider addresses.**
The settings seam is general — any plugin may register one — but the Web
configuration plane is the model-provider surface. Without the gate, every
future `settings.register()` would silently become remotely readable and
writable configuration. An unregistered namespace and an unexposed one answer
identically, so no caller can enumerate the registry one probe at a time.

**Path-addressed writes replace the redacted-document rebuild.** The editor
reads the REDACTED descriptor, so rebuilding a section from it and replacing
wholesale deleted every literal secret the wire never returned — reproduced as
`{baseURL, reasoning}` in, stored `apiKey` gone out. `settings.mutate` applies
set/unset ops to the section as it stands at the front of the seam's write
queue, and the client names only fields it can see, so an unseen secret is
untouched by construction rather than by care.

P2s in the same pass: `llm/adapters-updated` now contains async listener
rejections (an uncontained one escaped as unhandledRejection, contradicting
the documented "observer failures are contained"); llm-deepseek's retry-policy
swap uses the atomic `registration.replace` instead of dispose-then-register,
which published `[]` then `["deepseek-official"]` so an observer saw the
provider disappear and come back; and a transport rejection no longer strands
the page in `loading` or a card in `busy`, with removal failures surfaced on
the page banner instead of swallowed.
2026-07-30 18:30:15 +08:00
..

@deepseek-ai/dsh-host-apiproxy

English | 中文

The API gateway every client shape shares: the TS contract (src/api/, zero Node dependencies, importable from the browser), the fetch carrier pair (src/fetch/: toFetchHandler on the host side, AbstractApiClient plus platform subclasses on the client side), and the host-side implementation (src/api-proxy.ts: createApiProxy plus the default-exported ApiProxyService gateway plugin — config {provider, model, workspaceRoot?}, provides ctx.apiProxy). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap ctx.apiProxy themselves. The shipped core composition lives in apps/cli/cordis.yml.

Contract layer (/api)

Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: ClientRequest (POST /api/<method> body), ServerResponse (that POST's response body), ServerRequest (SSE frame), ClientResponse (POST /api/respond body). Responses always echo the matching request's rpcId and never mint a new one. Method parameter/return structures live only in the domain interface signatures (SessionsApi, HostApi, EventsApi); RpcMethodMap registers the methods and every other position derives via RequestPayload<K>/ResponseValue<K>. Zod schemas anchor satisfies z.ZodType<Wire<T>> and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride RpcResult's error branch (RpcErrorDetailsMap closes the code set); HTTP status expresses only the carrier. Every /api POST must declare the application/json media type — anything else is refused with 415 before dispatch, so cross-site "simple" requests (which browsers send without a CORS preflight) can never execute a side-effectful method blind.

The layering/protocol decisions are recorded in the GUI layering and RPC protocol RFC; the browser-side consumption architecture in the web client architecture RFC.

session.history's tail page (beforeSeq absent) additionally carries an optional projections block — the watermark snapshot of every unit registered on ctx.sessionProjections (@deepseek-ai/dsh-session-projection), with asOfSeq = the last event seq the values reflect (-1 on an empty log). The gateway also subscribes to the registry's change feed and mints a session/projection mux frame per changed unit ({sessionId, key, value, seq} — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep values/value wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.

Session titles ride the generic projection pair like every other domain — the history-tail projections block plus session/projection frames under the title key (the bespoke session/title frame is retired). Titles do not join session.list; cold sessions remain metadata-only there until opening or resuming attaches their logs. session.rename accepts an explicit user title (resuming a cold session first), delegating to ctx.sessionTitle.rename — the accepted session/title event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its title projection cell ahead of the push frame; a title that normalizes to empty returns title-invalid.

Session model routing is a session-domain contract. session.models returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. session.selectModel validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns model-unavailable.

Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued InboxItem occurrences from agent/inbox/* and broadcasts authoritative session/queue snapshots on every queued change and reconnect; pending steering stays outside this Web projection. session.updateQueue addresses one InboxItemId: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns queue-item-not-found. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.

Workspace and Session lists are separate reconnect baselines. workspace.create creates a unique name or adopts an existing directory, workspace.delete removes only the Workspace registration, session.create accepts an optional preallocated Session id, and host/workspace-changed, host/workspace-removed, plus host/session-added carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in session.list and become Ungrouped. SessionSummary.blank and the host/session-added frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first host/session-status(running:true), and treat session.list as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of list().

Directory picking delegates to the composed ctx.directoryPicker backend (the directory-picker seam); a method called outside the composed capability's kind fails with directory-picker-unavailable (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under native, host.pickDirectory opens one native chooser and returns its selected path (null on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under browse, host.listDirectory returns one name-sorted directory level with breadcrumb ancestry, a home anchor, and host-owned hidden flags (absent path = home directory), and host.createDirectory creates one validated child segment; the backend's typed failures map 1:1 onto the directory-unreadable/directory-exists/directory-create-failed codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other /api request.

host.openPath opens a filesystem path with the operating system's default application (open on macOS, Invoke-Item on Windows, xdg-open on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as host.pickDirectory.

The command.* and skill.* domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by sessionId (a served session always has an Agent; command.* resumes cold sessions through the same path as session.*, while skill.list resolves the project root from the session header without touching the Agent registry). skill.list serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. command.execute runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle commandId when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged command/run/command/done lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. host/commands-changed is the catalog invalidation frame: clients refetch command.list instead of diffing.

The settings.*, credentials.*, and llm.* domains are the configuration-page wire. settings.describe serves every registered namespace with its serialized schemastery schema plus redacted layered values (resolved/base/user — a field's presence in user marks it user-overridden) and the secrets slot list; settings.update/settings.replace write the user layer and answer with the namespace's new redacted view, folding every seam refusal into settings-rejected. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an update patch or credentials.set. credentials.describe returns value-free views (configured/source/writable), and credentials.set/credentials.unset map a shadowed-reference refusal onto credential-rejected. llm.providers merges the configurable-provider directory with live routes (dormant entries carry active: false; undeclared live routes append with no settings address) and llm.models is the session-independent catalog. Three invalidation frames keep every surface converged without polling: host/settings-changed {ns} (settings/updated passthrough — RPC writes and external settings.yaml edits alike), host/credentials-changed {ref} (reference names only, never values), and host/models-changed (llm/adapters-updated passthrough). The browser carrier restricts the four write methods (settings.update/settings.replace/credentials.set/credentials.unset) to loopback, same-origin requests — the host.pickDirectory privileged set. A composition without a settings or credential provider answers those domains with an actionable internal error naming the missing plugin.

Carrier layer (/client + root)

AbstractApiClient holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (subscribeEnvelopes) — while platform subclasses supply only the doFetch transport aspect. InProcessApiClient over toFetchHandler(api) is the isomorphic point: the full wire serialization/validation path with no network, used by dsh -p headless.

Model Experience

None, as the package defines the client↔host wire contract and carriers; nothing here reaches a model request.

KV Cache effect

None; this package neither assembles nor sends a provider request.

Known Limitations and Deferred Work

  • respond routing is shipped, but pending-interaction state is host-side work — the wire shape (POST /api/respond, RpcReceipt) is final; the pending table that makes late/duplicate answers meaningful lives in src/api-proxy.ts and is still minimal (questions only, no approvals).
  • Reserved seams stay out of RpcMethodMapsession.fork, prompt.mode: 'inject', task.list, and a describe hostInstanceId are documented reservations (the former host.listModels reservation shipped as llm.models); an unknown method fails loud at envelope parse rather than getting a not-implemented code.
  • No protocol version field — client and host ship together; host.describe gains a version negotiation field only when an independently released client exists.
  • Linux native picker requires desktop tooling — under the native capability, host.pickDirectory reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the native backend README).