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.
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Remote data plane for dsh-codespace-workspace.
3
+ *
4
+ * Every byte that reaches a remote shell passes through {@link buildCommand},
5
+ * which composes a POSIX command line from literal fragments and
6
+ * {@link shq}-quoted values. A model-supplied path is never interpolated into
7
+ * a command string: it is always quoted, and file bodies travel base64-encoded
8
+ * so no content is ever interpreted by the shell.
9
+ *
10
+ * Two transports back the same interface:
11
+ * 1. `gh codespace ssh -c <name> -- sh -lc '<cmd>'` when `gh` is usable.
12
+ * 2. a direct `ssh <alias> sh -lc '<cmd>'` against a user-configured
13
+ * ssh-config alias, which is what actually runs on a machine without `gh`.
14
+ *
15
+ * @module dsh-codespace-workspace/host/transport
16
+ */
17
+
18
+ import { spawn } from 'node:child_process'
19
+ import { redact } from '../shared.js'
20
+ import { ghExec, isNotRunning, shq } from './gh.js'
21
+
22
+ /** Default ceiling for one remote command. */
23
+ const DEFAULT_TIMEOUT_MS = 60000
24
+
25
+ /** Captured output is bounded; a runaway command must not exhaust memory. */
26
+ const MAX_OUTPUT_BYTES = 8 * 1024 * 1024
27
+
28
+ /** Default read ceiling, in bytes, for {@link remoteReadText}. */
29
+ const DEFAULT_MAX_READ_BYTES = 4 * 1024 * 1024
30
+
31
+ /**
32
+ * A remote-operation failure carrying a stable `code`.
33
+ *
34
+ * Codes are `E_REMOTE_*` so the caller can tell a remote failure from a local
35
+ * one without inspecting the message.
36
+ */
37
+ export class RemoteError extends Error {
38
+ /**
39
+ * @param {string} message - human-readable, already redacted.
40
+ * @param {string} code - stable machine code.
41
+ */
42
+ constructor(message, code) {
43
+ super(redact(message))
44
+ this.name = 'RemoteError'
45
+ this.code = code
46
+ }
47
+ }
48
+
49
+ /** The `ssh` binary, overridable for tests and unusual installs. */
50
+ const SSH_BIN = process.env.DSH_CODESPACE_SSH || 'ssh'
51
+
52
+ /**
53
+ * Normalize an arbitrary path against a remote POSIX cwd.
54
+ *
55
+ * Mirrors `path.resolve` for POSIX semantics without importing the local
56
+ * platform's path module, whose behaviour differs on Windows.
57
+ *
58
+ * A leading `~` cannot be expanded here: this function is synchronous and the
59
+ * remote home is only knowable by asking the remote host. A `~`-prefixed input
60
+ * is therefore returned with the tilde intact and the remainder normalized, so
61
+ * the caller can hand it to {@link remoteExpandTilde} — never to a command
62
+ * that quotes it, because a quoted tilde is not expanded by the shell.
63
+ *
64
+ * @param {string} cwd - the remote working directory; must be absolute.
65
+ * @param {string} input - the model-supplied path.
66
+ * @returns {string} an absolute POSIX path, or a `~`-prefixed one when the input was.
67
+ */
68
+ export function remoteResolve(cwd, input) {
69
+ const base = typeof cwd === 'string' && cwd.startsWith('/') ? cwd : '/'
70
+ const raw = typeof input === 'string' ? input : ''
71
+ if (raw.startsWith('/')) return normalizePosix(raw)
72
+ if (raw === '~') return '~'
73
+ if (raw.startsWith('~/')) return `~/${normalizePosix(`/${raw.slice(2)}`).slice(1)}`
74
+ return normalizePosix(`${base}/${raw}`)
75
+ }
76
+
77
+ /** Whether a resolved path still needs {@link remoteExpandTilde}. */
78
+ export function needsTildeExpansion(path) {
79
+ return typeof path === 'string' && (path === '~' || path.startsWith('~/'))
80
+ }
81
+
82
+ /**
83
+ * Collapse `.` and `..` segments in an absolute POSIX path.
84
+ *
85
+ * @param {string} path - an absolute POSIX path.
86
+ * @returns {string} the normalized path; `..` never escapes the root.
87
+ */
88
+ function normalizePosix(path) {
89
+ const segments = path.split('/')
90
+ const out = []
91
+ for (const segment of segments) {
92
+ if (segment.length === 0 || segment === '.') continue
93
+ if (segment === '..') {
94
+ out.pop()
95
+ continue
96
+ }
97
+ out.push(segment)
98
+ }
99
+ return `/${out.join('/')}`
100
+ }
101
+
102
+ /**
103
+ * Build one remote command line from literal fragments and quoted values.
104
+ *
105
+ * @param {Array<string | {value: unknown}>} parts - literals, or `{value}` for a quoted value.
106
+ * @returns {string} the composed command line.
107
+ */
108
+ function buildCommand(parts) {
109
+ return parts
110
+ .map((part) => (part !== null && typeof part === 'object' ? shq(part.value) : String(part)))
111
+ .join(' ')
112
+ }
113
+
114
+ /**
115
+ * Describe why a target cannot be used, or null when it can.
116
+ *
117
+ * @param {{kind?: string, codespace?: string, alias?: string}} target - the transport target.
118
+ * @returns {string|null} a plain-language reason, or null.
119
+ */
120
+ function targetProblem(target) {
121
+ if (target === null || typeof target !== 'object') return 'no transport target was configured'
122
+ if (target.kind === 'gh') {
123
+ if (typeof target.codespace !== 'string' || target.codespace.trim().length === 0) {
124
+ return 'the Codespace name is missing'
125
+ }
126
+ return null
127
+ }
128
+ if (target.kind === 'ssh') {
129
+ if (typeof target.alias !== 'string' || target.alias.trim().length === 0) {
130
+ return 'no SSH host is configured — set the Codespace SSH alias in the settings, or install the GitHub CLI'
131
+ }
132
+ // A bare filesystem path cannot address a host, so an `ssh -i <key>` with no
133
+ // destination would fail obscurely. Name the actual requirement instead.
134
+ // VERIFY: the client's locale hint says "a private key path also works",
135
+ // but docs/CONTRACT.md §7 specifies this fallback as an ssh-config *alias*,
136
+ // and a key alone supplies no host to connect to. Tolerant branch: a
137
+ // path-shaped value is reported as a misconfiguration with the fix spelled
138
+ // out, rather than being passed to ssh as a hostname.
139
+ if (/^[~.]|[/\\]/.test(target.alias.trim())) {
140
+ return `the SSH setting looks like a key path (${target.alias.trim()}), but a private key alone cannot name a host — put a Host entry for the Codespace in ~/.ssh/config and use that alias here, or install the GitHub CLI`
141
+ }
142
+ return null
143
+ }
144
+ return `unknown transport kind ${JSON.stringify(String(target.kind))}`
145
+ }
146
+
147
+ /**
148
+ * Run one program locally and capture its output.
149
+ *
150
+ * @param {string} command - executable.
151
+ * @param {string[]} args - argument vector.
152
+ * @param {{timeoutMs?: number, signal?: AbortSignal}} [options] - execution options.
153
+ * @returns {Promise<{stdout: string, stderr: string, exitCode: number, timedOut: boolean, aborted: boolean}>} the captured result.
154
+ */
155
+ function runLocal(command, args, options = {}) {
156
+ return new Promise((resolve, reject) => {
157
+ let child
158
+ try {
159
+ child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
160
+ } catch (error) {
161
+ reject(error)
162
+ return
163
+ }
164
+
165
+ let stdout = ''
166
+ let stderr = ''
167
+ let settled = false
168
+ let timer = null
169
+ let timedOut = false
170
+ let aborted = false
171
+
172
+ const finish = (fn, value) => {
173
+ if (settled) return
174
+ settled = true
175
+ if (timer !== null) clearTimeout(timer)
176
+ if (options.signal !== undefined) options.signal.removeEventListener('abort', onAbort)
177
+ fn(value)
178
+ }
179
+
180
+ const kill = () => {
181
+ try {
182
+ child.kill()
183
+ } catch {
184
+ /* already gone */
185
+ }
186
+ }
187
+
188
+ function onAbort() {
189
+ aborted = true
190
+ kill()
191
+ }
192
+
193
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS
194
+ if (timeoutMs > 0) {
195
+ timer = setTimeout(() => {
196
+ timedOut = true
197
+ kill()
198
+ }, timeoutMs)
199
+ }
200
+
201
+ if (options.signal !== undefined) {
202
+ if (options.signal.aborted) onAbort()
203
+ else options.signal.addEventListener('abort', onAbort, { once: true })
204
+ }
205
+
206
+ child.stdout.on('data', (chunk) => {
207
+ if (stdout.length < MAX_OUTPUT_BYTES) stdout += chunk.toString('utf8')
208
+ })
209
+ child.stderr.on('data', (chunk) => {
210
+ if (stderr.length < MAX_OUTPUT_BYTES) stderr += chunk.toString('utf8')
211
+ })
212
+ child.on('error', (error) => finish(reject, error))
213
+ child.on('close', (code) => finish(resolve, {
214
+ stdout,
215
+ stderr,
216
+ exitCode: code ?? 0,
217
+ timedOut,
218
+ aborted,
219
+ }))
220
+ })
221
+ }
222
+
223
+ /**
224
+ * Run a command inside a Codespace.
225
+ *
226
+ * @param {{kind: 'gh'|'ssh', codespace?: string, alias?: string, cwd?: string}} target - the transport target.
227
+ * @param {string} command - the complete POSIX shell command line.
228
+ * @param {{timeoutMs?: number, signal?: AbortSignal, run?: Function}} [options] - execution options.
229
+ * @returns {Promise<{stdout: string, stderr: string, exitCode: number, timedOut: boolean, aborted: boolean}>} a shell-run-shaped result.
230
+ * @throws {RemoteError} `E_REMOTE_TARGET` when the target is unusable, `E_REMOTE_SPAWN` when the transport cannot start.
231
+ */
232
+ export async function remoteExec(target, command, options = {}) {
233
+ const problem = targetProblem(target)
234
+ if (problem !== null) throw new RemoteError(problem, 'E_REMOTE_TARGET')
235
+
236
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS
237
+ const line = String(command)
238
+
239
+ if (target.kind === 'gh') {
240
+ const run = options.run
241
+ if (typeof run !== 'function') {
242
+ throw new RemoteError('the gh transport requires an injected executor', 'E_REMOTE_TARGET')
243
+ }
244
+ const result = await ghExec(run, target.codespace, line, { timeoutMs, signal: options.signal })
245
+ if (result.exitCode === -1 && isNotRunning(result)) {
246
+ throw new RemoteError(
247
+ `Codespace ${target.codespace} is not running — start it, then retry. ${result.stderr}`.trim(),
248
+ 'E_REMOTE_NOT_RUNNING',
249
+ )
250
+ }
251
+ if (result.exitCode === -1) {
252
+ throw new RemoteError(result.stderr || 'gh codespace ssh could not start', 'E_REMOTE_SPAWN')
253
+ }
254
+ return { ...result, timedOut: false, aborted: false }
255
+ }
256
+
257
+ const args = [
258
+ '-o', 'BatchMode=yes',
259
+ '-o', 'StrictHostKeyChecking=accept-new',
260
+ target.alias,
261
+ 'sh', '-lc', shq(line),
262
+ ]
263
+ try {
264
+ return await runLocal(SSH_BIN, args, { timeoutMs, signal: options.signal })
265
+ } catch (error) {
266
+ const reason = error instanceof Error ? error.message : String(error)
267
+ throw new RemoteError(
268
+ `cannot start ssh for alias ${JSON.stringify(String(target.alias))}: ${reason}. Install the GitHub CLI, or configure a working SSH alias.`,
269
+ 'E_REMOTE_SPAWN',
270
+ )
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Read a remote file as UTF-8 text.
276
+ *
277
+ * The body travels base64-encoded so no content is interpreted by the remote
278
+ * shell. The read is capped: the caller is told when the file was truncated
279
+ * rather than being handed an unbounded buffer.
280
+ *
281
+ * @param {object} target - the transport target.
282
+ * @param {string} path - the remote path.
283
+ * @param {{timeoutMs?: number, signal?: AbortSignal, maxBytes?: number, run?: Function}} [options] - read options.
284
+ * @returns {Promise<{text: string, size: number, truncated: boolean}>} the decoded text and its size.
285
+ * @throws {RemoteError} `E_REMOTE_NOT_FOUND` when the path is absent, `E_REMOTE_READ` otherwise.
286
+ */
287
+ export async function remoteReadText(target, path, options = {}) {
288
+ const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
289
+ ? options.maxBytes
290
+ : DEFAULT_MAX_READ_BYTES
291
+ // `-w0` keeps base64 on one line; `head -c` caps the raw bytes before
292
+ // encoding, so a huge file cannot blow up the transfer.
293
+ const command = buildCommand([
294
+ 'if [ -d', { value: path }, ']; then echo DSH_IS_DIR >&2; exit 3; fi;',
295
+ 'if [ ! -e', { value: path }, ']; then echo DSH_MISSING >&2; exit 4; fi;',
296
+ 'wc -c <', { value: path }, '| tr -d " ";',
297
+ `head -c ${maxBytes}`,
298
+ { value: path },
299
+ '| base64 -w0',
300
+ ])
301
+ const result = await remoteExec(target, command, options)
302
+ if (result.exitCode === 3) throw new RemoteError(`cannot read "${path}": is a directory`, 'E_REMOTE_IS_DIR')
303
+ if (result.exitCode === 4) throw new RemoteError(`cannot read "${path}": not found`, 'E_REMOTE_NOT_FOUND')
304
+ if (result.exitCode !== 0) {
305
+ throw new RemoteError(`cannot read "${path}": ${result.stderr.trim() || `exit ${result.exitCode}`}`, 'E_REMOTE_READ')
306
+ }
307
+
308
+ const newline = result.stdout.indexOf('\n')
309
+ if (newline < 0) throw new RemoteError(`cannot read "${path}": malformed transport response`, 'E_REMOTE_READ')
310
+ const size = Number.parseInt(result.stdout.slice(0, newline).trim(), 10)
311
+ const encoded = result.stdout.slice(newline + 1).replace(/\s+/g, '')
312
+ let text
313
+ try {
314
+ text = Buffer.from(encoded, 'base64').toString('utf8')
315
+ } catch {
316
+ throw new RemoteError(`cannot read "${path}": malformed base64 payload`, 'E_REMOTE_READ')
317
+ }
318
+ const total = Number.isFinite(size) ? size : Buffer.byteLength(text, 'utf8')
319
+ return { text, size: total, truncated: total > maxBytes }
320
+ }
321
+
322
+ /**
323
+ * Atomically write UTF-8 text to a remote path.
324
+ *
325
+ * The body is base64-encoded and decoded remotely, then moved into place, so a
326
+ * partially written file is never observable and no content byte is ever
327
+ * interpreted by the shell.
328
+ *
329
+ * @param {object} target - the transport target.
330
+ * @param {string} path - the remote path.
331
+ * @param {string} content - the text to write.
332
+ * @param {{timeoutMs?: number, signal?: AbortSignal, run?: Function}} [options] - write options.
333
+ * @returns {Promise<{path: string, bytes: number}>} the written path and byte count.
334
+ * @throws {RemoteError} `E_REMOTE_WRITE` on failure.
335
+ */
336
+ export async function remoteWriteText(target, path, content, options = {}) {
337
+ const text = typeof content === 'string' ? content : String(content ?? '')
338
+ const encoded = Buffer.from(text, 'utf8').toString('base64')
339
+ const command = buildCommand([
340
+ 'dir=$(dirname', { value: path }, ') && mkdir -p "$dir" &&',
341
+ `printf %s ${shq(encoded)} | base64 -d >`,
342
+ { value: `${path}.dsh-tmp` },
343
+ '&& mv', { value: `${path}.dsh-tmp` }, { value: path },
344
+ ])
345
+ const result = await remoteExec(target, command, options)
346
+ if (result.exitCode !== 0) {
347
+ throw new RemoteError(
348
+ `cannot write "${path}": ${result.stderr.trim() || `exit ${result.exitCode}`}`,
349
+ 'E_REMOTE_WRITE',
350
+ )
351
+ }
352
+ return { path, bytes: Buffer.byteLength(text, 'utf8') }
353
+ }
354
+
355
+ /**
356
+ * List a remote directory as content-free entries.
357
+ *
358
+ * Uses `find -maxdepth 1` with NUL-separated records, so a filename with a
359
+ * newline cannot be mistaken for two entries.
360
+ *
361
+ * @param {object} target - the transport target.
362
+ * @param {string} path - the remote directory.
363
+ * @param {{timeoutMs?: number, signal?: AbortSignal, run?: Function}} [options] - listing options.
364
+ * @returns {Promise<Array<{name: string, type: 'file'|'dir'|'other', size: number}>>} the entries.
365
+ * @throws {RemoteError} `E_REMOTE_NOT_FOUND` when the directory is absent.
366
+ */
367
+ export async function remoteListDir(target, path, options = {}) {
368
+ const command = buildCommand([
369
+ 'if [ ! -d', { value: path }, ']; then echo DSH_MISSING >&2; exit 4; fi;',
370
+ 'find', { value: path }, '-mindepth 1 -maxdepth 1 -printf "%y\\t%s\\t%f\\0" 2>/dev/null',
371
+ '|| ls -A', { value: path },
372
+ ])
373
+ const result = await remoteExec(target, command, options)
374
+ if (result.exitCode === 4) throw new RemoteError(`cannot list "${path}": not found`, 'E_REMOTE_NOT_FOUND')
375
+ if (result.exitCode !== 0) {
376
+ throw new RemoteError(`cannot list "${path}": ${result.stderr.trim() || `exit ${result.exitCode}`}`, 'E_REMOTE_READ')
377
+ }
378
+
379
+ const entries = []
380
+ for (const record of result.stdout.split('\0')) {
381
+ if (record.length === 0) continue
382
+ const parts = record.split('\t')
383
+ if (parts.length < 3) {
384
+ // Fallback shape from the `ls -A` branch: a bare name, type unknown.
385
+ entries.push({ name: record, type: 'other', size: 0 })
386
+ continue
387
+ }
388
+ const kind = parts[0]
389
+ entries.push({
390
+ name: parts.slice(2).join('\t'),
391
+ type: kind === 'd' ? 'dir' : kind === 'f' ? 'file' : 'other',
392
+ size: Number.isFinite(Number(parts[1])) ? Number(parts[1]) : 0,
393
+ })
394
+ }
395
+ return entries
396
+ }
397
+
398
+ /**
399
+ * Stat one remote path.
400
+ *
401
+ * @param {object} target - the transport target.
402
+ * @param {string} path - the remote path.
403
+ * @param {{timeoutMs?: number, signal?: AbortSignal, run?: Function}} [options] - stat options.
404
+ * @returns {Promise<{type: 'file'|'dir'|'other', size: number, mtimeMs: number}|null>} the stat, or null when absent.
405
+ */
406
+ export async function remoteStat(target, path, options = {}) {
407
+ const command = buildCommand([
408
+ 'if [ -e', { value: path }, ']; then',
409
+ 'stat -c "%F\\t%s\\t%Y"', { value: path }, '2>/dev/null || stat -f "%HT\\t%z\\t%m"', { value: path },
410
+ '; else echo DSH_MISSING; fi',
411
+ ])
412
+ const result = await remoteExec(target, command, options)
413
+ const text = result.stdout.trim()
414
+ if (result.exitCode !== 0 || text.length === 0 || text === 'DSH_MISSING') return null
415
+
416
+ const parts = text.split('\t')
417
+ if (parts.length < 3) return null
418
+ const kind = parts[0].toLowerCase()
419
+ const type = kind.includes('directory') || kind === 'dir' ? 'dir'
420
+ : kind.includes('regular') || kind === 'file' ? 'file'
421
+ : 'other'
422
+ const seconds = Number(parts[2])
423
+ return {
424
+ type,
425
+ size: Number.isFinite(Number(parts[1])) ? Number(parts[1]) : 0,
426
+ mtimeMs: Number.isFinite(seconds) ? seconds * 1000 : 0,
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Resolve the remote home directory.
432
+ *
433
+ * Exists so a `~`-prefixed path can be turned into a real absolute path before
434
+ * any command that must quote it.
435
+ *
436
+ * @param {object} target - the transport target.
437
+ * @param {{timeoutMs?: number, signal?: AbortSignal, run?: Function}} [options] - options.
438
+ * @returns {Promise<string>} the absolute home path, or `/` when it cannot be read.
439
+ */
440
+ export async function remoteHome(target, options = {}) {
441
+ try {
442
+ const result = await remoteExec(target, 'printf %s "$HOME"', options)
443
+ const home = result.stdout.trim()
444
+ return result.exitCode === 0 && home.startsWith('/') ? normalizePosix(home) : '/'
445
+ } catch {
446
+ return '/'
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Expand a leading `~` against the real remote home.
452
+ *
453
+ * @param {object} target - the transport target.
454
+ * @param {string} path - a possibly `~`-prefixed path.
455
+ * @param {{timeoutMs?: number, signal?: AbortSignal, run?: Function}} [options] - options.
456
+ * @returns {Promise<string>} an absolute POSIX path.
457
+ */
458
+ export async function remoteExpandTilde(target, path, options = {}) {
459
+ const raw = typeof path === 'string' ? path : ''
460
+ if (!raw.startsWith('~')) return remoteResolve('/', raw)
461
+ const home = await remoteHome(target, options)
462
+ return normalizePosix(`${home}/${raw.slice(1)}`)
463
+ }
package/lib/index.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * dsh-codespace-workspace — host half.
3
+ *
4
+ * Makes a GitHub Codespace usable as a DSH workspace. The Codespace is
5
+ * registered in DSH's own workspace registry as a real (empty) local
6
+ * placeholder directory, so the harness's workspace and session machinery
7
+ * works untouched; the file and shell tools the agent actually uses are
8
+ * shadowed *per agent* with remote implementations once a session's cwd is
9
+ * recognized as a placeholder.
10
+ *
11
+ * Nothing global is replaced: `ctx.fs` and `ctx.shell` are never swapped, so
12
+ * local sessions and every other plugin are unaffected.
13
+ *
14
+ * @module dsh-codespace-workspace
15
+ */
16
+
17
+ import z from '@deepseek-ai/schemastery'
18
+
19
+ import { PLUGIN_ID, SETTINGS_DEFAULTS, SETTINGS_NS, redact } from './shared.js'
20
+ import { CodespaceManager } from './host/codespaces.js'
21
+ import { installRoutes } from './host/routes.js'
22
+ import { installSessionWiring } from './host/session.js'
23
+
24
+ export const name = PLUGIN_ID
25
+
26
+ /**
27
+ * Services this plugin waits for.
28
+ *
29
+ * Only the two that the per-agent surface genuinely cannot work without:
30
+ * `tools` to register the shadow tools and `systemPrompt` to add the rule.
31
+ * Everything else is optional and is reached through `ctx.get(...)` or
32
+ * `ctx.inject([...])`, so a profile without a web server or without the
33
+ * workspace registry still activates this plugin and still reports a clear
34
+ * error from the action that needed the missing service.
35
+ */
36
+ export const inject = ['tools', 'systemPrompt']
37
+
38
+ /** Guard for a schemastery build without `volatile()`. */
39
+ const vol = (schema) => (typeof schema?.volatile === 'function' ? schema.volatile() : schema)
40
+
41
+ /**
42
+ * The plugin's configuration.
43
+ *
44
+ * The whole object is volatile, so every field is editable while DSH runs:
45
+ * `applies: 'live'` is the settings service's default for a volatile form, and
46
+ * the values are read lazily on each use rather than cached at activation.
47
+ *
48
+ * `githubToken` carries `role('secret')`: the settings service removes it from
49
+ * every value it returns to a client and reports a `{ path, set }` marker
50
+ * instead, so a write-only input can render without the secret ever crossing
51
+ * the wire.
52
+ */
53
+ export const Config = vol(z.object({
54
+ githubUsername: z.string().default(SETTINGS_DEFAULTS.githubUsername),
55
+ githubToken: z.string().role('secret').default(''),
56
+ defaultBranch: z.string().default(SETTINGS_DEFAULTS.defaultBranch),
57
+ autoPauseMinutes: z.number().default(SETTINGS_DEFAULTS.autoPauseMinutes),
58
+ sshKeyPath: z.string().default(SETTINGS_DEFAULTS.sshKeyPath),
59
+ alwaysShowCloudButton: z.boolean().default(SETTINGS_DEFAULTS.alwaysShowCloudButton),
60
+ autoInitEmptyRepo: z.boolean().default(SETTINGS_DEFAULTS.autoInitEmptyRepo),
61
+ readmeContent: z.string().default(SETTINGS_DEFAULTS.readmeContent),
62
+ }))
63
+
64
+ /**
65
+ * Read the live configuration out of whatever shape schemastery produced.
66
+ *
67
+ * A volatile root is a frozen reference whose `get()` returns an immutable
68
+ * snapshot; a plain root is the object itself. Both are handled, because the
69
+ * guard exists precisely for a build that did not wrap the schema.
70
+ *
71
+ * @param {unknown} config - the plugin's `config` argument.
72
+ * @returns {object} a plain settings object.
73
+ */
74
+ function readConfig(config) {
75
+ const raw = config !== null && typeof config === 'object' && typeof config.get === 'function'
76
+ ? config.get()
77
+ : config
78
+ const source = raw !== null && typeof raw === 'object' ? raw : {}
79
+ const settings = {}
80
+ for (const [key, fallback] of Object.entries(SETTINGS_DEFAULTS)) {
81
+ settings[key] = source[key] === undefined ? fallback : source[key]
82
+ }
83
+ // Not in `SETTINGS_DEFAULTS` because it is write-only and never echoed back.
84
+ settings.githubToken = typeof source.githubToken === 'string' ? source.githubToken : ''
85
+ return settings
86
+ }
87
+
88
+ /**
89
+ * Activate the plugin.
90
+ *
91
+ * @param {object} ctx - the plugin context.
92
+ * @param {object} config - the parsed {@link Config}.
93
+ */
94
+ export function apply(ctx, config) {
95
+ const log = (level, message) => {
96
+ try {
97
+ const logger = ctx.logger
98
+ const fn = logger?.[level] ?? logger?.info
99
+ if (typeof fn === 'function') fn.call(logger, redact(`codespace: ${message}`))
100
+ } catch {
101
+ /* a logger is a nicety, never a dependency */
102
+ }
103
+ }
104
+
105
+ const getSettings = () => readConfig(config)
106
+
107
+ const manager = new CodespaceManager(ctx, {
108
+ getSettings,
109
+ logger: ctx.logger,
110
+ })
111
+
112
+ // ------------------------------------------------------------- persistence
113
+
114
+ /**
115
+ * Persist a settings patch through the settings service.
116
+ *
117
+ * The namespace is this plugin's loader row id, which is what the settings
118
+ * service keys its descriptors by. Only volatile paths are writable, and
119
+ * every field is volatile, so no path check can fail here.
120
+ *
121
+ * @param {object} patch - the coerced patch.
122
+ * @returns {Promise<void>} resolves once the write is committed.
123
+ */
124
+ const setSettings = async (patch) => {
125
+ const settings = ctx.get('settings')
126
+ if (settings === undefined || typeof settings.update !== 'function') {
127
+ const error = new Error('the settings service is unavailable in this profile, so settings cannot be saved')
128
+ error.code = 'E_NO_SETTINGS'
129
+ throw error
130
+ }
131
+ if (Object.keys(patch).length === 0) return
132
+ await settings.update(SETTINGS_NS, patch)
133
+ }
134
+
135
+ // -------------------------------------------------------------- registrations
136
+
137
+ const routesEffect = ctx.effect(
138
+ () => installRoutes(ctx, manager, { getSettings, setSettings, logger: ctx.logger }),
139
+ 'dsh-codespace-workspace: rpc routes',
140
+ )
141
+
142
+ const sessionEffect = ctx.effect(
143
+ () => installSessionWiring(ctx, manager, { getSettings }),
144
+ 'dsh-codespace-workspace: session wiring',
145
+ )
146
+
147
+ // The manager's own disposal and its shutdown stop-all live in the session
148
+ // wiring's shutdown effect, which is the one hook the harness awaits. This
149
+ // effect only guarantees the timers are cleared if the plugin unloads while
150
+ // the process keeps running.
151
+ const disposeEffect = ctx.effect(
152
+ () => () => manager.dispose(),
153
+ 'dsh-codespace-workspace: manager disposal',
154
+ )
155
+
156
+ let torn = false
157
+ ctx.effect(() => () => {
158
+ if (torn) return
159
+ torn = true
160
+ for (const dispose of [routesEffect, sessionEffect, disposeEffect]) {
161
+ try {
162
+ if (typeof dispose === 'function') dispose()
163
+ } catch {
164
+ /* idempotent teardown */
165
+ }
166
+ }
167
+ }, 'dsh-codespace-workspace: teardown')
168
+
169
+ log('info', `ready; placeholder workspaces live under ${manager.root}`)
170
+ }