threadwire 0.1.5 → 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.
Files changed (40) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +17 -4
  3. package/TELEGRAM-INGRESS.md +8 -2
  4. package/bin/isolated-runtime.js +5 -0
  5. package/bin/model-broker.js +5 -0
  6. package/docs/card-10520-plan.md +45 -0
  7. package/docs/container-runtime.md +3 -2
  8. package/docs/delegated-result-protocol.md +161 -0
  9. package/docs/evidence-artifacts.md +106 -0
  10. package/docs/isolated-provider-runtime.md +137 -0
  11. package/package.json +5 -1
  12. package/scripts/provider-shims/front-door.sh.template +18 -0
  13. package/scripts/verify-package.js +22 -1
  14. package/src/absolute-deadline.js +94 -0
  15. package/src/cli.js +362 -42
  16. package/src/context-budget-metrics.js +180 -0
  17. package/src/delegated-result-admission.js +377 -0
  18. package/src/docker-api.js +131 -0
  19. package/src/evidence-store.js +1472 -0
  20. package/src/isolated-runtime-client.js +149 -0
  21. package/src/isolated-runtime.js +982 -0
  22. package/src/isolated-state.js +409 -0
  23. package/src/isolated-worker.js +123 -0
  24. package/src/model-broker-policy.js +139 -0
  25. package/src/model-broker.js +313 -0
  26. package/src/mount-policy.js +28 -0
  27. package/src/normalized-output.js +68 -0
  28. package/src/notice-queue.js +22 -7
  29. package/src/providers/opencode.js +42 -18
  30. package/src/relay-write.js +44 -0
  31. package/src/relay.js +6 -6
  32. package/src/run-worker.js +158 -40
  33. package/src/telegram-ingress/command.js +16 -0
  34. package/src/telegram-ingress/config.js +96 -11
  35. package/src/telegram-ingress/core.js +201 -89
  36. package/src/telegram-ingress/http.js +17 -1
  37. package/src/telegram-webhook.js +22 -0
  38. package/src/types.js +2 -2
  39. package/src/worker-control.js +294 -0
  40. package/src/hermes-protocol.js +0 -126
