Development & Runtime

dsh-api-gateway

litestartup-com/dsh-api-gateway · ★ 11

litestartup-comCurated

REST + SSE gateway exposing running Harness sessions to third-party clients: API-key auth, token streaming, workspace grouping, and adopting GUI sessions to keep chatting.

License
MIT
Added
2026-08-16
Maintenance
active
Install kind
github
Manifest details
Manifest status
Declared
Manifest path
package.json#dsh
Manifest checked
2026-08-19
Lifecycle scripts
None found

Static manifest checks report declarations only; they are not compatibility or security review.

安装

dsh plugin --profile web add github:litestartup-com/dsh-api-gateway

GitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试

For install and environment setup, plus plugin development tutorials, see DSH community docs ↗

README

Source README · body: en · Read Chinese README

Table of contents
  1. Why it exists
  2. Supported DSH versions
  3. Install
  4. Docker deployment (standalone API stack)
  5. Quickstart
  6. Files
  7. .env reference
  8. Sessions & workspaces
  9. OpenAI-compatible LLM route
  10. Upgrades & lock refresh
  11. Web demos
  12. Configuration
  13. Endpoints
  14. Default whitelist
  15. Security model
  16. Deploy steps
  17. Acceptance
  18. Uninstall
  19. Documentation scope
  20. License

A DeepSeek Harness host plugin: an authenticated, fail-closed in-process HTTP facade that serves the host's session surface (the 0.1.2 typertGateway remotes + follow/control streams + question/approval waterfalls) to remote clients under the frozen legacy apiproxy contract (typical client: dsh-agent-manager).

The plugin does exactly three things: authentication, whitelisting, and contract translation. The 0.1.1-era loopback HTTP forwarding was removed with the 0.1.2 rebuild; envelopes, frame shapes, and receipts remain identical to 0.1.1 — every version difference is absorbed here, so clients need zero changes.

Why it exists

DSH's /api surface sits behind a two-layer gate (trust fence + browser auth) that remote clients cannot pass, and its in-process seams are not exported. This plugin runs inside the DSH process and calls the host domain services directly (typertGateway dispatcher, session streams, waterfalls), guarded by API-key auth and a deny-by-default whitelist.

Supported DSH versions

The facade runs inside a DSH host, so its compatibility surface is the host version. The declared range lives in package.json peerDependencies (^0.1.2-rc.1 || ^0.2.0-0 — dual-range since facade 0.2.4: the 0.2.0 corridor broke the old ceiling, and the DSH host enforces peers at install and startup); the pairings below are the ones verified end-to-end — wire contract, question/approval card chains, and GUI token capture — not merely semver-declared:

DSH Status Evidence
0.2.0-rc.2 ✅ verified Full-chain smoke + question/approval card chains + V3→V4 session-volume migration on the standalone Docker stack (facade 0.2.4, host 192.168.33.11); npm latest line
0.1.5-rc.2 ✅ verified Full-chain + card-chain smoke on facade 0.2.4 (dual-compat regression of the 0.2.0 corridor work) and on #b592b4f before it; see the install note below
0.1.2-rc.1 ✅ verified Full-chain smoke (dsh-agent-manager/scripts/smoke-proxy-b.ts, real model turn)
0.1.1-rc.2 ⚠️ legacy Wire contract frozen from this era; not the supported base

0.1.5 install note: npm's strict peer resolution rejects the default install (ERESOLVE) even though the range covers 0.1.5 semantically — compose 0.1.5 profiles with npm install --legacy-peer-deps. Consumers track this in their version matrix (dsh-agent-manager src/dsh-matrix.ts, needsLegacyPeerDeps). The same posture applies to the 0.2.0 line.

