threadwire 0.1.6 → 0.1.8
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/CHANGELOG.md +40 -0
- package/README.md +13 -2
- package/TELEGRAM-INGRESS.md +8 -2
- package/bin/isolated-runtime.js +5 -0
- package/bin/model-broker.js +5 -0
- package/docs/isolated-provider-runtime.md +137 -0
- package/package.json +5 -1
- package/scripts/provider-shims/front-door.sh.template +18 -0
- package/scripts/verify-package.js +15 -0
- package/src/absolute-deadline.js +94 -0
- package/src/cli.js +120 -21
- package/src/delegated-result-admission.js +1 -1
- package/src/docker-api.js +131 -0
- package/src/isolated-runtime-client.js +149 -0
- package/src/isolated-runtime.js +982 -0
- package/src/isolated-state.js +409 -0
- package/src/isolated-worker.js +123 -0
- package/src/model-broker-policy.js +139 -0
- package/src/model-broker.js +313 -0
- package/src/mount-policy.js +28 -0
- package/src/normalized-output.js +68 -0
- package/src/relay-write.js +44 -0
- package/src/telegram-ingress/config.js +7 -0
- package/src/telegram-ingress/core.js +40 -0
- package/src/telegram-webhook.js +6 -0
package/src/cli.js
CHANGED
|
@@ -15,18 +15,24 @@ import {resolveWorkspaceProfile} from "./workspace-profile.js"
|
|
|
15
15
|
import {WorkerControl} from "./worker-control.js"
|
|
16
16
|
import {EvidenceStore} from "./evidence-store.js"
|
|
17
17
|
import {ContextBudgetMetrics} from "./context-budget-metrics.js"
|
|
18
|
+
import {isolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
|
|
19
|
+
import {validateRelayWriteProviderArguments} from "./relay-write.js"
|
|
20
|
+
import {abortable} from "./absolute-deadline.js"
|
|
21
|
+
import {NormalizedOutput} from "./normalized-output.js"
|
|
18
22
|
|
|
19
23
|
const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
|
|
20
24
|
[--process-number <positive-integer>] [--cwd <directory>]
|
|
21
25
|
[--workspace-profile <name>]
|
|
26
|
+
[--relay-write]
|
|
22
27
|
[--tool-messages] [--max-output-length <positive-integer>]
|
|
23
|
-
[--resume-session <provider-session-id>] [--
|
|
28
|
+
[--resume-session <provider-session-id>] [--transcript <normalized-jsonl-path>]
|
|
29
|
+
[--activity-log <local-jsonl-path>]
|
|
24
30
|
[--prompt <text> | --prompt-file <path> | stdin]
|
|
25
31
|
[-- <provider arguments...>]
|
|
26
32
|
threadwire evidence read --handle <opaque-handle>
|
|
27
33
|
(--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
|
|
28
34
|
|
|
29
|
-
/** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
|
|
35
|
+
/** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
|
|
30
36
|
/** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
|
|
31
37
|
/**
|
|
32
38
|
* @typedef {{
|
|
@@ -40,6 +46,7 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
|
|
|
40
46
|
* evidenceOwnerScope?: {destinationId: string, runId: string},
|
|
41
47
|
* workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
|
|
42
48
|
* workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations
|
|
49
|
+
* isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
|
|
43
50
|
* }} MainDependencies
|
|
44
51
|
*/
|
|
45
52
|
|
|
@@ -52,8 +59,9 @@ export function parseArguments(arguments_) {
|
|
|
52
59
|
const ownArguments = separator < 0 ? arguments_.slice(1) : arguments_.slice(1, separator)
|
|
53
60
|
const providerArguments = separator < 0 ? [] : arguments_.slice(separator + 1)
|
|
54
61
|
/** @type {Partial<ParsedArguments>} */
|
|
55
|
-
const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false}
|
|
62
|
+
const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false, relayWrite: false}
|
|
56
63
|
let cwdProvided = false
|
|
64
|
+
let transcriptProvided = false
|
|
57
65
|
for (let index = 0; index < ownArguments.length;) {
|
|
58
66
|
const option = ownArguments[index]
|
|
59
67
|
if (option === "--tool-messages") {
|
|
@@ -61,7 +69,15 @@ export function parseArguments(arguments_) {
|
|
|
61
69
|
index += 1
|
|
62
70
|
continue
|
|
63
71
|
}
|
|
72
|
+
if (option === "--relay-write") {
|
|
73
|
+
parsed.relayWrite = true
|
|
74
|
+
index += 1
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
64
77
|
const value = ownArguments[index + 1]
|
|
78
|
+
if (option === "--transcript" && (value === undefined || value.startsWith("--"))) {
|
|
79
|
+
throw new Error("Missing value for --transcript")
|
|
80
|
+
}
|
|
65
81
|
if (!option || !value) throw new Error(`Missing value for ${option ?? "option"}`)
|
|
66
82
|
if (option === "--provider") parsed.provider = value
|
|
67
83
|
else if (option === "--target") parsed.target = value
|
|
@@ -75,7 +91,12 @@ export function parseArguments(arguments_) {
|
|
|
75
91
|
else if (option === "--max-output-length") parsed.maxOutputLength = positiveInteger(value, "--max-output-length")
|
|
76
92
|
else if (option === "--resume-session") parsed.resumeSession = sessionId(value)
|
|
77
93
|
else if (option === "--activity-log") parsed.activityLog = resolve(value)
|
|
78
|
-
else
|
|
94
|
+
else if (option === "--transcript") {
|
|
95
|
+
if (transcriptProvided) throw new Error("Duplicate option: --transcript")
|
|
96
|
+
if (value.includes("\0")) throw new Error("--transcript path is invalid")
|
|
97
|
+
parsed.transcript = resolve(value)
|
|
98
|
+
transcriptProvided = true
|
|
99
|
+
} else throw new Error(`Unknown option: ${option}`)
|
|
79
100
|
index += 2
|
|
80
101
|
}
|
|
81
102
|
if (!parsed.provider) throw new Error("--provider is required")
|
|
@@ -87,6 +108,11 @@ export function parseArguments(arguments_) {
|
|
|
87
108
|
if (parsed.workspaceProfile !== undefined && cwdProvided) {
|
|
88
109
|
throw new Error("--cwd and --workspace-profile are mutually exclusive")
|
|
89
110
|
}
|
|
111
|
+
if (parsed.transcript !== undefined && parsed.transcript === parsed.activityLog) {
|
|
112
|
+
throw new Error("--transcript and --activity-log must resolve to different paths")
|
|
113
|
+
}
|
|
114
|
+
if (parsed.relayWrite && parsed.workspaceProfile === undefined) throw new Error("--relay-write requires --workspace-profile")
|
|
115
|
+
if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
|
|
90
116
|
return /** @type {ParsedArguments} */ (parsed)
|
|
91
117
|
}
|
|
92
118
|
|
|
@@ -140,6 +166,10 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
140
166
|
const sourceEnvironment = dependencies.env ?? process.env
|
|
141
167
|
let environment = sourceEnvironment
|
|
142
168
|
const validateOnly = environment.THREADWIRE_VALIDATE_ONLY === "1"
|
|
169
|
+
/** @type {NormalizedOutput | undefined} */
|
|
170
|
+
let normalizedOutput
|
|
171
|
+
/** @type {DelegatedResultAdmission | undefined} */
|
|
172
|
+
let runAdmission
|
|
143
173
|
try {
|
|
144
174
|
const parsed = arguments_[0] === "evidence" ? parseEvidenceArguments(arguments_) : parseArguments(arguments_)
|
|
145
175
|
if ("help" in parsed) {
|
|
@@ -159,7 +189,13 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
159
189
|
await store.close()
|
|
160
190
|
}
|
|
161
191
|
}
|
|
192
|
+
if (!validateOnly) {
|
|
193
|
+
normalizedOutput = new NormalizedOutput(output, parsed.transcript)
|
|
194
|
+
}
|
|
195
|
+
const metrics = new ContextBudgetMetrics()
|
|
196
|
+
runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
|
|
162
197
|
const target = parseTelegramTarget(parsed.target)
|
|
198
|
+
if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
|
|
163
199
|
const resolvedWorkspace = parsed.workspaceProfile === undefined
|
|
164
200
|
? undefined
|
|
165
201
|
: await resolveWorkspaceProfile(
|
|
@@ -167,8 +203,20 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
167
203
|
dependencies.workspaceProfileOperations
|
|
168
204
|
)
|
|
169
205
|
if (validateOnly) return 0
|
|
170
|
-
const
|
|
171
|
-
|
|
206
|
+
const isolatedRuntimeClient = parsed.relayWrite
|
|
207
|
+
? dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
|
|
208
|
+
: undefined
|
|
209
|
+
const isolatedPreflight = parsed.relayWrite
|
|
210
|
+
? await isolatedRuntimeClient?.preflight({
|
|
211
|
+
provider: parsed.provider,
|
|
212
|
+
profile: /** @type {string} */ (parsed.workspaceProfile),
|
|
213
|
+
repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
|
|
214
|
+
cwd: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).cwd,
|
|
215
|
+
providerArguments: parsed.providerArguments,
|
|
216
|
+
...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
|
|
217
|
+
})
|
|
218
|
+
: undefined
|
|
219
|
+
const admission = /** @type {DelegatedResultAdmission} */ (runAdmission)
|
|
172
220
|
let terminalExitCode = 2
|
|
173
221
|
/** @type {unknown} */
|
|
174
222
|
let evidenceError
|
|
@@ -179,12 +227,13 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
179
227
|
/** @type {EvidenceStore | undefined} */
|
|
180
228
|
let ownedEvidenceStore
|
|
181
229
|
let evidencePayloadBytes = 0
|
|
230
|
+
const launchDeadline = isolatedPreflight?.deadline
|
|
182
231
|
try {
|
|
183
|
-
environment = await resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"])
|
|
184
|
-
const prompt = await readPrompt(parsed, dependencies.input ?? stdin)
|
|
232
|
+
environment = await boundedLaunch(resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"]), launchDeadline)
|
|
233
|
+
const prompt = await readPrompt(parsed, dependencies.input ?? stdin, launchDeadline?.signal)
|
|
185
234
|
const configuredEvidenceRoot = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
|
|
186
235
|
if (dependencies.evidenceStore === undefined && configuredEvidenceRoot !== undefined) {
|
|
187
|
-
ownedEvidenceStore = await EvidenceStore.open({root: configuredEvidenceRoot})
|
|
236
|
+
ownedEvidenceStore = await boundedLaunch(EvidenceStore.open({root: configuredEvidenceRoot}), launchDeadline)
|
|
188
237
|
}
|
|
189
238
|
const evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
|
|
190
239
|
if (evidenceStore !== undefined) {
|
|
@@ -194,16 +243,22 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
194
243
|
})
|
|
195
244
|
evidence = await evidenceStore.createArtifact(evidenceOwner, {
|
|
196
245
|
contentType: "text/plain; charset=utf-8",
|
|
197
|
-
redactions: await collectEvidenceRedactions(environment)
|
|
246
|
+
redactions: await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
|
|
198
247
|
})
|
|
199
248
|
}
|
|
200
249
|
if (evidence !== undefined) {
|
|
201
250
|
const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
|
|
202
|
-
await evidence.append("prompt", promptEvidence)
|
|
251
|
+
await boundedLaunch(evidence.append("prompt", promptEvidence), launchDeadline)
|
|
203
252
|
evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
|
|
204
253
|
}
|
|
205
254
|
const providerEnvironment = buildProviderEnvironment(environment)
|
|
206
|
-
const provider = createProvider(
|
|
255
|
+
const provider = createProvider(
|
|
256
|
+
parsed.provider,
|
|
257
|
+
parsed.providerArguments,
|
|
258
|
+
prompt,
|
|
259
|
+
parsed.resumeSession,
|
|
260
|
+
parsed.relayWrite ? {} : providerEnvironment
|
|
261
|
+
)
|
|
207
262
|
if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
|
|
208
263
|
if (parsed.workspaceProfile !== undefined) {
|
|
209
264
|
if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
|
|
@@ -228,7 +283,8 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
228
283
|
resolvedWorkspace.sourceIdentity
|
|
229
284
|
)
|
|
230
285
|
}
|
|
231
|
-
|
|
286
|
+
/** @type {import("./run-worker.js").RunWorkerOptions} */
|
|
287
|
+
const workerOptions = {
|
|
232
288
|
executable: provider.executable,
|
|
233
289
|
arguments: provider.arguments,
|
|
234
290
|
cwd: resolvedWorkspace?.cwd ?? parsed.cwd,
|
|
@@ -265,8 +321,21 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
265
321
|
if (evidence === undefined) return undefined
|
|
266
322
|
return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
|
|
267
323
|
}
|
|
268
|
-
}
|
|
269
|
-
|
|
324
|
+
}
|
|
325
|
+
const exitCode = parsed.relayWrite
|
|
326
|
+
? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).run({
|
|
327
|
+
preflightId: /** @type {{preflightId: string}} */ (isolatedPreflight).preflightId,
|
|
328
|
+
prompt,
|
|
329
|
+
providerArguments: parsed.providerArguments,
|
|
330
|
+
...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession}),
|
|
331
|
+
onEvent: workerOptions.onEvent,
|
|
332
|
+
onRecord: /** @type {NonNullable<typeof workerOptions.onRecord>} */ (workerOptions.onRecord),
|
|
333
|
+
...(workerOptions.onStdoutChunk === undefined ? {} : {onStdoutChunk: workerOptions.onStdoutChunk}),
|
|
334
|
+
...(workerOptions.onStderrChunk === undefined ? {} : {onStderrChunk: workerOptions.onStderrChunk}),
|
|
335
|
+
...(launchDeadline === undefined ? {} : {deadline: launchDeadline})
|
|
336
|
+
})
|
|
337
|
+
: await (dependencies.workerRunner ?? runWorker)(workerOptions)
|
|
338
|
+
await boundedLaunch(control.close(), launchDeadline)
|
|
270
339
|
terminalExitCode = exitCode
|
|
271
340
|
} finally {
|
|
272
341
|
if (evidence !== undefined) {
|
|
@@ -289,13 +358,28 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
289
358
|
admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
|
|
290
359
|
errorOutput.write(`threadwire-context-metrics ${JSON.stringify(metrics.conciseDiagnostic())}\n`)
|
|
291
360
|
activity?.close()
|
|
292
|
-
await ownedEvidenceStore?.close()
|
|
361
|
+
await boundedLaunch(ownedEvidenceStore?.close(), launchDeadline).catch(() => {})
|
|
362
|
+
launchDeadline?.close()
|
|
293
363
|
}
|
|
294
364
|
if (evidenceError !== undefined) throw evidenceError
|
|
295
365
|
return terminalExitCode
|
|
296
366
|
} catch (error) {
|
|
297
|
-
|
|
367
|
+
let reportedError = error
|
|
368
|
+
if (runAdmission !== undefined && !runAdmission.completed) {
|
|
369
|
+
try {
|
|
370
|
+
runAdmission.complete({state: "failed", exitCode: 2})
|
|
371
|
+
} catch (transcriptError) {
|
|
372
|
+
reportedError = transcriptError
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
errorOutput.write(`threadwire: ${safeMessage(reportedError)}\n`)
|
|
298
376
|
return 2
|
|
377
|
+
} finally {
|
|
378
|
+
try {
|
|
379
|
+
normalizedOutput?.close()
|
|
380
|
+
} catch (error) {
|
|
381
|
+
errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
|
|
382
|
+
}
|
|
299
383
|
}
|
|
300
384
|
}
|
|
301
385
|
|
|
@@ -350,21 +434,36 @@ function validateNormalizedWorkerEvent(event) {
|
|
|
350
434
|
throw new Error("Worker emitted an unknown normalized event kind")
|
|
351
435
|
}
|
|
352
436
|
|
|
353
|
-
/** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input */
|
|
354
|
-
async function readPrompt(parsed, input) {
|
|
437
|
+
/** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input @param {AbortSignal | undefined} signal */
|
|
438
|
+
async function readPrompt(parsed, input, signal) {
|
|
355
439
|
let prompt
|
|
356
440
|
if (parsed.prompt !== undefined) prompt = parsed.prompt
|
|
357
|
-
else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, "utf8")
|
|
441
|
+
else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, {encoding: "utf8", signal})
|
|
358
442
|
else {
|
|
359
443
|
if (input.isTTY) throw new Error("A prompt is required via --prompt, --prompt-file, or piped stdin")
|
|
360
444
|
prompt = ""
|
|
361
445
|
input.setEncoding("utf8")
|
|
362
|
-
|
|
446
|
+
const abort = () => /** @type {NodeJS.ReadableStream & {destroy?: (error?: unknown) => void}} */ (input).destroy?.(signal?.reason)
|
|
447
|
+
signal?.addEventListener("abort", abort, {once: true})
|
|
448
|
+
try {
|
|
449
|
+
for await (const chunk of input) {
|
|
450
|
+
if (signal?.aborted) throw signal.reason
|
|
451
|
+
prompt += chunk
|
|
452
|
+
}
|
|
453
|
+
} finally {
|
|
454
|
+
signal?.removeEventListener("abort", abort)
|
|
455
|
+
}
|
|
363
456
|
}
|
|
364
457
|
if (prompt.trim().length === 0) throw new Error("Prompt cannot be empty")
|
|
365
458
|
return prompt
|
|
366
459
|
}
|
|
367
460
|
|
|
461
|
+
/** @param {Promise<unknown> | undefined} promise @param {import("./absolute-deadline.js").AbsoluteDeadline | undefined} deadline */
|
|
462
|
+
function boundedLaunch(promise, deadline) {
|
|
463
|
+
deadline?.throwIfAborted()
|
|
464
|
+
return abortable(Promise.resolve(promise), deadline?.signal)
|
|
465
|
+
}
|
|
466
|
+
|
|
368
467
|
/** @param {unknown} error */
|
|
369
468
|
function safeMessage(error) {
|
|
370
469
|
return error instanceof Error ? error.message : "Unknown error"
|
|
@@ -45,7 +45,7 @@ const STATES = new Set(["completed", "failed", "blocked", "needs_decision"])
|
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
47
|
export class DelegatedResultAdmission {
|
|
48
|
-
/** @param {{output:
|
|
48
|
+
/** @param {{output: {write: (chunk: string) => unknown}, metrics?: import("./context-budget-metrics.js").ContextBudgetMetrics}} options */
|
|
49
49
|
constructor(options) {
|
|
50
50
|
this.output = options.output
|
|
51
51
|
this.metrics = options.metrics
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/* eslint-disable jsdoc/require-jsdoc */
|
|
3
|
+
|
|
4
|
+
import {request as httpRequest} from "node:http"
|
|
5
|
+
|
|
6
|
+
export class DockerApi {
|
|
7
|
+
/** @param {{host?: string, requestImplementation?: typeof httpRequest}} [options] */
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.host = options.host ?? process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock"
|
|
10
|
+
this.requestImplementation = options.requestImplementation ?? httpRequest
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @param {string} method @param {string} path @param {unknown} [body] @param {boolean} [raw] */
|
|
14
|
+
request(method, path, body, raw = false, options = {}) {
|
|
15
|
+
const timeoutMs = options.timeoutMs ?? 30_000
|
|
16
|
+
const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
|
|
17
|
+
const target = dockerTarget(this.host, path)
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const request = this.requestImplementation({
|
|
20
|
+
...target,
|
|
21
|
+
method,
|
|
22
|
+
headers: payload === undefined ? {} : {"content-type": "application/json", "content-length": payload.length}
|
|
23
|
+
}, (response) => {
|
|
24
|
+
/** @type {Buffer[]} */
|
|
25
|
+
const chunks = []
|
|
26
|
+
let bytes = 0
|
|
27
|
+
response.on("data", (chunk) => {
|
|
28
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
29
|
+
bytes += buffer.length
|
|
30
|
+
if (bytes > 8_388_608) {
|
|
31
|
+
request.destroy(new Error("Docker response exceeded capacity"))
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
chunks.push(buffer)
|
|
35
|
+
})
|
|
36
|
+
response.on("end", () => {
|
|
37
|
+
const combined = Buffer.concat(chunks)
|
|
38
|
+
const content = combined.toString("utf8")
|
|
39
|
+
const status = response.statusCode ?? 500
|
|
40
|
+
if (status < 200 || status >= 300) {
|
|
41
|
+
reject(new Error(`Docker API ${method} ${path} failed (${status})`))
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (combined.length === 0) {
|
|
45
|
+
resolve(undefined)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (raw) {
|
|
49
|
+
resolve(combined)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
resolve(JSON.parse(content))
|
|
54
|
+
} catch {
|
|
55
|
+
resolve(content)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
request.on("error", reject)
|
|
60
|
+
const abort = () => request.destroy(options.signal.reason instanceof Error ? options.signal.reason : new Error("Docker request aborted"))
|
|
61
|
+
if (options.signal?.aborted) abort()
|
|
62
|
+
else options.signal?.addEventListener("abort", abort, {once: true})
|
|
63
|
+
const deadline = setTimeout(
|
|
64
|
+
() => request.destroy(new Error(`Docker API ${method} ${path} timed out`)),
|
|
65
|
+
timeoutMs
|
|
66
|
+
)
|
|
67
|
+
deadline.unref()
|
|
68
|
+
request.once("close", () => {
|
|
69
|
+
clearTimeout(deadline)
|
|
70
|
+
options.signal?.removeEventListener("abort", abort)
|
|
71
|
+
})
|
|
72
|
+
if (payload !== undefined) request.end(payload)
|
|
73
|
+
else request.end()
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
version(options) { return this.request("GET", "/version", undefined, false, options) }
|
|
78
|
+
inspectImage(image, options) { return this.request("GET", `/images/${encodeURIComponent(image)}/json`, undefined, false, options) }
|
|
79
|
+
createNetwork(name, labels = {}, options) {
|
|
80
|
+
return this.request("POST", "/networks/create", {
|
|
81
|
+
Name: name,
|
|
82
|
+
Internal: true,
|
|
83
|
+
CheckDuplicate: true,
|
|
84
|
+
EnableIPv6: false,
|
|
85
|
+
Labels: {"org.threadwire.owner": "isolated-runtime", ...labels}
|
|
86
|
+
}, false, options)
|
|
87
|
+
}
|
|
88
|
+
removeNetwork(id, options) { return this.request("DELETE", `/networks/${encodeURIComponent(id)}`, undefined, false, options) }
|
|
89
|
+
inspectNetwork(id, options) { return this.request("GET", `/networks/${encodeURIComponent(id)}`, undefined, false, options) }
|
|
90
|
+
connectNetwork(id, container, aliases = [], options) {
|
|
91
|
+
return this.request("POST", `/networks/${encodeURIComponent(id)}/connect`, {
|
|
92
|
+
Container: container,
|
|
93
|
+
EndpointConfig: {Aliases: aliases}
|
|
94
|
+
}, false, options)
|
|
95
|
+
}
|
|
96
|
+
disconnectNetwork(id, container, options) {
|
|
97
|
+
return this.request("POST", `/networks/${encodeURIComponent(id)}/disconnect`, {Container: container, Force: true}, false, options)
|
|
98
|
+
}
|
|
99
|
+
createContainer(name, spec, options) {
|
|
100
|
+
return this.request("POST", `/containers/create?name=${encodeURIComponent(name)}`, spec, false, options)
|
|
101
|
+
}
|
|
102
|
+
listContainers(labels = {}, all = true, options) {
|
|
103
|
+
const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
|
|
104
|
+
return this.request("GET", `/containers/json?all=${all ? 1 : 0}&filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
|
|
105
|
+
}
|
|
106
|
+
listNetworks(labels = {}, options) {
|
|
107
|
+
const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
|
|
108
|
+
return this.request("GET", `/networks?filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
|
|
109
|
+
}
|
|
110
|
+
startContainer(id, options) { return this.request("POST", `/containers/${encodeURIComponent(id)}/start`, undefined, false, options) }
|
|
111
|
+
waitContainer(id, options) { return this.request("POST", `/containers/${encodeURIComponent(id)}/wait?condition=not-running`, undefined, false, options) }
|
|
112
|
+
logs(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/logs?stdout=1&stderr=1`, undefined, true, options) }
|
|
113
|
+
inspectContainer(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/json`, undefined, false, options) }
|
|
114
|
+
removeContainer(id, options) { return this.request("DELETE", `/containers/${encodeURIComponent(id)}?force=1&v=1`, undefined, false, options) }
|
|
115
|
+
createVolume(name, labels = {}, options) {
|
|
116
|
+
return this.request("POST", "/volumes/create", {Name: name, Labels: labels}, false, options)
|
|
117
|
+
}
|
|
118
|
+
listVolumes(labels = {}, options) {
|
|
119
|
+
const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
|
|
120
|
+
return this.request("GET", `/volumes?filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
|
|
121
|
+
}
|
|
122
|
+
inspectVolume(name, options) { return this.request("GET", `/volumes/${encodeURIComponent(name)}`, undefined, false, options) }
|
|
123
|
+
removeVolume(name, options) { return this.request("DELETE", `/volumes/${encodeURIComponent(name)}?force=0`, undefined, false, options) }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function dockerTarget(host, path) {
|
|
127
|
+
const url = new URL(host)
|
|
128
|
+
if (url.protocol === "unix:") return {socketPath: url.pathname, path}
|
|
129
|
+
if (url.protocol !== "tcp:" && url.protocol !== "http:") throw new Error("DOCKER_HOST must use unix or tcp")
|
|
130
|
+
return {hostname: url.hostname, port: Number(url.port || "2375"), path}
|
|
131
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/* eslint-disable jsdoc/require-jsdoc */
|
|
3
|
+
|
|
4
|
+
import {deadlineAfter, abortable, readResponseCapped} from "./absolute-deadline.js"
|
|
5
|
+
|
|
6
|
+
const MAX_RESPONSE_BYTES = 2_097_152
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 3_660_000
|
|
8
|
+
const MAX_RAW_OUTPUT_BYTES = 1_048_576
|
|
9
|
+
|
|
10
|
+
export class IsolatedRuntimeClient {
|
|
11
|
+
/** @param {{url: string, controlToken: string, timeoutMs?: number | string, fetchImplementation?: typeof fetch}} options */
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.url = normalizedUrl(options.url)
|
|
14
|
+
this.controlToken = nonempty(options.controlToken, "isolated runtime control token")
|
|
15
|
+
this.fetch = options.fetchImplementation ?? fetch
|
|
16
|
+
this.timeoutMs = positiveDuration(options.timeoutMs, DEFAULT_TIMEOUT_MS)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{provider: string, profile: string, repositoryRoot: string, cwd: string, providerArguments: string[], resumeSession?: string, signal?: AbortSignal, deadline?: import("./absolute-deadline.js").AbsoluteDeadline}} request
|
|
21
|
+
* @returns {Promise<{preflightId: string, deadline?: import("./absolute-deadline.js").AbsoluteDeadline}>}
|
|
22
|
+
*/
|
|
23
|
+
async preflight(request) {
|
|
24
|
+
const deadline = request.deadline ?? deadlineAfter(this.timeoutMs, {signal: request.signal, timeoutMessage: "Isolated runtime launch timeout"})
|
|
25
|
+
const response = await this.request("/preflight", {...request, launchDeadlineAt: deadline.expiresAt}, deadline)
|
|
26
|
+
if (response.ok !== true || typeof response.preflightId !== "string") throw new Error("Isolated runtime preflight failed")
|
|
27
|
+
return {preflightId: response.preflightId, deadline}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{
|
|
32
|
+
* preflightId: string, prompt: string, providerArguments: string[],
|
|
33
|
+
* resumeSession?: string,
|
|
34
|
+
* signal?: AbortSignal,
|
|
35
|
+
* deadline?: import("./absolute-deadline.js").AbsoluteDeadline,
|
|
36
|
+
* onEvent: (event: import("./types.js").WorkerEvent) => void | Promise<void>,
|
|
37
|
+
* onRecord?: (record: unknown) => void | Promise<void>,
|
|
38
|
+
* onStdoutChunk?: (chunk: Buffer) => void | Promise<void>,
|
|
39
|
+
* onStderrChunk?: (chunk: Buffer) => void | Promise<void>
|
|
40
|
+
* }} options
|
|
41
|
+
*/
|
|
42
|
+
async run(options) {
|
|
43
|
+
const deadline = options.deadline ?? deadlineAfter(this.timeoutMs, {signal: options.signal, timeoutMessage: "Isolated runtime launch timeout"})
|
|
44
|
+
const response = await this.request("/run", {
|
|
45
|
+
preflightId: options.preflightId,
|
|
46
|
+
prompt: options.prompt,
|
|
47
|
+
providerArguments: options.providerArguments,
|
|
48
|
+
...(options.resumeSession === undefined ? {} : {resumeSession: options.resumeSession})
|
|
49
|
+
}, deadline)
|
|
50
|
+
if (!Array.isArray(response.records) || !Array.isArray(response.rawChunks) || !Number.isSafeInteger(response.exitCode)) throw new Error("Isolated runtime emitted an invalid response")
|
|
51
|
+
let rawBytes = 0
|
|
52
|
+
const decoded = response.rawChunks.map((item) => {
|
|
53
|
+
if (!isRecord(item) || Object.keys(item).sort().join(",") !== "channel,data"
|
|
54
|
+
|| (item.channel !== "stdout" && item.channel !== "stderr") || typeof item.data !== "string"
|
|
55
|
+
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(item.data)) {
|
|
56
|
+
throw new Error("Isolated runtime emitted an invalid response")
|
|
57
|
+
}
|
|
58
|
+
const data = Buffer.from(item.data, "base64")
|
|
59
|
+
rawBytes += data.length
|
|
60
|
+
if (rawBytes > MAX_RAW_OUTPUT_BYTES) throw new Error("Isolated runtime emitted an invalid response")
|
|
61
|
+
return {channel: item.channel, data}
|
|
62
|
+
})
|
|
63
|
+
for (const chunk of decoded) {
|
|
64
|
+
deadline.throwIfAborted()
|
|
65
|
+
const callback = chunk.channel === "stdout" ? options.onStdoutChunk : options.onStderrChunk
|
|
66
|
+
await abortable(callback?.(chunk.data), deadline.signal)
|
|
67
|
+
}
|
|
68
|
+
for (const record of response.records) {
|
|
69
|
+
deadline.throwIfAborted()
|
|
70
|
+
await abortable(options.onRecord?.(record), deadline.signal)
|
|
71
|
+
if (isWorkerEventEnvelope(record)) await abortable(options.onEvent(record.event), deadline.signal)
|
|
72
|
+
}
|
|
73
|
+
return /** @type {number} */ (response.exitCode)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @param {string} path @param {unknown} body */
|
|
77
|
+
async request(path, body, deadline) {
|
|
78
|
+
const signal = deadline.signal
|
|
79
|
+
const response = await abortable(this.fetch(`${this.url}${path}`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
redirect: "error",
|
|
82
|
+
headers: {
|
|
83
|
+
authorization: `Bearer ${this.controlToken}`,
|
|
84
|
+
"content-type": "application/json",
|
|
85
|
+
connection: "close",
|
|
86
|
+
"x-threadwire-launch-deadline": String(deadline.expiresAt)
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify(body),
|
|
89
|
+
signal
|
|
90
|
+
}), signal)
|
|
91
|
+
let bytes
|
|
92
|
+
try {
|
|
93
|
+
bytes = await readResponseCapped(response, MAX_RESPONSE_BYTES, signal)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof Error && error.message === "Response exceeded capacity") throw new Error("Isolated runtime response exceeded the configured capacity", {cause: error})
|
|
96
|
+
throw error
|
|
97
|
+
}
|
|
98
|
+
if (!response.ok) throw new Error(safeRemoteError(bytes))
|
|
99
|
+
try {
|
|
100
|
+
const value = JSON.parse(bytes.toString("utf8"))
|
|
101
|
+
if (!isRecord(value)) throw new Error()
|
|
102
|
+
return value
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error("Isolated runtime emitted an invalid response")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function positiveDuration(value, fallback) {
|
|
110
|
+
if (value === undefined) return fallback
|
|
111
|
+
const number = Number(value)
|
|
112
|
+
if (!Number.isSafeInteger(number) || number < 1 || number > 604_800_000) throw new Error("Invalid isolated runtime client timeout")
|
|
113
|
+
return number
|
|
114
|
+
}
|
|
115
|
+
/** @param {NodeJS.ProcessEnv} environment */
|
|
116
|
+
export function isolatedRuntimeClientFromEnvironment(environment) {
|
|
117
|
+
return new IsolatedRuntimeClient({
|
|
118
|
+
url: nonempty(environment.THREADWIRE_ISOLATED_RUNTIME_URL, "THREADWIRE_ISOLATED_RUNTIME_URL"),
|
|
119
|
+
controlToken: nonempty(environment.THREADWIRE_ISOLATED_RUNTIME_CONTROL_TOKEN, "THREADWIRE_ISOLATED_RUNTIME_CONTROL_TOKEN"),
|
|
120
|
+
timeoutMs: environment.THREADWIRE_ISOLATED_RUNTIME_CLIENT_TIMEOUT_MS
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizedUrl(value) {
|
|
125
|
+
const url = new URL(value)
|
|
126
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("THREADWIRE_ISOLATED_RUNTIME_URL must use HTTP or HTTPS")
|
|
127
|
+
url.pathname = url.pathname.replace(/\/+$/u, "")
|
|
128
|
+
url.search = ""
|
|
129
|
+
url.hash = ""
|
|
130
|
+
return url.toString().replace(/\/$/u, "")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function nonempty(value, name) {
|
|
134
|
+
if (typeof value !== "string" || value.trim().length === 0 || /[\r\n]/u.test(value)) throw new Error(`${name} is required`)
|
|
135
|
+
return value
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function safeRemoteError(bytes) {
|
|
139
|
+
const text = bytes.toString("utf8").trim()
|
|
140
|
+
return /^[\x20-\x7e]{1,200}$/u.test(text) ? text : "Isolated runtime request failed"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isRecord(value) {
|
|
144
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isWorkerEventEnvelope(value) {
|
|
148
|
+
return isRecord(value) && value.type === "worker-event" && isRecord(value.event)
|
|
149
|
+
}
|