dsh-codespace-workspace 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,555 @@
1
+ /**
2
+ * The host RPC route for dsh-codespace-workspace.
3
+ *
4
+ * One path, `POST /api/dsh-codespace-workspace/rpc`, registered on **both**
5
+ * carriers the harness offers: `webServer.register` for the browser and
6
+ * `connection.fetch.register` for the Desktop shell. The client posts the same
7
+ * body to the same path either way.
8
+ *
9
+ * Every action returns HTTP 200 with `{ ok: true, ...data }` or
10
+ * `{ ok: false, error: { code, message } }`. Nothing throws across the wire and
11
+ * nothing takes longer than a bounded request: a failing GitHub call is a
12
+ * value, not an exception, and the UI never waits on a hung socket.
13
+ *
14
+ * @module dsh-codespace-workspace/host/routes
15
+ */
16
+
17
+ import { ACTIONS, RPC_ROUTE, SETTINGS_DEFAULTS, redact, wireError } from '../shared.js'
18
+
19
+ /** Request bodies are tiny; anything larger is not ours. */
20
+ const MAX_BODY_BYTES = 64 * 1024
21
+
22
+ /** Settings fields the client may write, and how to coerce each one. */
23
+ const SETTINGS_FIELDS = {
24
+ githubUsername: (value) => (typeof value === 'string' ? value.trim() : ''),
25
+ githubToken: (value) => (typeof value === 'string' ? value.trim() : ''),
26
+ defaultBranch: (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : SETTINGS_DEFAULTS.defaultBranch),
27
+ autoPauseMinutes: (value) => {
28
+ const n = Number(value)
29
+ if (!Number.isFinite(n)) return SETTINGS_DEFAULTS.autoPauseMinutes
30
+ return Math.max(1, Math.min(1440, Math.round(n)))
31
+ },
32
+ sshKeyPath: (value) => (typeof value === 'string' ? value.trim() : ''),
33
+ alwaysShowCloudButton: (value) => value === true,
34
+ autoInitEmptyRepo: (value) => value !== false,
35
+ readmeContent: (value) => (typeof value === 'string' ? value : ''),
36
+ }
37
+
38
+ /** Fields that must never reach the browser. */
39
+ const SECRET_FIELDS = ['githubToken', 'sshKeyPath']
40
+
41
+ /** Whether an address is the loopback interface. */
42
+ function isLoopbackAddress(address) {
43
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
44
+ }
45
+
46
+ /**
47
+ * Whether a normalized URL hostname names the local loopback authority.
48
+ *
49
+ * Matches `isLoopbackHostname` in `@deepseek-ai/dsh-client-connection`: the
50
+ * literal `localhost`, the bracketed IPv6 loopback, or ANY IPv4 address in
51
+ * 127/8 — not just `127.0.0.1`. A narrower check here would 403 a deployment
52
+ * reached over another 127/8 address.
53
+ *
54
+ * @param {string} hostname - a WHATWG URL hostname (IPv6 keeps its brackets).
55
+ * @returns {boolean} true for a loopback hostname.
56
+ */
57
+ function isLoopbackHostname(hostname) {
58
+ if (hostname === 'localhost' || hostname === '[::1]') return true
59
+ const parts = hostname.split('.')
60
+ return parts.length === 4 && parts[0] === '127'
61
+ && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
62
+ }
63
+
64
+ /**
65
+ * Whether a request may reach this RPC route.
66
+ *
67
+ * This is a faithful port of `isTrustedApiRequest` from
68
+ * `@deepseek-ai/dsh-client-connection/lib/index.js`, which is the harness's own
69
+ * browser-trust fence for `/api` traffic. Do NOT make this stricter than that
70
+ * fence: the desktop shell carrier reaches the host through the connection's
71
+ * internal fetch, which attaches neither `Origin` nor Fetch-Metadata, and an
72
+ * earlier revision of this function required one of the two. That rejected
73
+ * every call from the desktop app with "Requests must be same-origin" while the
74
+ * Web GUI worked, because the browser does send `Origin`.
75
+ *
76
+ * The load-bearing check is the HOST, not the Origin. The reasoning, from the
77
+ * harness's own source: over plain HTTP a browser attaches neither `Origin` nor
78
+ * Fetch-Metadata to every request shape, so an unmarked request may still be a
79
+ * rebound browser read — and `Host` is the one header that DNS rebinding cannot
80
+ * forge. `Origin` (when present) and `Sec-Fetch-Site` are supplementary.
81
+ *
82
+ * The loopback peer-address check is kept as well; it is a defense the harness
83
+ * fence leaves to the webserver's bind policy.
84
+ *
85
+ * @param {import('node:http').IncomingMessage} req - the request.
86
+ * @returns {boolean} true when the request is trusted.
87
+ */
88
+ function isTrustedApiRequest(req) {
89
+ const host = req.headers.host
90
+ if (typeof host !== 'string') return false
91
+ let hostUrl
92
+ try {
93
+ hostUrl = new URL(`http://${host}`)
94
+ } catch {
95
+ return false
96
+ }
97
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
98
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
99
+ const origin = req.headers.origin
100
+ if (origin === undefined) return true
101
+ try {
102
+ // Compared through WHATWG normalization on both sides, so case and a
103
+ // redundant default port never decide trust.
104
+ return new URL(origin).host === hostUrl.host
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
110
+ /** Write a JSON response. */
111
+ function sendJson(res, statusCode, value) {
112
+ const body = JSON.stringify(value)
113
+ res.statusCode = statusCode
114
+ res.setHeader('content-type', 'application/json; charset=utf-8')
115
+ res.setHeader('cache-control', 'no-store')
116
+ res.setHeader('content-length', String(Buffer.byteLength(body)))
117
+ res.end(body)
118
+ }
119
+
120
+ /** Read and parse a bounded JSON request body. */
121
+ async function readJsonBody(req) {
122
+ req.setEncoding('utf8')
123
+ let text = ''
124
+ for await (const chunk of req) {
125
+ text += chunk
126
+ if (Buffer.byteLength(text) > MAX_BODY_BYTES) throw new Error('request body exceeds 64 KiB')
127
+ }
128
+ if (text.length === 0) return {}
129
+ const value = JSON.parse(text)
130
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
131
+ throw new Error('request body must be an object')
132
+ }
133
+ return value
134
+ }
135
+
136
+ /**
137
+ * Reduce a raw settings object to the client-safe view.
138
+ *
139
+ * The token and the SSH key never cross the wire: each is replaced by a
140
+ * boolean `set` marker, which is what a write-only input needs to render.
141
+ *
142
+ * @param {object} raw - the live settings.
143
+ * @returns {{settings: object, secrets: object}} the safe settings and the markers.
144
+ */
145
+ function publicSettings(raw) {
146
+ const source = raw && typeof raw === 'object' ? raw : {}
147
+ const settings = {}
148
+ const secrets = {}
149
+ for (const [key, fallback] of Object.entries(SETTINGS_DEFAULTS)) {
150
+ settings[key] = source[key] === undefined ? fallback : source[key]
151
+ }
152
+ for (const key of SECRET_FIELDS) {
153
+ const value = source[key]
154
+ secrets[key] = { set: typeof value === 'string' && value.length > 0 }
155
+ delete settings[key]
156
+ }
157
+ return { settings, secrets }
158
+ }
159
+
160
+ /**
161
+ * Validate and coerce a settings patch.
162
+ *
163
+ * Unknown keys are dropped rather than forwarded: the settings service rejects
164
+ * a write outside the volatile paths, and a client typo should not turn into a
165
+ * failed RPC.
166
+ *
167
+ * @param {unknown} patch - the client-supplied patch.
168
+ * @returns {object} the coerced patch.
169
+ */
170
+ function cleanPatch(patch) {
171
+ const source = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {}
172
+ const out = {}
173
+ for (const [key, coerce] of Object.entries(SETTINGS_FIELDS)) {
174
+ if (!Object.hasOwn(source, key)) continue
175
+ out[key] = coerce(source[key])
176
+ }
177
+ return out
178
+ }
179
+
180
+ /**
181
+ * Build the RPC handler.
182
+ *
183
+ * @param {object} ctx - the plugin context.
184
+ * @param {object} manager - the {@link import('./codespaces.js').CodespaceManager}.
185
+ * @param {{getSettings?: () => object, setSettings?: (patch: object) => Promise<object>, logger?: object}} [options] - wiring.
186
+ * @returns {(input: object) => Promise<object>} the dispatcher.
187
+ */
188
+ export function createRpcHandler(ctx, manager, options = {}) {
189
+ const getSettings = typeof options.getSettings === 'function' ? options.getSettings : () => ({})
190
+ const setSettings = typeof options.setSettings === 'function' ? options.setSettings : null
191
+
192
+ const log = (level, message) => {
193
+ try {
194
+ const logger = options.logger ?? ctx?.logger
195
+ const fn = logger?.[level] ?? logger?.info
196
+ if (typeof fn === 'function') fn.call(logger, redact(`codespace rpc: ${message}`))
197
+ } catch {
198
+ /* a logger is a nicety, never a dependency */
199
+ }
200
+ }
201
+
202
+ /** Resolve the current settings plus the revision the client must echo back. */
203
+ const settingsView = async () => {
204
+ let revision
205
+ try {
206
+ // The revision guards a concurrent edit: it is the settings service's
207
+ // own optimistic-concurrency token, read from its redacted descriptor.
208
+ const descriptors = await ctx?.settings?.describe?.({ redactSecrets: true })
209
+ const ours = Array.isArray(descriptors)
210
+ ? descriptors.find((row) => row?.ns === 'dsh-codespace-workspace')
211
+ : undefined
212
+ revision = ours?.revision
213
+ } catch {
214
+ // An unavailable settings service is not an error here: the live config
215
+ // is still the source of truth for reads, and a write will report its own
216
+ // failure.
217
+ revision = undefined
218
+ }
219
+ const { settings, secrets } = publicSettings(getSettings())
220
+ return { settings, secrets, revision }
221
+ }
222
+
223
+ const handlers = {
224
+ async [ACTIONS.STATUS]() {
225
+ // `secrets` is additive to CONTRACT §6's `{gh, tokenSource, configured}`:
226
+ // the client reads `status.secrets` for the write-only SSH-key indicator
227
+ // and does not retain the `secrets` from `get-settings`. Same markers,
228
+ // same guarantee — booleans only, never a value.
229
+ const { secrets } = publicSettings(getSettings())
230
+ return { ...(await manager.status()), secrets }
231
+ },
232
+
233
+ async [ACTIONS.GET_SETTINGS]() {
234
+ return await settingsView()
235
+ },
236
+
237
+ async [ACTIONS.SET_SETTINGS](payload) {
238
+ if (setSettings === null) {
239
+ const error = new Error('the settings service is unavailable in this profile, so settings cannot be saved')
240
+ error.code = 'E_NO_SETTINGS'
241
+ throw error
242
+ }
243
+ const patch = cleanPatch(payload?.patch)
244
+ await setSettings(patch)
245
+ return await settingsView()
246
+ },
247
+
248
+ async [ACTIONS.LIST_REPOS]() {
249
+ return { repos: await manager.listRepos() }
250
+ },
251
+
252
+ async [ACTIONS.LIST_CODESPACES](payload) {
253
+ return { codespaces: await manager.listCodespaces(payload?.repo) }
254
+ },
255
+
256
+ async [ACTIONS.MACHINES](payload) {
257
+ return { machines: await manager.machines(requireString(payload, 'repo')) }
258
+ },
259
+
260
+ async [ACTIONS.PREBUILD](payload) {
261
+ return { hasPrebuild: await manager.prebuild(requireString(payload, 'repo')) }
262
+ },
263
+
264
+ async [ACTIONS.CREATE](payload) {
265
+ const codespace = await manager.create({
266
+ repo: requireString(payload, 'repo'),
267
+ branch: typeof payload?.branch === 'string' ? payload.branch : undefined,
268
+ machine: typeof payload?.machine === 'string' ? payload.machine : undefined,
269
+ displayName: typeof payload?.displayName === 'string' ? payload.displayName : undefined,
270
+ })
271
+ return { codespace }
272
+ },
273
+
274
+ async [ACTIONS.START](payload) {
275
+ return { codespace: await manager.start(requireString(payload, 'name')) }
276
+ },
277
+
278
+ async [ACTIONS.STOP](payload) {
279
+ return { codespace: await manager.stop(requireString(payload, 'name')) }
280
+ },
281
+
282
+ async [ACTIONS.REMOVE](payload) {
283
+ await manager.remove(requireString(payload, 'name'))
284
+ return {}
285
+ },
286
+
287
+ async [ACTIONS.PAUSE_CANCEL](payload) {
288
+ const workspaceId = manager.recordByName(requireString(payload, 'name'))?.workspaceId
289
+ if (workspaceId !== undefined) manager.cancelAutoPause(workspaceId)
290
+ return { autoPause: null }
291
+ },
292
+
293
+ async [ACTIONS.PAUSE_NOW](payload) {
294
+ const workspaceId = manager.recordByName(requireString(payload, 'name'))?.workspaceId
295
+ if (workspaceId !== undefined) await manager.pauseNow(workspaceId)
296
+ return {}
297
+ },
298
+
299
+ async [ACTIONS.WORKSPACES]() {
300
+ return { workspaces: await manager.listWorkspaces() }
301
+ },
302
+
303
+ async [ACTIONS.CREATE_WORKSPACE](payload) {
304
+ const codespace = payload?.codespace
305
+ if (codespace === undefined || codespace === null) {
306
+ const error = new Error('create-workspace requires a codespace')
307
+ error.code = 'E_INVALID'
308
+ throw error
309
+ }
310
+ return { workspace: await manager.createWorkspace({ codespace, title: payload?.title }) }
311
+ },
312
+
313
+ async [ACTIONS.REMOVE_WORKSPACE](payload) {
314
+ await manager.removeWorkspace({
315
+ workspaceId: requireString(payload, 'workspaceId'),
316
+ deleteCodespace: payload?.deleteCodespace === true,
317
+ })
318
+ return {}
319
+ },
320
+ }
321
+
322
+ return async function dispatch(input) {
323
+ const action = typeof input?.action === 'string' ? input.action : ''
324
+ const handler = handlers[action]
325
+ if (handler === undefined) {
326
+ const error = new Error(`unknown action ${JSON.stringify(action)}`)
327
+ error.code = 'E_UNKNOWN_ACTION'
328
+ throw error
329
+ }
330
+ const data = await handler(input)
331
+ return { ok: true, ...data }
332
+ }
333
+ }
334
+
335
+ /** Read a required string payload field. */
336
+ function requireString(payload, key) {
337
+ const value = payload?.[key]
338
+ if (typeof value !== 'string' || value.trim().length === 0) {
339
+ const error = new Error(`"${key}" is required`)
340
+ error.code = 'E_INVALID'
341
+ throw error
342
+ }
343
+ return value.trim()
344
+ }
345
+
346
+ /**
347
+ * Register the RPC route on both carriers.
348
+ *
349
+ * @param {object} ctx - the plugin context.
350
+ * @param {object} manager - the manager.
351
+ * @param {{getSettings?: () => object, setSettings?: (patch: object) => Promise<object>}} [options] - wiring.
352
+ * @returns {() => void} a disposer removing every route. Idempotent.
353
+ */
354
+ export function installRoutes(ctx, manager, options = {}) {
355
+ const dispatch = createRpcHandler(ctx, manager, options)
356
+ const disposers = []
357
+ // Set by the returned disposer. The injection callback below can run *after*
358
+ // teardown (the injected service may appear late), so it must be able to see
359
+ // that the plugin is already gone and register nothing.
360
+ let torn = false
361
+
362
+ /** Turn a dispatcher call into a wire response, never throwing. */
363
+ const respond = async (input) => {
364
+ try {
365
+ return await dispatch(input)
366
+ } catch (error) {
367
+ const failure = wireError(error)
368
+ try {
369
+ const logger = options.logger ?? ctx?.logger
370
+ logger?.warn?.(`codespace rpc ${String(input?.action)} failed: ${failure.code} ${failure.message}`)
371
+ } catch {
372
+ /* logging is optional */
373
+ }
374
+ return { ok: false, error: failure }
375
+ }
376
+ }
377
+
378
+ ctx.inject(['webServer'], (ready) => {
379
+ if (torn) return
380
+
381
+ /**
382
+ * Register one route, tolerating a missing carrier or a late teardown.
383
+ *
384
+ * @returns a disposer that also removes itself from `disposers`, so an
385
+ * upgraded-away carrier is not disposed twice, or `null` on failure.
386
+ */
387
+ const register = (label, install) => {
388
+ try {
389
+ const dispose = install()
390
+ // Teardown may have run between the `torn` check and here.
391
+ if (torn) {
392
+ try {
393
+ dispose?.()
394
+ } catch {
395
+ /* idempotent teardown */
396
+ }
397
+ return null
398
+ }
399
+ const entry = () => {
400
+ try {
401
+ dispose?.()
402
+ } catch {
403
+ /* idempotent teardown */
404
+ }
405
+ }
406
+ disposers.push(entry)
407
+ return entry
408
+ } catch (error) {
409
+ ctx.logger?.warn?.(`codespace: cannot register ${label}: ${redact(error instanceof Error ? error.message : String(error))}`)
410
+ return null
411
+ }
412
+ }
413
+
414
+ /** Drop a route registered above, e.g. when a stronger carrier arrives. */
415
+ const unregister = (entry) => {
416
+ if (entry === null || entry === undefined) return
417
+ const at = disposers.indexOf(entry)
418
+ if (at !== -1) disposers.splice(at, 1)
419
+ entry()
420
+ }
421
+
422
+ /**
423
+ * The RPC body, as a Connection Fetch route.
424
+ *
425
+ * Registering on `connection.fetch` is the PREFERRED carrier, because DSH's
426
+ * own `/api` prefix route serves this registry and admits the request
427
+ * first: `createSharedFetchHandler` is invoked from the `/api` route's
428
+ * handler, which calls `connection.admit(req)` — the harness Host/Origin
429
+ * fence AND the browser-auth cookie check — before `bridge()` ever
430
+ * dispatches to a registered Fetch route. Registering here therefore
431
+ * inherits the harness's security policy instead of re-implementing it, and
432
+ * serves the browser and the Desktop shell alike.
433
+ *
434
+ * Do NOT go back to registering only a `webServer` exact route: the web
435
+ * server resolves an exact route BEFORE the `/api` prefix route
436
+ * (`dsh-host-webserver` `match()` tries the exact table first), so an exact
437
+ * route on this same path SHADOWS the `/api` route and silently drops both
438
+ * the fence and the authentication the harness would have applied.
439
+ */
440
+ const fetchRoute = {
441
+ path: RPC_ROUTE,
442
+ methods: ['POST'],
443
+ requestBody: 'buffered',
444
+ fetch: async (request) => {
445
+ let input
446
+ try {
447
+ input = await request.json()
448
+ } catch {
449
+ return Response.json(
450
+ { ok: false, error: { code: 'E_BAD_REQUEST', message: 'invalid JSON request body' } },
451
+ { status: 200, headers: { 'cache-control': 'no-store' } },
452
+ )
453
+ }
454
+ // Always 200: the client reads `ok` from the body, so an action
455
+ // failure is data, not a transport error.
456
+ return Response.json(await respond(input), { headers: { 'cache-control': 'no-store' } })
457
+ },
458
+ }
459
+
460
+ // `connection` is NOT in this module's `inject`, so a plain property read
461
+ // (`ready.connection`) is gated by cordis and throws "cannot get property
462
+ // ... without inject" once the plugin's own inject list is enforced.
463
+ // `ctx.get(name, false)` is the public ungated accessor -- documented in
464
+ // cordis as "Read a service from the store without the inject requirement"
465
+ // -- and returns undefined when nothing provides it. It is the same
466
+ // `readService(name, false)` path the client runner uses for optional
467
+ // services.
468
+ //
469
+ // Load order is not guaranteed, so this cannot be a one-shot decision: if
470
+ // `connection` is not up yet we must not silently settle for the weaker
471
+ // fallback for the rest of the process. `ready.inject(['connection'], ...)`
472
+ // is the public trigger for that -- verified against cordis: it fires when
473
+ // the service is provided later, and stays silent (without throwing) when it
474
+ // never appears. The nested injection below is deliberately one level deep,
475
+ // so it can also run immediately if `connection` is already up.
476
+ let fetchEntry = null
477
+ let fallbackEntry = null
478
+
479
+ const upgrade = () => {
480
+ if (torn || fetchEntry !== null) return
481
+ const connection = ready.get('connection', false)
482
+ if (!connection?.fetch?.register) return
483
+ const entry = register('the rpc route', () => ready.effect(
484
+ () => connection.fetch.register(fetchRoute),
485
+ 'dsh-codespace-workspace: rpc fetch route',
486
+ ))
487
+ if (entry === null) return
488
+ fetchEntry = entry
489
+ // Only drop the fallback once the preferred carrier is really in place:
490
+ // dropping it first would leave a window with no route at all.
491
+ unregister(fallbackEntry)
492
+ fallbackEntry = null
493
+ }
494
+
495
+ // Install the preferred carrier now if it is available, and again if it
496
+ // shows up later.
497
+ upgrade()
498
+ ready.inject(['connection'], () => upgrade())
499
+
500
+ if (fetchEntry !== null) return
501
+
502
+ // Fallback for a composition with no connection service. An exact route is
503
+ // matched before DSH's `/api` prefix route, so this one shadows the harness
504
+ // fence and auth and must therefore apply the fence itself. `upgrade()` will
505
+ // replace it if `connection` appears later.
506
+ fallbackEntry = register('the rpc route', () => ready.effect(() => ready.webServer.register({
507
+ kind: 'exact',
508
+ path: RPC_ROUTE,
509
+ handler: async (req, res) => {
510
+ if (req.method !== 'POST') {
511
+ res.setHeader('allow', 'POST')
512
+ sendJson(res, 405, { ok: false, error: { code: 'E_METHOD', message: 'Method not allowed.' } })
513
+ return
514
+ }
515
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
516
+ sendJson(res, 403, { ok: false, error: { code: 'E_FORBIDDEN', message: 'This endpoint is available only over a loopback connection.' } })
517
+ return
518
+ }
519
+ if (!isTrustedApiRequest(req)) {
520
+ sendJson(res, 403, { ok: false, error: { code: 'E_FORBIDDEN', message: 'Requests must come from a loopback, same-origin page.' } })
521
+ return
522
+ }
523
+ const contentType = req.headers['content-type']
524
+ if (typeof contentType !== 'string' || !contentType.toLowerCase().startsWith('application/json')) {
525
+ sendJson(res, 415, { ok: false, error: { code: 'E_CONTENT_TYPE', message: 'Requests must be application/json.' } })
526
+ return
527
+ }
528
+ let input
529
+ try {
530
+ input = await readJsonBody(req)
531
+ } catch (error) {
532
+ sendJson(res, 400, {
533
+ ok: false,
534
+ error: { code: 'E_BAD_REQUEST', message: error instanceof Error ? redact(error.message) : 'invalid request body' },
535
+ })
536
+ return
537
+ }
538
+ sendJson(res, 200, await respond(input))
539
+ },
540
+ }), 'dsh-codespace-workspace: rpc route (fallback)'))
541
+ })
542
+
543
+ return () => {
544
+ if (torn) return
545
+ torn = true
546
+ for (const dispose of disposers) {
547
+ try {
548
+ dispose()
549
+ } catch {
550
+ /* idempotent teardown */
551
+ }
552
+ }
553
+ disposers.length = 0
554
+ }
555
+ }