# dsh-codespace-workspace — implementation contract Single source of truth for both halves of the plugin. Every API named here was read out of the installed DSH 0.2.0-rc.2 build; do not substitute remembered APIs. Extracted reference copies live under `../../../tmp/` (`dsh-src`, `dsh-src3`). DSH's own authoring skill is authoritative and ships in the app: `tmp/dsh-src3/dsh-agent/-preset/skills/cordis-plugin-development/` — read `SKILL.md`, `references/practices.md`, `references/host-plugin.md`, `references/ui-plugin.md` before writing code. --- ## 1. Product A GitHub Codespace **is** a DSH workspace. The agent's file and command tools operate inside the remote Codespace. It is not a generic SSH host manager: it does not touch DSH's SSH host list and must not collide with other cloud workspace plugins. ## 2. Verified DSH facts (do not re-derive, do not contradict) ### Plugin shape * Host entry (`lib/index.js`) is one of, never mixed: * `export function apply(ctx, config) {}` + optional `export const inject = [...]` and `export const Config`. * a service class as the default export. * `export const name = 'dsh-codespace-workspace'`. * `Config` is a `@deepseek-ai/schemastery` schema. Fields the user may change while DSH runs carry `.volatile()`. Guard for old schemastery: `const vol = (s) => (typeof s?.volatile === 'function' ? s.volatile() : s)`. * Secrets use `.role('secret')` (redacted from client reads; write-only input). * `package.json` needs `exports["./client"]` pointing at the **exact** produced file, and `dsh.bundle.patch` pointing at the patch YAML. A missing client artifact aborts Host startup. * `cordis.patch.yml` inserts rows; `name` must be the **full package name** (`dsh-codespace-workspace`), `id` is the settings namespace. ### Settings — the standard mechanism There is **no `installSection` API** in DSH. Do not invent one. * Declare `Config`; mark live fields `.volatile()`. * Persist from the host with the settings service, keyed by the **profile entry id** (our row id, `dsh-codespace-workspace`): * `ctx.settings.update(ns, patch, expectedRevision?)` * `ctx.settings.mutate(ns, ops, expectedRevision?)` — ops are `{ op: 'set'|'unset', path: string[], value? }` * `ctx.settings.describe({ redactSecrets: true })` → descriptors with `ns`, `schema`, `revision`, `value`, `base`, `user`, `secrets`. * Observe changes with `ctx.on('settings/document-updated', …)` and/or `ctx.on('app-boot/config-reload', …)`. These are the **only** two: the settings package emits exactly `settings/document-updated` and `settings/redact`. There is no `settings/updated`. * Only volatile paths are writable; a write outside them throws. ### System prompt `ctx.systemPrompt.section({ name, order, text })` — text may be a string or `(context) => string`. `order` must be finite. Register inside `ctx.effect`. Use a fixed order (our rule belongs after the persona, before tool guidance → `order: 1500`). Do **not** register an assembly waterfall listener. ### Workspace registry Service name is **`workspaceRegistry`** (`WorkspaceRegistry extends Service`, `super(ctx, "workspaceRegistry")`; `static inject = ["storageDomain", "sessionPersistence"]`). Verified in `@deepseek-ai/dsh-workspace/lib/index.js` ≈L354–375 and `lib/types/index.js`. * `await ctx.workspaceRegistry.create(path, title?)` → `Workspace`. `path` must be fully qualified and **already exist as a directory** (`mkdir` it first); it is `realpath`-canonicalized and is the uniqueness key. Returns the **existing** entity if one already owns that canonical path (and does *not* update the title). New records are **prepended** to the durable order. * `ctx.workspaceRegistry.list()` → `Workspace[]` (synchronous, durable order); `.get(id)` → `Workspace | undefined`; `await .resolveByPath(path)` → `Workspace | undefined` (rejects if `realpath` fails, returns `undefined` for an existing-but-unowned directory — this is the correct way to ask "is this path already a workspace?" without creating one). * `Workspace`: getters `path`, `title`, `createdAt`, `updatedAt`, `sessionIds`; methods `setTitle(title)`, `attachSession(id)`, `detachSession(id)`, `insertSessionBefore(id, beforeId)`, `status()` (`'ok' | 'missing-dir'`). * **The title is a display string and the registry never re-derives it.** `create` does not retitle an existing entity, so a naming change has to be applied with `setTitle` on every already-registered workspace, or existing rows keep the old name forever. Both halves are in `CodespaceManager`: `createWorkspace` titles a new record and `listWorkspaces` → `retitleFromLive` backfills existing ones. * **The workspace title is the Codespace's REPOSITORY short name** (`owner/name` → `name`), decided by `titleForCodespace()` in `lib/host/codespaces.js`. A Codespace's own name is machine-generated (`psychic-goggles-7v9974jwpwr5fpqxq`) and says nothing about which project the row is. The full `owner/name` and the Codespace name are surfaced in the hover card instead — see the client's `hoverLocationOf()` and the `row.hoverRepo` locale key. * **Deletion is `await ctx.workspaceRegistry.delete(id)` → `Promise`** (verified, `lib/types/index.js` ≈L217–227). It removes only the registration; the directory and every session log are retained. **Unknown ids are an idempotent no-op returning `false`** — it does not throw, so the delete flow needs no existence pre-check. It is serialized through the registry's own mutation queue, so concurrent calls are safe. * The service is **not** synchronously available at plugin load: `init` awaits `storageDomain` + `sessionPersistence` and completes the history bootstrap before the service activates. Declare it in `inject` and touch it only inside an injected context, never at module top level. ### Tools and per-agent scoping — the load-bearing mechanism * Tools resolve services from **their own host-plane context**, not from the agent. So swapping `ctx.fs` globally would affect every session. Do not do it. * `ctx.tools.register(definition)` called on **`agent.ctx`** registers a tool in that agent's scope, which shadows a same-named global for that agent only. This is the supported per-agent seam (`dsh-tools`: "for a per-agent variant, register through that agent's `agent.ctx`"). * `ctx.tools.restrict(name, …)` requires a scoped context and removes a tool for that agent. Use it to hide `pwsh` for a Codespace agent (Linux) and to keep the local `read`/`write`/`edit`/`bash`/`glob`/`grep` from being used. * `agent/created` is a **serial** event; listeners get the agent. Register per-agent behaviour on `agent.ctx` inside one `agent.ctx.effect()` **and** keep that disposer keyed by agent in our own plugin effect, because unloading the plugin does not dispose `agent.ctx` registrations by itself. * **Do NOT read `agent.ctx.tools` directly at `agent/created` time.** It is `undefined` there, and it fails *quietly*, which is what makes it dangerous: cordis' `ReflectService.handler.get` ends with `if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false)` (verified in `@deepseek-ai/cordis` `lib/index.js:680`), and the agent scope carries no running fiber, so the lookup degrades to the no-inject-required read instead of throwing `cannot get property "tools" without inject`. A `undefined` read is then easy to turn into a silent no-op — which is exactly what shipped once: the agent kept its local `read`/`glob`/`pwsh`, never got the remote surface, and nothing was logged. The symptom was an agent scanning the empty placeholder directory on Windows while its system prompt said it was in the cloud. Wait for the service with **`agent.ctx.inject(['tools'], (scope) => …)`** — the same idiom DSH's own `dsh-file-reference-local` uses (`lib/index.js:342`). Read the service off the **`scope`** argument, never off `agentCtx`: only the scope created by `inject` has it in its fiber store. It returns a fiber whose `dispose()` tears the registration down. * `agent.ctx.effect(fn, label)`; `agent.session.header.cwd` is the session's working directory — this is how we decide whether an agent is a Codespace agent. * Tool definitions are read from `dsh-tool-fs` / `dsh-tool-bash`; our shadow tools must return the same `output` schema shape and render the same `[exit code: N]` marker contract (`parseExitStatus` lives in `@deepseek-ai/dsh-shell`). **Verify the exact `register()` definition shape by reading `dsh-tools`' README and `dsh-tool-fs/lib/index.js` before writing.** ### Host HTTP routes for the client Two supported carriers; use both like `@copylee/dsh-remote-control` does: ```js // Browser, over DSH's authenticated transport: await (globalThis.__DSH_TRANSPORT__?.fetch ?? fetch)(ROUTE, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input), }) ``` ```js // Host side — register the exact path on the web server, and the same path on // connection.fetch so the Desktop shell reaches it too: ctx.inject(['webServer'], (ready) => { ready.effect(() => ready.webServer.register({ kind: 'exact', path: ROUTE, handler }), 'label') ready.effect(() => ready.connection.fetch.register({ path: ROUTE, methods: ['POST'], requestBody: 'buffered', fetch: async (req) => Response.json(...), }), 'label') }) ``` **Register on `connection.fetch`, NOT on a `webServer` exact route.** This was learned the hard way; do not "simplify" it back. DSH mounts its own `/api` route as a **prefix** route that first calls `connection.admit(req)` — the Host/Origin fence *and* the browser-auth cookie check — and only then hands the request to `createSharedFetchHandler`, which dispatches to whatever exact Fetch routes plugins registered under `/api`. Registering on `connection.fetch` therefore inherits the harness's security policy instead of re-implementing it, and serves the browser and the Desktop shell alike. `webServer.match()` tries the **exact table before the prefix table**, so a plugin-registered `{ kind: 'exact', path: '/api/...' }` route is matched first and **shadows the `/api` route entirely** — silently dropping both the fence and the authentication. That was a real defect in this plugin: measured against a live host, ``` POST /api/dsh-codespace-workspace/rpc (no cookie, matching Origin) -> 200 POST /api/no-such-plugin-xyz (no cookie, matching Origin) -> 401 unauthorized ``` The second line is DSH's `/api` route refusing an unauthenticated caller; the first line shows our exact route bypassing it. The route is now registered on `connection.fetch` and the exact route survives only as a fallback for a composition with no `connection` service (where it must apply the fence itself). Two more details that matter: * `connection` is **not** in this module's `inject` list, so `ready.connection` is gated by cordis and throws `cannot get property "connection" without inject`. Read it through `ready.get('connection', false)` — the documented ungated accessor, "Read a service from the store without the inject requirement" — which returns `undefined` when nothing provides it. * `assertFetchRoute` requires the path to sit under `/api` and be non-empty: `endpointFromPath('/api', path)` must not be `undefined`, i.e. the path must be `/api/` with every segment matching `/^[A-Za-z0-9_$.-]+$/`. ### Outbound HTTPS: Node trusts only its bundled CA list Measured on the development machine and worth knowing before debugging any "cannot reach the API" report: this box has a **TLS-intercepting proxy** whose root lives in the Windows trust store and nowhere else. `fetch` (undici) validates against Node's *bundled* CA list, so **every** request to the GitHub API fails, while `gh` (Go, which reads the OS store) succeeds on the very same request — the confusing "authenticated via `gh auth token`, yet the API is unreachable" state. The real cause is **never in `error.name`**: a `fetch` transport failure is `TypeError: fetch failed`, and the actionable code is in `error.cause.code` (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`). Reporting `error.name` alone produces a useless message; report the cause code. `lib/host/github.js` handles it **in process**, without touching global TLS state: ```js import { getCACertificates } from 'node:tls' // Node 24+ // on a trust failure only, retry once through node:https with // ca = getCACertificates('system') (225 certs on this machine) ``` `tls.setDefaultCACertificates(...)` also works but is **rejected**: it would silently widen TLS trust for the entire host process, affecting every other plugin. `NODE_OPTIONS=--use-system-ca` is the environment-level equivalent and fixes all Node code at once, but needs an env change plus a DSH restart, so it is only documented as an alternative. The `test/live.mjs` suite exists to catch this class of failure — it first measures whether the direct path works and then asserts the control plane succeeds either way. ### Client module * File is a lazy-CJS bundle loaded through the global loader: ```js window.__ModuleLoader__.load({ id: 'dsh-codespace-workspace', factory: (require) => { const module = { exports: {} } // REQUIRED: the loader passes only `require` const React = require('react') const h = React.createElement // ... module.exports.apply = apply module.exports.inject = ['slots'] return module.exports }, }) ``` * **React is not a global** — `require('react')`. * Do **not** `require('@deepseek-ai/dsh-client-ui-primitives')`. Copy the markup and CSS instead; a throwing component blanks the slot entry. Style with `--dsw-alias-*` tokens only. * Register into a slot with `ctx.slots.inject(key, () => ctx.slots.register({ name, id, order, label, children, inject }, Component))`. Registering into an undeclared slot throws. ### Available slots (verified) The **authoritative catalog** is generated by the client runner itself: `@deepseek-ai/dsh-cordis-client-runner/lib/client.js` carries one service-catalog entry per slot, each with a `ctx.slots.inject('')` example. I extracted the complete list — **90 slots**. Anyone adding a slot must be able to point at a `ctx.slots.register({ name: '', … })` in a shipped package, or the registration throws `slot "" is not declared`. | Slot | Kind | Use | |---|---|---| | `settings.section` | list | our **Codespace** settings page (`id`, `order`, `label: () => …`) | | `shell.overlay` | list | the single multi-step **new cloud workspace** window and the delete dialog | | `sidebar.workspaces` | single | owned by `ui-workspace`; **do not take it** | Kind declarations verified: `settings.section` is `{kind:"list", scope:"root"}` declared by `ui-settings-general` (`lib/client.js` ≈L1136); `shell.overlay` is `{kind:"list", scope:"root"}` declared by `ui-layout` (≈L617). Registering a `list` entry **without** an `id` collapses it to a single always-present cell — always pass `id` and `order`. **The new-cloud-workspace launcher is deliberately NOT a slot entry.** The requirement is that it sits in the workspace section's own header icon row as a peer of the host's controls, immediately left of 添加工作区 — and there is no slot for that row (see below). `sidebar.footer.action` *is* a real declared slot (by `ui-sidebar`, ≈L515, `{kind:"list", scope:"root"}`) and was used in an earlier revision, but it puts the control in the sidebar footer, which is not where the requirement places it. It is therefore a DOM augmentation instead. ### The workspace section header has NO slot (verified negative finding) `dsh-client-ui-workspace/lib/client.js` (`WorkspaceBrowser`, ≈L150370): ```
flex, height:36px, gap:4px, justify-content:flex-end {wide && } {wide &&
…
}
gap:4px, max-width:60px, overflow:hidden {wide && } Tooltip > button._iconButton {directoryFlowAvailable && button._iconButton, aria-label="添加工作区"
``` * The icon row is `headerActions`; its **last element child is the host's "+"** (a real `