dsh-codespace-workspace 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +793 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +10 -0
- package/lib/client.js +3648 -0
- package/lib/host/codespaces.js +1100 -0
- package/lib/host/gh.js +271 -0
- package/lib/host/github.js +619 -0
- package/lib/host/remote-tools.js +565 -0
- package/lib/host/routes.js +555 -0
- package/lib/host/session.js +301 -0
- package/lib/host/transport.js +463 -0
- package/lib/index.js +170 -0
- package/lib/shared.js +181 -0
- package/locale/en.json +122 -0
- package/locale/zh.json +122 -0
- package/package.json +80 -4
|
@@ -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
|
+
}
|