0.2.0 corridor note (what facades 0.2.4/0.2.5 absorb, so clients don't have to): the host's wireStream.open grew duplex uplink/peer parameters (arity-based detection keeps one code path booting both host generations); ctx.settings.register is gone host-side, so the durable key path on 0.2.0 hosts is the composition config (the Docker stack's entrypoint injects GW_KEY into the profile patch; POST {prefix}/key bootstrap keys are memory-only there); session logs moved to V4 (V3 volumes are migrated on read — one-way, back up before upgrading); the DeepSeek session-log upload defaults to on (the Docker stack pins it off in the profile patch); and streaming deltas moved out of the durable log into an opt-in live channel — since 0.2.5 the facade subscribes to it and re-emits the frozen assistant/chunk frames, so typewriter clients keep streaming with zero changes. The wire contract itself is unchanged — managers and probes pinned to older facade commits keep working against 0.2.0 hosts through this facade.

Consumers pin the facade by commit (github:litestartup-com/dsh-api-gateway#<sha>), so each DSH line is re-verified before a pin moves; verification records live in the private design library (dsh-facts). The 0.1.6-alpha.* / 0.1.7-* lines were superseded by 0.2.0 and are not separately verified (the 0.2.0 corridor spans them via the community jump cards).

Install

dsh plugin --profile web add github:litestartup-com/dsh-api-gateway

Add one row to the host composition (see examples/cordis.yml) and restart DSH.

Naming note: DSH ships a built-in package named @deepseek-ai/dsh-api-gateway (the typert dispatcher) which is unrelated to this plugin. This plugin is the external HTTP facade; its settings namespace / composition row / service field are all ohdsh-api-facade.

Docker deployment (standalone API stack)

The repo ships a self-contained compose stack that runs the facade as a standalone, public-facing API service — no manager and no extra wiring required:

client ──HTTP──▶ nginx (:${HTTP_PORT}) ──/api-gw/ only──▶ gateway container
                                                          = DSH host + this facade

The gateway port is never published; nginx is the only front door and it is fail-closed: only /api-gw/ is proxied, everything else (the DSH web GUI, /api, assets) answers 404. Behind that door the facade enforces its own API-key auth and the deny-by-default method whitelist — two independent layers.

Quickstart

bash docker/gen-env.sh          # generate .env (HOST_UID/GID, a random GW_KEY) — idempotent
# edit .env: fill in DEEPSEEK_API_KEY (required for real session turns)
docker compose up -d --build    # builds the node image (pinned DSH + committed lock) and boots
node docker/smoke.mjs           # wiring acceptance; add --model for one real model turn
node docker/probe-cards.mjs     # question/approval card chains (respond roundtrips; needs a model key)

The API base becomes http://<host>:${HTTP_PORT}/api-gw/v1, authenticated with GW_KEY from .env as X-API-Key:

curl -s http://127.0.0.1/api-gw/v1/health
curl -s -X POST http://127.0.0.1/api-gw/v1/proxy/session.list \
  -H "X-API-Key: $GW_KEY" -H 'content-type: application/json' \
  -d '{"type":"client-request","rpcId":"1","method":"session.list","payload":{}}'

Files

Path Role
docker-compose.yml nginx + gateway services, health-gated startup
docker/Dockerfile one container = one DSH API node (pinned DSH + the facade from this checkout)
docker/gen-profile.mjs build-time profile generator (lock-driven npm ci; --lock-only refreshes the lock)
docker/profile-lock/ committed dependency locks (reproducible trees, one per DSH version)
docker/entrypoint.sh idempotent seeding: profile → volume, GW_KEY → settings.yaml
docker/nginx/gateway.conf the fail-closed front door (API prefix only; WebSocket-upgrade aware)
docker/gen-env.sh .env generator (HOST_UID/GID red line, random GW_KEY)
docker/probe-lib.mjs shared probe machinery (config, envelopes, raw-WS client)
docker/smoke.mjs zero-dependency stack acceptance (includes a raw-WS mux check)
docker/probe-cards.mjs question/approval card-chain probe (respond roundtrips + sandbox escalation + file-on-disk proof)

.env reference

Variable Default Description
HTTP_PORT 80 External nginx port (plain HTTP; TLS is not wired in v1)
GW_KEY generated Static facade API key (X-API-Key). Empty leaves the one-time POST /key bootstrap open — not recommended on a public surface
DEEPSEEK_API_KEY — Official DeepSeek route credential; required for real turns unless an OpenAI-compatible route below is configured
OPENAI_BASE_URL — Optional: activates the OpenAI-compatible route (one URL, a group of models — see below)
OPENAI_API_KEY / OPENAI_PROVIDER / OPENAI_MODELS / OPENAI_MODEL / FACADE_MODEL see below Route credential (optional), key (default openai), model list, single-model shorthand, default model (provider/model)
OPENAI_API / OPENAI_CONTEXT_WINDOW / OPENAI_MAX_TOKENS see .env.example Optional protocol / capacity refinements
DSH_VERSION 0.2.0-rc.2 Pinned DSH line baked into the image (needs a matching docker/profile-lock/ entry; 0.1.5-rc.2 remains supported)
NGINX_IMAGE nginx:alpine Override where alpine cannot be pulled (e.g. docker.m.daocloud.io/library/nginx:alpine)
NODE_IMAGE / NPM_REGISTRY docker.io / npmjs Build-time mirrors for GFW builds
HOST_UID / HOST_GID 1000 Container runtime uid = host file-owner uid (written by gen-env.sh)
GW_ADMIN_KEY — Optional: enables the {prefix}/admin/* endpoints
GW_ALLOW_FULL_ACCESS — Optional true: the sandbox route may grant danger-full-access (risk notice under Configuration)
GW_EXPOSE_ERRORS — Optional false: strip internal error details (recommended for public deployments)
GW_CORS_ORIGIN — Optional: tighten CORS origin(s) for public deployments

Sessions & workspaces

./workspaces on the host is mounted at /workspace in the gateway container. When creating a session through the API, pass a cwd under that mount (e.g. /workspace/my-project) — the same tree is visible as ./workspaces/my-project on the host. DSH state (settings, credentials, session logs) lives in the gateway-data named volume and survives docker compose down; down -v wipes it.

OpenAI-compatible LLM route

The node image ships DSH's built-in llm-pi-ai adapter (the pi-ai multi-provider bridge). OPENAI_BASE_URL in .env activates the route — one URL, a group of models, no image rebuild, one docker compose up -d away:

# .env — any OpenAI-compatible endpoint (OpenAI, DeepSeek's /v1, vLLM, OneAPI, Ollama…)
OPENAI_BASE_URL=https://api.openai.com/v1   # up to, not including, /chat/completions
OPENAI_API_KEY=***                          # optional — omit for key-less endpoints
OPENAI_MODELS=gpt-4o-mini,gpt-4o            # comma-separated; the group this URL serves
FACADE_MODEL=openai/gpt-4o-mini             # default for new sessions (provider/model)
docker compose up -d                        # recreates the gateway; config regenerates at boot
node docker/smoke.mjs --model --provider openai   # asserts the route served the turn

The variable vocabulary is shared with the sibling pi-api-facade project (which keeps its historical PI_OPENAI_* names as aliases of the same OPENAI_* set), so one operator — or one DAC manager wiring layer — feeds a Pi node and a DSH node the same model configuration. Both facades sit on the same pi-ai layer underneath, so a provider/model string means the same thing on both.

Behavior facts (source-verified; private design library dsh-facts §20):

  • OPENAI_PROVIDER (default openai) is the route key — the provider value visible in session.models and request/header events. The default key overrides the endpoint of pi-ai's built-in openai catalog route, so its model ids come free; a custom key declares a brand-new route, which should list its models explicitly (OPENAI_MODELS).
  • One honest difference vs the pi side: an explicit OPENAI_MODELS list replaces the route's catalog here, where pi adds to it. Practical effect is the same — against a custom gateway you list what it actually serves.
  • Wire protocol: OPENAI_API defaults to openai-completions (OpenAI Chat Completions; pi-ai also serves openai-responses, anthropic-messages, google-vertex, …). Capacity claims are configuration's job — nothing interrogates a gateway: OPENAI_CONTEXT_WINDOW (default 262144) and OPENAI_MAX_TOKENS (default 32768) are the route-level fallbacks for models that declare none.
  • The key never lands in a config file: the profile stores apiKeyEnv: OPENAI_API_KEY (the variable name) and DSH's credential layer resolves the process environment per request (env is its top layer). A key-less endpoint simply leaves OPENAI_API_KEY empty.
  • FACADE_MODEL (provider/model, same contract as the pi side's PI_FACADE_MODEL) picks the default for new sessions; unset, the first declared model wins, and with no models declared the bundle default (deepseek-official/deepseek-flash) stays. It also works standalone — without any OPENAI_* — to re-pin the default to any mounted route.
  • Every mounted route stays available: all of them appear in session.models, and session.selectModel pins an individual session to any of them. With no OPENAI_* at all, DEEPSEEK_API_KEY remains the default path — existing deployments change nothing.
  • Requires a 0.2.x DSH line (the stack default). Legacy 0.1.2/0.1.5 images ignore OPENAI_* with a boot warning.

Upgrades & lock refresh

docker compose up -d --build rebuilds the image from the current checkout. The entrypoint re-seeds the profile into the volume whenever the image's seed version changes (DSH pin, facade version, or plugin content) — no manual step, and .env's GW_KEY remains the source of truth for the key.

Refresh the committed lock first whenever DSH_VERSION or the facade's dependency ranges change:

node docker/gen-profile.mjs --lock-only 0.2.0-rc.2
# → writes docker/profile-lock/0.2.0-rc.2.package-lock.json — commit it

Debug note: the DSH web GUI is not exposed. If you need it, uncomment the loopback mapping in docker-compose.yml (127.0.0.1:3081:3080) and reach it through an SSH tunnel — never on a public surface.

Web demos

examples/demos/ ships two web demos on top of this stack (a compose overlay adds them behind the same nginx door): KB Studio (/kb/ — knowledge-base management with an AI steward session pinned to workspace-write) and a Support widget (/cs/ — a read-only support agent grounded in the same knowledge base, which is this project's own documentation). Both are zero-dependency BFF + vanilla-JS apps and double as reference clients for the wire contract — see examples/demos/README.md.

Configuration

Field Default Description
prefix /api-gw/v1 Route prefix
enabled true Master switch (also toggleable via the admin endpoint)
apiKeys [] Static API keys
provisionedKey — Key minted by POST {prefix}/key, persisted in settings
allowKeyProvision true Allow the one-time unauthenticated key bootstrap
adminKey — Enables the admin endpoints when set
corsOrigin * CORS origin(s)
exposeErrors true Include internal error details in responses
allowFullAccess false Allow the sandbox route to grant danger-full-access (risk notice: operator opt-in; the gateway logs a warning at boot and on every hit, and applies no environment restriction)
proxyWhitelist default list Optional whitelist override

proxyTarget is kept only for 0.1.1-era config compatibility (deprecated; its value no longer takes part in any request path).

Endpoints

Method Path Auth
GET {prefix}/health none
POST {prefix}/key first call only (one-time bootstrap)
POST {prefix}/admin/enable X-Admin-Key
POST {prefix}/admin/rotate-key X-Admin-Key
POST {prefix}/proxy/<method> X-API-Key / Bearer
POST {prefix}/respond and {prefix}/proxy/respond X-API-Key / Bearer
POST {prefix}/sessions/{id}/sandbox-mode X-API-Key / Bearer
GET {prefix}/events.mux (WebSocket upgrade) X-API-Key

POST {prefix}/proxy/<method> takes a client-request envelope and returns a server-response envelope (HTTP is always 200; success lives in result.ok). Methods served in-process: session.list / session.create / session.prompt / session.cancel / session.history (follow-stream snapshot translation) / session.rename / session.fork / session.updateQueue / session.attachment / session.models (modelCatalog translation) / session.selectModel / host.describe (synthesized protocol constant 0.0.1, DSH-FACTS §6). Anything whitelisted but not migrated answers 501 method_not_migrated — the facade never silently forwards down a dead path.

sessions/{id}/sandbox-mode pins { "mode": "read-only" | "workspace-write" } (danger-full-access requires allowFullAccess: true) on a live session by writing a durable sandbox/mode log event. Cold/unknown sessions → 409 session_not_live. This is the only wire channel that can pin a session's sandbox mode (used once, right after creation).

respond uses the legacy { accepted, reason? } receipts; question claims / declines (ASK_CANCELLED semantics) / approval outcomes behave exactly as 0.1.1.

The mux upgrade path is also registered at {prefix}/proxy/events.mux, so clients with the uniform "base + method" convention (the manager's rpc base is /api-gw/v1/proxy) need no special case. The mux pipe is downlink only (clients sending frames are closed with 1008). Live traffic = per-session follow streams (session events) + one host-wide control stream (live projections as per-key session/projection frames). Reconnection is the client's job.

Default whitelist

session.list, session.create, session.history,
session.prompt, session.cancel, session.rename,
session.fork, session.updateQueue, session.attachment,
session.models, session.selectModel,
respond,  host.describe

Anything else → 403 { error: 'method_not_allowed' } without touching host services. The privileged plane (credentials.*, settings.*, host.openPath, host.pickDirectory, llm.discoverModels, …) is unreachable through the facade. Note the real method name is host.describe (host.version does not exist).

Security model

  • Non-degradable auth: constant-time key comparison, CSPRNG keys, one-time key bootstrap (closes permanently once any key exists).
  • Fail-closed whitelist; unmigrated methods answer 501 honestly — there is no path that silently forwards to a dead channel.
  • respond only settles pending entries this plugin itself forwarded (rpcId match); anything unknown is not-pending.
  • Keys are never logged; apiKeys/adminKey are redacted on the settings wire surface.

Deploy steps

  1. Build and commit: pnpm build && pnpm test (all green; lib/ must be committed).
  2. Update the host install: dsh plugin update (or pnpm install under profiles/web).
  3. Restart DSH.
  4. Run acceptance (below).

Acceptance

  1. GET {prefix}/health → 200, upstream: ok.
  2. POST {prefix}/proxy/credentials.set (with a valid key) → 403 method_not_allowed.
  3. POST {prefix}/proxy/session.list with a wrong key → 401.
  4. With a valid key: POST {prefix}/proxy/host.describe → { version: '0.0.1' }; session.list returns the session list.
  5. WebSocket to ws://host{prefix}/proxy/events.mux (with X-API-Key in the handshake): after session.prompt you should see session/event frames up to turn/end, with projection updates streamed as session/projection frames.

Automated acceptance: dsh-agent-manager/scripts/smoke-proxy-b.ts (the manager's end-to-end smoke over the proxy path, including a real model turn).

Uninstall

Remove the plugin row from the composition (optionally dsh plugin remove ohdsh-api-facade) and restart.

Documentation scope

This repository ships only what consumers need: this README, README.zh.md, openapi.yaml, examples, and tests. Internal design and the refactor plan live in a private design library — the code, the wire contract, and the examples are the complete, runnable, self-hostable deliverable.

License

MIT