@@ -0,0 +1,180 @@
1
+ // @ts-check
2
+
3
+ export const METRIC_COUNTER_LIMIT = 2_147_483_647
4
+ export const CONTEXT_GROWTH_METADATA_ALLOWANCE_BYTES = 256
5
+
6
+ const RAW_CLASSES = ["provider_stdout", "provider_stderr"]
7
+ const REJECTED_CLASSES = ["provider_stream", "stderr", "lifecycle_progress", "diagnostic_progress", "tool_progress"]
8
+ const PROGRESS_CLASSES = ["assistant", "lifecycle", "diagnostic", "tool"]
9
+ const ENVELOPE_FIELDS = [
10
+ "version", "type", "state", "exitCode", "conclusion", "blocker", "decisionRequest",
11
+ "continuationHandle", "artifactHandles", "references", "validationSummary"
12
+ ]
13
+
14
+ export class ContextBudgetMetrics {
15
+ constructor() {
16
+ this.streamBytes = 0
17
+ this.streamChunks = 0
18
+ this.streamBytesByClass = counters(RAW_CLASSES)
19
+ this.streamChunksByClass = counters(RAW_CLASSES)
20
+ this.parsedRecords = 0
21
+ this.parsedRecordsByClass = counters(RAW_CLASSES)
22
+ this.rejectedBytes = counters(REJECTED_CLASSES)
23
+ this.rejectedEvents = counters(REJECTED_CLASSES)
24
+ this.progress = progressCounters()
25
+ this.artifactBytes = 0
26
+ this.artifactHandles = 0
27
+ this.admittedBytes = 0
28
+ this.admittedTokens = 0
29
+ this.admittedEnvelopeFields = counters(ENVELOPE_FIELDS)
30
+ }
31
+
32
+ /** @param {string} metricClass @param {number} bytes */
33
+ recordRawChildChunk(metricClass, bytes) {
34
+ addClass(this.streamBytesByClass, metricClass, bytes)
35
+ addClass(this.streamChunksByClass, metricClass, 1)
36
+ this.streamBytes = saturatingAdd(this.streamBytes, bytes)
37
+ this.streamChunks = saturatingAdd(this.streamChunks, 1)
38
+ }
39
+
40
+ /** Backward-compatible name for callers outside the package. @param {string} metricClass @param {number} bytes */
41
+ recordRawChildEvent(metricClass, bytes) {
42
+ this.recordRawChildChunk(metricClass, bytes)
43
+ }
44
+
45
+ /** @param {string} metricClass @param {number} [records] */
46
+ recordParsedProviderRecord(metricClass, records = 1) {
47
+ addClass(this.parsedRecordsByClass, metricClass, records)
48
+ this.parsedRecords = saturatingAdd(this.parsedRecords, records)
49
+ }
50
+
51
+ /** @param {string} metricClass @param {number} bytes */
52
+ recordRejected(metricClass, bytes) {
53
+ addClass(this.rejectedBytes, metricClass, bytes)
54
+ addClass(this.rejectedEvents, metricClass, 1)
55
+ }
56
+
57
+ /** @param {string} metricClass @param {number} bytes */
58
+ recordProgress(metricClass, bytes, outcome = "delivered") {
59
+ if (!Object.prototype.hasOwnProperty.call(this.progress, outcome)) throw new Error("Invalid context progress outcome")
60
+ const target = this.progress[outcome]
61
+ if (target === undefined) throw new Error("Invalid context progress outcome")
62
+ addClass(target.bytesByClass, metricClass, bytes)
63
+ addClass(target.eventsByClass, metricClass, 1)
64
+ target.bytes = saturatingAdd(target.bytes, bytes)
65
+ target.events = saturatingAdd(target.events, 1)
66
+ }
67
+
68
+ /** @param {number} bytes @param {number} handles */
69
+ recordArtifact(bytes, handles) {
70
+ this.artifactBytes = saturatingAdd(this.artifactBytes, bytes)
71
+ this.artifactHandles = saturatingAdd(this.artifactHandles, handles)
72
+ }
73
+
74
+ /**
75
+ * Set the final logical payload size for an artifact containing this snapshot.
76
+ * The returned fixed point includes the snapshot's own newline-terminated bytes.
77
+ * @param {number} bytesBeforeMetrics @param {number} handles
78
+ */
79
+ projectSelfInclusiveArtifact(bytesBeforeMetrics, handles) {
80
+ const base = bounded(bytesBeforeMetrics)
81
+ this.artifactHandles = bounded(handles)
82
+ this.artifactBytes = base
83
+ for (let iteration = 0; iteration < 16; iteration += 1) {
84
+ const projected = saturatingAdd(base, Buffer.byteLength(`${JSON.stringify(this.snapshot())}\n`, "utf8"))
85
+ if (projected === this.artifactBytes) return projected
86
+ this.artifactBytes = projected
87
+ }
88
+ throw new Error("Context metrics artifact projection did not converge")
89
+ }
90
+
91
+ clearArtifact() {
92
+ this.artifactBytes = 0
93
+ this.artifactHandles = 0
94
+ }
95
+
96
+ /** @param {Record<string, unknown>} envelope */
97
+ recordAdmission(envelope) {
98
+ const serialized = `${JSON.stringify(envelope)}\n`
99
+ this.admittedBytes = bounded(Buffer.byteLength(serialized, "utf8"))
100
+ this.admittedTokens = bounded(Math.ceil(this.admittedBytes / 4))
101
+ for (const field of ENVELOPE_FIELDS) {
102
+ if (!Object.prototype.hasOwnProperty.call(envelope, field)) continue
103
+ this.admittedEnvelopeFields[field] = bounded(Buffer.byteLength(JSON.stringify(envelope[field]), "utf8"))
104
+ }
105
+ }
106
+
107
+ snapshot() {
108
+ return {
109
+ version: 1,
110
+ units: {bytes: "utf8", tokens: "estimated_ceiling_bytes_div_4", events: "count"},
111
+ rawChild: {
112
+ streamBytes: this.streamBytes,
113
+ streamChunks: this.streamChunks,
114
+ streamBytesByClass: {...this.streamBytesByClass},
115
+ streamChunksByClass: {...this.streamChunksByClass},
116
+ parsedRecords: this.parsedRecords,
117
+ parsedRecordsByClass: {...this.parsedRecordsByClass}
118
+ },
119
+ admitted: {bytes: this.admittedBytes, estimatedTokens: this.admittedTokens},
120
+ rejected: {bytesByClass: {...this.rejectedBytes}, eventsByClass: {...this.rejectedEvents}},
121
+ artifacts: {bytes: this.artifactBytes, handles: this.artifactHandles},
122
+ progress: Object.fromEntries(Object.entries(this.progress).map(([outcome, values]) => [
123
+ outcome,
124
+ {bytes: values.bytes, events: values.events, bytesByClass: {...values.bytesByClass}, eventsByClass: {...values.eventsByClass}}
125
+ ])),
126
+ admittedEnvelopeFields: {...this.admittedEnvelopeFields},
127
+ parentContextDeltaBytes: this.admittedBytes
128
+ }
129
+ }
130
+
131
+ conciseDiagnostic() {
132
+ return {
133
+ version: 1,
134
+ rawBytes: this.streamBytes,
135
+ rawChunks: this.streamChunks,
136
+ parsedRecords: this.parsedRecords,
137
+ admittedBytes: this.admittedBytes,
138
+ rejectedBytes: sumCounters(this.rejectedBytes),
139
+ artifactBytes: this.artifactBytes,
140
+ artifactHandles: this.artifactHandles,
141
+ progressEvents: this.progress.delivered?.events ?? 0,
142
+ parentContextDeltaBytes: this.admittedBytes
143
+ }
144
+ }
145
+ }
146
+
147
+ /** @returns {Record<string, {bytes: number, events: number, bytesByClass: Record<string, number>, eventsByClass: Record<string, number>}>} */
148
+ function progressCounters() {
149
+ return Object.fromEntries(["attempted", "suppressed", "coalesced", "delivered"].map((outcome) => [
150
+ outcome,
151
+ {bytes: 0, events: 0, bytesByClass: counters(PROGRESS_CLASSES), eventsByClass: counters(PROGRESS_CLASSES)}
152
+ ]))
153
+ }
154
+
155
+ /** @param {string[]} classes */
156
+ function counters(classes) {
157
+ return Object.fromEntries(classes.map((metricClass) => [metricClass, 0]))
158
+ }
159
+
160
+ /** @param {Record<string, number>} target @param {string} metricClass @param {number} increment */
161
+ function addClass(target, metricClass, increment) {
162
+ if (!Object.prototype.hasOwnProperty.call(target, metricClass)) throw new Error("Invalid context metric class")
163
+ target[metricClass] = saturatingAdd(target[metricClass] ?? 0, increment)
164
+ }
165
+
166
+ /** @param {number} current @param {number} increment */
167
+ function saturatingAdd(current, increment) {
168
+ return Math.min(METRIC_COUNTER_LIMIT, current + bounded(increment))
169
+ }
170
+
171
+ /** @param {number} value */
172
+ function bounded(value) {
173
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("Invalid context metric counter")
174
+ return Math.min(value, METRIC_COUNTER_LIMIT)
175
+ }
176
+
177
+ /** @param {Record<string, number>} values */
178
+ function sumCounters(values) {
179
+ return Object.values(values).reduce((total, value) => saturatingAdd(total, value), 0)
180
+ }
@@ -0,0 +1,377 @@
1
+ // @ts-check
2
+
3
+ import {types as utilTypes} from "node:util"
4
+ import {sanitizeOutput} from "./relay.js"
5
+
6
+ export const CONCLUSION_LIMIT = 16_384
7
+ export const BLOCKER_LIMIT = 1_024
8
+ export const DECISION_REQUEST_LIMIT = 1_024
9
+ export const CONTINUATION_HANDLE_LIMIT = 512
10
+ export const ARTIFACT_HANDLE_LIMIT = 512
11
+ export const REFERENCE_VALUE_LIMIT = 2_048
12
+ export const VALIDATION_SUMMARY_LIMIT = 2_048
13
+ export const MAX_ARTIFACT_HANDLES = 16
14
+ export const MAX_REFERENCES = 16
15
+ export const MAX_ENVELOPE_BYTES = 98_304
16
+ const MAX_VALIDATION_BUDGET = 256
17
+
18
+ const CANDIDATE_FIELDS = new Set([
19
+ "state",
20
+ "exitCode",
21
+ "conclusion",
22
+ "blocker",
23
+ "decisionRequest",
24
+ "continuationHandle",
25
+ "artifactHandles",
26
+ "references",
27
+ "validationSummary"
28
+ ])
29
+ const STATES = new Set(["completed", "failed", "blocked", "needs_decision"])
30
+
31
+ /**
32
+ * @typedef {"completed" | "failed" | "blocked" | "needs_decision"} TerminalState
33
+ * @typedef {{kind: "commit" | "url", value: string}} AdmissionReference
34
+ * @typedef {{
35
+ * state: TerminalState,
36
+ * exitCode: number,
37
+ * conclusion?: string,
38
+ * blocker?: string,
39
+ * decisionRequest?: string,
40
+ * continuationHandle?: string,
41
+ * artifactHandles?: string[],
42
+ * references?: AdmissionReference[],
43
+ * validationSummary?: string
44
+ * }} DelegatedResultCandidate
45
+ */
46
+
47
+ export class DelegatedResultAdmission {
48
+ /** @param {{output: {write: (chunk: string) => unknown}, metrics?: import("./context-budget-metrics.js").ContextBudgetMetrics}} options */
49
+ constructor(options) {
50
+ this.output = options.output
51
+ this.metrics = options.metrics
52
+ this.conclusion = ""
53
+ this.conclusionTruncated = false
54
+ /** @type {string | undefined} */
55
+ this.continuationHandle = undefined
56
+ /** @type {string[]} */
57
+ this.artifactHandles = []
58
+ /** @type {AdmissionReference[]} */
59
+ this.references = []
60
+ this.completed = false
61
+ }
62
+
63
+ /** @param {string} text */
64
+ appendConclusion(text) {
65
+ if (typeof text !== "string") throw new Error("Conclusion text must be a string")
66
+ if (this.conclusionTruncated) return
67
+ text = wellFormedString(text)
68
+ const remaining = CONCLUSION_LIMIT - Array.from(this.conclusion).length
69
+ const characters = Array.from(text)
70
+ if (characters.length <= remaining) this.conclusion += text
71
+ else {
72
+ this.conclusion += characters.slice(0, Math.max(0, remaining - 1)).join("") + "…"
73
+ this.conclusionTruncated = true
74
+ }
75
+ }
76
+
77
+ /** @param {unknown} event */
78
+ acceptConclusionEvent(event) {
79
+ if (!isPlainObject(event) || !hasExactEnumerableFields(event, ["type", "text", "streamId"])) {
80
+ throw new Error("Invalid admitted conclusion event")
81
+ }
82
+ if (event.type !== "text-delta" || typeof event.text !== "string" || typeof event.streamId !== "string") {
83
+ throw new Error("Invalid admitted conclusion event")
84
+ }
85
+ this.appendConclusion(event.text)
86
+ }
87
+
88
+ /** @param {string} handle */
89
+ setContinuationHandle(handle) {
90
+ this.continuationHandle = validateContinuationHandle(handle)
91
+ }
92
+
93
+ /** @param {string} handle */
94
+ addArtifactHandle(handle) {
95
+ this.artifactHandles = optionalArtifactHandles([...this.artifactHandles, handle]) ?? []
96
+ }
97
+
98
+ /** @param {string} handle */
99
+ removeArtifactHandle(handle) {
100
+ this.artifactHandles = this.artifactHandles.filter((candidate) => candidate !== handle)
101
+ }
102
+
103
+ /** @param {{state: TerminalState, exitCode: number}} terminal */
104
+ preview(terminal) {
105
+ return this.createEnvelope(terminal)
106
+ }
107
+
108
+ /** @param {AdmissionReference} reference */
109
+ addReference(reference) {
110
+ this.references = /** @type {AdmissionReference[]} */ (optionalReferences([...this.references, reference]) ?? [])
111
+ }
112
+
113
+ /** @param {{state: TerminalState, exitCode: number}} terminal */
114
+ complete(terminal) {
115
+ if (this.completed) throw new Error("Delegated result already completed")
116
+ const envelope = this.createEnvelope(terminal)
117
+ this.completed = true
118
+ this.metrics?.recordAdmission(envelope)
119
+ this.output.write(`${JSON.stringify(envelope)}\n`)
120
+ }
121
+
122
+ /** @param {{state: TerminalState, exitCode: number}} terminal */
123
+ createEnvelope(terminal) {
124
+ const safeConclusion = truncateBoundedString(sanitizeOutput(this.conclusion), CONCLUSION_LIMIT)
125
+ return createDelegatedResultEnvelope({
126
+ ...terminal,
127
+ ...(terminal.state !== "completed" || safeConclusion === undefined ? {} : {conclusion: safeConclusion}),
128
+ ...(this.continuationHandle === undefined ? {} : {continuationHandle: this.continuationHandle}),
129
+ ...(this.artifactHandles.length === 0 ? {} : {artifactHandles: this.artifactHandles}),
130
+ ...(this.references.length === 0 ? {} : {references: this.references})
131
+ })
132
+ }
133
+ }
134
+
135
+ /** @param {unknown} candidate */
136
+ export function createDelegatedResultEnvelope(candidate) {
137
+ const clonedCandidate = cloneTopLevelCandidate(candidate)
138
+ for (const field of Reflect.ownKeys(clonedCandidate)) {
139
+ if (typeof field !== "string" || !CANDIDATE_FIELDS.has(field) || !Object.prototype.propertyIsEnumerable.call(clonedCandidate, field)) {
140
+ throw new Error(`Unknown delegated result candidate field: ${String(field)}`)
141
+ }
142
+ }
143
+ if (typeof clonedCandidate.state !== "string" || !STATES.has(clonedCandidate.state)) {
144
+ throw new Error("Invalid delegated result terminal state")
145
+ }
146
+ if (!Number.isSafeInteger(clonedCandidate.exitCode) || /** @type {number} */ (clonedCandidate.exitCode) < 0) {
147
+ throw new Error("Invalid delegated result exitCode")
148
+ }
149
+ const conclusion = optionalBoundedString(clonedCandidate.conclusion, "conclusion", CONCLUSION_LIMIT)
150
+ const blocker = optionalBoundedString(clonedCandidate.blocker, "blocker", BLOCKER_LIMIT)
151
+ const decisionRequest = optionalBoundedString(clonedCandidate.decisionRequest, "decisionRequest", DECISION_REQUEST_LIMIT)
152
+ const continuationHandle = clonedCandidate.continuationHandle === undefined ? undefined : validateContinuationHandle(clonedCandidate.continuationHandle)
153
+ const validationSummary = optionalBoundedString(clonedCandidate.validationSummary, "validationSummary", VALIDATION_SUMMARY_LIMIT)
154
+ const artifactHandles = optionalArtifactHandles(clonedCandidate.artifactHandles)
155
+ const references = optionalReferences(clonedCandidate.references)
156
+
157
+ const envelope = {
158
+ version: 1,
159
+ type: "delegated_result",
160
+ state: /** @type {TerminalState} */ (clonedCandidate.state),
161
+ exitCode: /** @type {number} */ (clonedCandidate.exitCode),
162
+ ...(conclusion === undefined ? {} : {conclusion}),
163
+ ...(blocker === undefined ? {} : {blocker}),
164
+ ...(decisionRequest === undefined ? {} : {decisionRequest}),
165
+ ...(continuationHandle === undefined ? {} : {continuationHandle}),
166
+ ...(artifactHandles === undefined ? {} : {artifactHandles}),
167
+ ...(references === undefined ? {} : {references}),
168
+ ...(validationSummary === undefined ? {} : {validationSummary})
169
+ }
170
+ if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > MAX_ENVELOPE_BYTES) {
171
+ throw new Error("Delegated result candidate exceeds the envelope byte limit")
172
+ }
173
+ return envelope
174
+ }
175
+
176
+ /** @param {unknown} value @param {string} field @param {number} limit */
177
+ function optionalBoundedString(value, field, limit) {
178
+ return value === undefined ? undefined : boundedString(value, field, limit)
179
+ }
180
+
181
+ /** @param {unknown} value @param {string} field @param {number} limit */
182
+ function boundedString(value, field, limit) {
183
+ if (typeof value !== "string" || value.length === 0 || wellFormedString(value) !== value || Array.from(value).length > limit) {
184
+ throw new Error(`Invalid delegated result ${field}`)
185
+ }
186
+ return value
187
+ }
188
+
189
+ /** @param {string | undefined} value @param {number} limit */
190
+ function truncateBoundedString(value, limit) {
191
+ if (value === undefined) return undefined
192
+ const characters = Array.from(value)
193
+ if (characters.length <= limit) return value
194
+ if (limit <= 1) return "…"
195
+ return `${characters.slice(0, limit - 1).join("")}…`
196
+ }
197
+
198
+ /** @param {unknown} value */
199
+ export function validateContinuationHandle(value) {
200
+ const handle = boundedString(value, "continuationHandle", CONTINUATION_HANDLE_LIMIT)
201
+ if (
202
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*(?::[A-Za-z0-9][A-Za-z0-9._-]*)*$/u.test(handle)
203
+ || /^(?:data|file|ftp|ftps|http|https|javascript|mailto|sftp|ssh|urn|ws|wss):/iu.test(handle)
204
+ ) {
205
+ throw new Error("Invalid delegated result continuationHandle")
206
+ }
207
+ return handle
208
+ }
209
+
210
+ /** @param {unknown} value */
211
+ function optionalArtifactHandles(value) {
212
+ if (value === undefined) return undefined
213
+ return validateClosedArray(value, "artifactHandles", MAX_ARTIFACT_HANDLES).map((item) => artifactHandle(item))
214
+ }
215
+
216
+ /** @param {unknown} value */
217
+ function artifactHandle(value) {
218
+ const handle = boundedString(value, "artifactHandles", ARTIFACT_HANDLE_LIMIT)
219
+ if (!/^\/?[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(handle)) {
220
+ throw new Error("Invalid delegated result artifactHandles")
221
+ }
222
+ const components = handle.split("/").filter((component) => component.length > 0)
223
+ if (components.some((component) => component === "." || component === "..")) {
224
+ throw new Error("Invalid delegated result artifactHandles")
225
+ }
226
+ return handle
227
+ }
228
+
229
+ /** @param {unknown} value */
230
+ function optionalReferences(value) {
231
+ if (value === undefined) return undefined
232
+ return validateClosedArray(value, "references", MAX_REFERENCES).map((reference) => {
233
+ if (!isPlainObject(reference) || !hasExactEnumerableFields(reference, ["kind", "value"])) {
234
+ throw new Error("Invalid delegated result reference")
235
+ }
236
+ if (reference.kind !== "commit" && reference.kind !== "url") throw new Error("Invalid delegated result reference kind")
237
+ const referenceValue = boundedString(reference.value, "references", REFERENCE_VALUE_LIMIT)
238
+ if (reference.kind === "commit" && !/^[0-9a-f]{7,64}$/iu.test(referenceValue)) {
239
+ throw new Error("Invalid delegated result commit reference")
240
+ }
241
+ if (reference.kind === "url") validateUrlReference(referenceValue)
242
+ return {kind: reference.kind, value: referenceValue}
243
+ })
244
+ }
245
+
246
+ /**
247
+ * @param {unknown} value
248
+ * @param {"artifactHandles" | "references"} field
249
+ * @param {number} limit
250
+ * @returns {unknown[]}
251
+ */
252
+ function validateClosedArray(value, field, limit) {
253
+ if (!Array.isArray(value) || value.length === 0 || value.length > limit) {
254
+ throw new Error(`Invalid delegated result ${field}`)
255
+ }
256
+ const keys = Reflect.ownKeys(value)
257
+ if (keys.length !== value.length + 1) throw new Error(`Invalid delegated result ${field}`)
258
+ for (let index = 0; index < value.length; index += 1) {
259
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
260
+ if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true) {
261
+ throw new Error(`Invalid delegated result ${field}`)
262
+ }
263
+ }
264
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length")
265
+ if (
266
+ !lengthDescriptor
267
+ || !("value" in lengthDescriptor)
268
+ || lengthDescriptor.value !== value.length
269
+ || lengthDescriptor.enumerable !== false
270
+ ) throw new Error(`Invalid delegated result ${field}`)
271
+ if (keys.some((key) => (
272
+ key !== "length"
273
+ && (typeof key !== "string" || !/^(?:0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length)
274
+ ))) throw new Error(`Invalid delegated result ${field}`)
275
+ return value
276
+ }
277
+
278
+ /** @param {string} value */
279
+ function validateUrlReference(value) {
280
+ if (Array.from(value).some((character) => {
281
+ const codePoint = character.codePointAt(0) ?? 0
282
+ return codePoint <= 31 || codePoint === 127 || /\s/u.test(character)
283
+ })) throw new Error("Invalid delegated result URL reference")
284
+ let parsed
285
+ try {
286
+ parsed = new URL(value)
287
+ } catch {
288
+ throw new Error("Invalid delegated result URL reference")
289
+ }
290
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.username.length > 0 || parsed.password.length > 0) {
291
+ throw new Error("Invalid delegated result URL reference")
292
+ }
293
+ }
294
+
295
+ /** @param {unknown} candidate @returns {DelegatedResultCandidate} */
296
+ function cloneTopLevelCandidate(candidate) {
297
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
298
+ throw new Error("Delegated result candidate must be a plain object")
299
+ }
300
+ if (utilTypes.isProxy(candidate)) throw new Error("Delegated result candidate must not be a proxy")
301
+ if (!isPlainObject(candidate)) throw new Error("Delegated result candidate must be a plain object")
302
+ return /** @type {DelegatedResultCandidate} */ (cloneWithinBudget(candidate, {remaining: MAX_VALIDATION_BUDGET, seen: new WeakSet()}))
303
+ }
304
+
305
+ /**
306
+ * @param {unknown} value
307
+ * @param {{remaining: number, seen: WeakSet<object>}} state
308
+ * @returns {unknown}
309
+ */
310
+ function cloneWithinBudget(value, state) {
311
+ state.remaining -= 1
312
+ if (state.remaining < 0) throw new Error("Delegated result candidate exceeds the validation budget")
313
+ if (value === null || typeof value !== "object") return value
314
+ if (utilTypes.isProxy(value)) throw new Error("Delegated result candidate must not contain proxy values")
315
+ if (!Array.isArray(value) && !isPlainObject(value)) return value
316
+ if (state.seen.has(value)) throw new Error("Delegated result candidate must not contain cycles")
317
+ state.seen.add(value)
318
+ try {
319
+ return cloneConcreteContainer(value, state)
320
+ } finally {
321
+ state.seen.delete(value)
322
+ }
323
+ }
324
+
325
+ /**
326
+ * @param {Record<PropertyKey, unknown> | unknown[]} value
327
+ * @param {{remaining: number, seen: WeakSet<object>}} state
328
+ */
329
+ function cloneConcreteContainer(value, state) {
330
+ const clone = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value))
331
+ for (const key of Reflect.ownKeys(value)) {
332
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
333
+ if (!descriptor) continue
334
+ if ("get" in descriptor || "set" in descriptor) {
335
+ throw new Error("Delegated result candidate must not contain accessor descriptors")
336
+ }
337
+ Object.defineProperty(clone, key, {
338
+ ...descriptor,
339
+ value: Array.isArray(value) && key === "length" ? descriptor.value : cloneWithinBudget(descriptor.value, state)
340
+ })
341
+ }
342
+ return clone
343
+ }
344
+
345
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
346
+ function isPlainObject(value) {
347
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
348
+ const prototype = Object.getPrototypeOf(value)
349
+ return prototype === Object.prototype || prototype === null
350
+ }
351
+
352
+ /** @param {Record<string, unknown>} value @param {string[]} expected */
353
+ function hasExactEnumerableFields(value, expected) {
354
+ const fields = Reflect.ownKeys(value)
355
+ return fields.length === expected.length && fields.every((field) => (
356
+ typeof field === "string"
357
+ && expected.includes(field)
358
+ && Object.prototype.propertyIsEnumerable.call(value, field)
359
+ ))
360
+ }
361
+
362
+ /** @param {string} value */
363
+ function wellFormedString(value) {
364
+ let result = ""
365
+ for (let index = 0; index < value.length; index += 1) {
366
+ const codeUnit = value.charCodeAt(index)
367
+ if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) {
368
+ const next = value.charCodeAt(index + 1)
369
+ if (next >= 0xDC00 && next <= 0xDFFF) {
370
+ result += value.slice(index, index + 2)
371
+ index += 1
372
+ } else result += "\uFFFD"
373
+ } else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) result += "\uFFFD"
374
+ else result += value.slice(index, index + 1)
375
+ }
376
+ return result
377
+ }
@@ -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
+ }