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.
- package/LICENSE +21 -0
- package/README.md +680 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +10 -0
- package/lib/client.js +3598 -0
- package/lib/host/codespaces.js +942 -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 +267 -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,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
|
+
}
|