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.
- package/CHANGELOG.md +40 -0
- package/README.md +17 -4
- package/TELEGRAM-INGRESS.md +8 -2
- package/bin/isolated-runtime.js +5 -0
- package/bin/model-broker.js +5 -0
- package/docs/card-10520-plan.md +45 -0
- package/docs/container-runtime.md +3 -2
- package/docs/delegated-result-protocol.md +161 -0
- package/docs/evidence-artifacts.md +106 -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 +22 -1
- package/src/absolute-deadline.js +94 -0
- package/src/cli.js +362 -42
- package/src/context-budget-metrics.js +180 -0
- package/src/delegated-result-admission.js +377 -0
- package/src/docker-api.js +131 -0
- package/src/evidence-store.js +1472 -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/notice-queue.js +22 -7
- package/src/providers/opencode.js +42 -18
- package/src/relay-write.js +44 -0
- package/src/relay.js +6 -6
- package/src/run-worker.js +158 -40
- package/src/telegram-ingress/command.js +16 -0
- package/src/telegram-ingress/config.js +96 -11
- package/src/telegram-ingress/core.js +201 -89
- package/src/telegram-ingress/http.js +17 -1
- package/src/telegram-webhook.js +22 -0
- package/src/types.js +2 -2
- package/src/worker-control.js +294 -0
- package/src/hermes-protocol.js +0 -126
package/src/relay.js
CHANGED
|
@@ -45,7 +45,7 @@ export class Relay {
|
|
|
45
45
|
return
|
|
46
46
|
}
|
|
47
47
|
const text = this.renderActivity(event)
|
|
48
|
-
if (text) this.queue.enqueue({text, label: `${this.label}
|
|
48
|
+
if (text) this.queue.enqueue({text, label: `${this.label} `, metricClass: event.type})
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
@@ -60,7 +60,7 @@ export class Relay {
|
|
|
60
60
|
if (event.phase === "started") {
|
|
61
61
|
const description = this.toolDescription(event)
|
|
62
62
|
this.activeToolDescriptions.set(event.key, {description, hasDetail: event.detail !== undefined && event.detail.length > 0})
|
|
63
|
-
this.queue.enqueue({activity: {key: event.key, phase: "started"}, text: `🛠 ${this.label} Tool: ${description}
|
|
63
|
+
this.queue.enqueue({activity: {key: event.key, phase: "started"}, text: `🛠 ${this.label} Tool: ${description}`, metricClass: "tool"})
|
|
64
64
|
return
|
|
65
65
|
}
|
|
66
66
|
const stored = this.activeToolDescriptions.get(event.key)
|
|
@@ -71,10 +71,10 @@ export class Relay {
|
|
|
71
71
|
const status = `✅ ${this.label} Tool: ${description}`
|
|
72
72
|
const html = renderCompletedOutput(status, event.output, this.outputMessageLimit())
|
|
73
73
|
if (html === undefined) {
|
|
74
|
-
this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: status})
|
|
74
|
+
this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: status, metricClass: "tool"})
|
|
75
75
|
return
|
|
76
76
|
}
|
|
77
|
-
this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: html, parseMode: "HTML"})
|
|
77
|
+
this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: html, parseMode: "HTML", metricClass: "tool"})
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
@@ -165,10 +165,10 @@ export class Relay {
|
|
|
165
165
|
|
|
166
166
|
/** @param {string} text */
|
|
167
167
|
enqueueAssistant(text) {
|
|
168
|
-
if (this.inJavaScriptFence) this.queue.enqueue({text, format: "javascript-code", label: `${this.label} Assistant
|
|
168
|
+
if (this.inJavaScriptFence) this.queue.enqueue({text, format: "javascript-code", label: `${this.label} Assistant:`, metricClass: "assistant"})
|
|
169
169
|
else {
|
|
170
170
|
const label = `${this.label} Assistant: `
|
|
171
|
-
this.queue.enqueue({text: `${label}${text.replaceAll(/(\r\n|[\n\r\u2028\u2029])(?=.)/gu, `$1${label}`)}`, label, batchSeparator: ""})
|
|
171
|
+
this.queue.enqueue({text: `${label}${text.replaceAll(/(\r\n|[\n\r\u2028\u2029])(?=.)/gu, `$1${label}`)}`, label, batchSeparator: "", metricClass: "assistant"})
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
package/src/run-worker.js
CHANGED
|
@@ -6,17 +6,25 @@ import {StringDecoder} from "node:string_decoder"
|
|
|
6
6
|
import {buildProviderEnvironment} from "./telegram-ingress/config.js"
|
|
7
7
|
|
|
8
8
|
const DEFAULT_MAX_STDOUT_RECORD_BYTES = 1_048_576
|
|
9
|
+
const DEFAULT_TERMINATION_GRACE_PERIOD_MS = 5_000
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
* @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, parse: (record: unknown) => import("./types.js").WorkerEvent[], onEvent: (event: import("./types.js").WorkerEvent) => void
|
|
12
|
+
* @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, parse: (record: unknown) => import("./types.js").WorkerEvent[], onEvent: (event: import("./types.js").WorkerEvent) => void | Promise<void>, onSpawn?: (pid: number) => void, onRecord?: (record: unknown) => void | Promise<void>, onStdoutChunk?: (chunk: Buffer) => void | Promise<void>, onStderrChunk?: (chunk: Buffer) => void | Promise<void>, spawnImplementation?: typeof nodeSpawn, maxStdoutRecordBytes?: number, terminationGracePeriodMs?: number, setTimer?: (callback: () => void, milliseconds: number) => unknown, clearTimer?: (handle: unknown) => void, platform?: NodeJS.Platform, killProcess?: (pid: number, signal: NodeJS.Signals) => boolean}} RunWorkerOptions
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
/** @param {RunWorkerOptions} options @returns {Promise<number>} */
|
|
15
16
|
export function runWorker(options) {
|
|
16
17
|
const maxStdoutRecordBytes = positiveSafeInteger(options.maxStdoutRecordBytes ?? DEFAULT_MAX_STDOUT_RECORD_BYTES, "maxStdoutRecordBytes")
|
|
18
|
+
const terminationGracePeriodMs = positiveSafeInteger(options.terminationGracePeriodMs ?? DEFAULT_TERMINATION_GRACE_PERIOD_MS, "terminationGracePeriodMs")
|
|
19
|
+
const platform = options.platform ?? process.platform
|
|
17
20
|
const spawnImplementation = options.spawnImplementation ?? nodeSpawn
|
|
21
|
+
const killProcess = options.killProcess ?? ((pid, signal) => process.kill(pid, signal))
|
|
22
|
+
const setTimer = options.setTimer ?? ((callback, milliseconds) => setTimeout(callback, milliseconds))
|
|
23
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(/** @type {ReturnType<typeof setTimeout>} */ (handle)))
|
|
24
|
+
const useDetachedProcessGroup = platform !== "win32"
|
|
18
25
|
const child = spawnImplementation(options.executable, options.arguments, {
|
|
19
26
|
cwd: options.cwd,
|
|
27
|
+
detached: useDetachedProcessGroup,
|
|
20
28
|
env: childEnvironment(options.environment ?? process.env),
|
|
21
29
|
shell: false,
|
|
22
30
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -28,48 +36,124 @@ export function runWorker(options) {
|
|
|
28
36
|
|
|
29
37
|
return new Promise((resolve, reject) => {
|
|
30
38
|
let settled = false
|
|
39
|
+
/** @type {number | null} */
|
|
40
|
+
let closeCode = null
|
|
41
|
+
/** @type {NodeJS.Signals | null} */
|
|
42
|
+
let closeSignal = null
|
|
43
|
+
let closeSeen = false
|
|
44
|
+
/** @type {Promise<void>} */
|
|
45
|
+
let consumer = Promise.resolve()
|
|
46
|
+
/** @type {Error | null} */
|
|
47
|
+
let shutdownError = null
|
|
48
|
+
/** @type {unknown | null} */
|
|
49
|
+
let terminationTimer = null
|
|
50
|
+
const processGroupPid = useDetachedProcessGroup && isPositiveSafeInteger(child.pid) ? child.pid : null
|
|
31
51
|
/** @param {NodeJS.Signals} signal */
|
|
32
|
-
const forwardSignal = (signal) => child
|
|
52
|
+
const forwardSignal = (signal) => signalChildTree(child, processGroupPid, signal, killProcess)
|
|
33
53
|
const onInterrupt = () => forwardSignal("SIGINT")
|
|
34
54
|
const onTerminate = () => forwardSignal("SIGTERM")
|
|
35
|
-
|
|
36
|
-
|
|
55
|
+
const clearTerminationTimer = () => {
|
|
56
|
+
if (terminationTimer === null) return
|
|
57
|
+
clearTimer(terminationTimer)
|
|
58
|
+
terminationTimer = null
|
|
59
|
+
}
|
|
60
|
+
const detachForShutdown = () => {
|
|
61
|
+
child.stdout?.removeListener("data", onStdout)
|
|
62
|
+
child.stderr?.removeListener("data", onStderr)
|
|
63
|
+
child.removeListener("spawn", onSpawn)
|
|
64
|
+
child.removeListener("error", onError)
|
|
65
|
+
removeSignalHandlers(onInterrupt, onTerminate)
|
|
66
|
+
}
|
|
67
|
+
const cleanup = () => {
|
|
68
|
+
detachForShutdown()
|
|
69
|
+
clearTerminationTimer()
|
|
70
|
+
child.removeListener("close", onClose)
|
|
71
|
+
}
|
|
72
|
+
/** @param {Error} error */
|
|
73
|
+
const rejectAndCleanup = (error) => {
|
|
37
74
|
if (settled) return
|
|
38
75
|
settled = true
|
|
39
76
|
cleanup()
|
|
40
|
-
if (terminateChild) {
|
|
41
|
-
try { child.kill("SIGTERM") } catch { /* Child may already be gone. */ }
|
|
42
|
-
}
|
|
43
77
|
reject(error)
|
|
44
78
|
}
|
|
79
|
+
/** @param {Error} error @param {boolean} [terminateChild] */
|
|
80
|
+
const fail = (error, terminateChild = true) => {
|
|
81
|
+
if (settled || shutdownError) return
|
|
82
|
+
if (!terminateChild) {
|
|
83
|
+
rejectAndCleanup(error)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
if (closeSeen) {
|
|
87
|
+
if (processGroupPid === null) {
|
|
88
|
+
try {
|
|
89
|
+
forwardSignal("SIGTERM")
|
|
90
|
+
} catch {
|
|
91
|
+
/* Child may already be gone. */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
rejectAndCleanup(error)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
shutdownError = error
|
|
98
|
+
detachForShutdown()
|
|
99
|
+
try {
|
|
100
|
+
forwardSignal("SIGTERM")
|
|
101
|
+
} catch {
|
|
102
|
+
/* Child may already be gone. */
|
|
103
|
+
}
|
|
104
|
+
terminationTimer = setTimer(() => {
|
|
105
|
+
if (settled || shutdownError === null) return
|
|
106
|
+
terminationTimer = null
|
|
107
|
+
try {
|
|
108
|
+
forwardSignal("SIGKILL")
|
|
109
|
+
} catch {
|
|
110
|
+
/* Child may already be gone. */
|
|
111
|
+
}
|
|
112
|
+
rejectAndCleanup(shutdownError)
|
|
113
|
+
}, terminationGracePeriodMs)
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* @param {() => Promise<void>} task
|
|
117
|
+
* @param {NodeJS.ReadableStream | undefined} stream
|
|
118
|
+
*/
|
|
119
|
+
const enqueueConsumer = (task, stream) => {
|
|
120
|
+
stream?.pause()
|
|
121
|
+
consumer = consumer
|
|
122
|
+
.then(async () => {
|
|
123
|
+
if (settled || shutdownError) return
|
|
124
|
+
await task()
|
|
125
|
+
})
|
|
126
|
+
.then(() => {
|
|
127
|
+
stream?.resume()
|
|
128
|
+
if (closeSeen) void finalizeClose()
|
|
129
|
+
})
|
|
130
|
+
.catch((error) => {
|
|
131
|
+
fail(toError(error))
|
|
132
|
+
})
|
|
133
|
+
}
|
|
45
134
|
/** @param {Buffer | string} chunk */
|
|
46
135
|
const onStdout = (chunk) => {
|
|
47
|
-
if (settled) return
|
|
48
|
-
|
|
136
|
+
if (settled || shutdownError) return
|
|
137
|
+
enqueueConsumer(async () => {
|
|
49
138
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
139
|
+
await options.onStdoutChunk?.(bytes)
|
|
50
140
|
stdoutRecordBytes = nextRecordByteCount(bytes, stdoutRecordBytes, maxStdoutRecordBytes)
|
|
51
141
|
stdoutBuffer += stdoutDecoder.write(bytes)
|
|
52
|
-
stdoutBuffer = drainLines(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
|
|
53
|
-
}
|
|
54
|
-
fail(toError(error))
|
|
55
|
-
}
|
|
142
|
+
stdoutBuffer = await drainLines(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
|
|
143
|
+
}, child.stdout)
|
|
56
144
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
child.removeListener("spawn", onSpawn)
|
|
70
|
-
child.removeListener("error", onError)
|
|
71
|
-
child.removeListener("close", onClose)
|
|
72
|
-
removeSignalHandlers(onInterrupt, onTerminate)
|
|
145
|
+
/** @param {Buffer | string} chunk */
|
|
146
|
+
const onStderr = (chunk) => {
|
|
147
|
+
if (settled || shutdownError) return
|
|
148
|
+
enqueueConsumer(
|
|
149
|
+
async () => {
|
|
150
|
+
await options.onStderrChunk?.(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
151
|
+
if (stderrSeen) return
|
|
152
|
+
stderrSeen = true
|
|
153
|
+
await options.onEvent({type: "diagnostic", level: "warning", summary: "Worker wrote diagnostics to stderr"})
|
|
154
|
+
},
|
|
155
|
+
child.stderr
|
|
156
|
+
)
|
|
73
157
|
}
|
|
74
158
|
/** @param {Error} error */
|
|
75
159
|
const onError = (error) => fail(error, false)
|
|
@@ -82,19 +166,36 @@ export function runWorker(options) {
|
|
|
82
166
|
fail(toError(error))
|
|
83
167
|
}
|
|
84
168
|
}
|
|
85
|
-
|
|
86
|
-
const onClose = (code, signal) => {
|
|
169
|
+
const finalizeClose = async () => {
|
|
87
170
|
if (settled) return
|
|
171
|
+
if (shutdownError) {
|
|
172
|
+
settled = true
|
|
173
|
+
cleanup()
|
|
174
|
+
reject(shutdownError)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
88
177
|
try {
|
|
178
|
+
await consumer
|
|
179
|
+
if (settled) return
|
|
89
180
|
stdoutBuffer += stdoutDecoder.end()
|
|
90
|
-
if (stdoutBuffer.trim().length > 0) parseLine(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
|
|
181
|
+
if (stdoutBuffer.trim().length > 0) await parseLine(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
|
|
91
182
|
} catch (error) {
|
|
92
183
|
fail(toError(error), false)
|
|
93
184
|
return
|
|
94
185
|
}
|
|
95
186
|
settled = true
|
|
96
187
|
cleanup()
|
|
97
|
-
resolve(
|
|
188
|
+
resolve(closeCode ?? signalExitCode(closeSignal))
|
|
189
|
+
}
|
|
190
|
+
/** @param {number | null} code @param {NodeJS.Signals | null} signal */
|
|
191
|
+
const onClose = (code, signal) => {
|
|
192
|
+
if (settled) return
|
|
193
|
+
closeSeen = true
|
|
194
|
+
removeSignalHandlers(onInterrupt, onTerminate)
|
|
195
|
+
closeCode = code
|
|
196
|
+
closeSignal = signal
|
|
197
|
+
clearTerminationTimer()
|
|
198
|
+
void finalizeClose()
|
|
98
199
|
}
|
|
99
200
|
child.stdout?.on("data", onStdout)
|
|
100
201
|
child.stderr?.on("data", onStderr)
|
|
@@ -125,26 +226,32 @@ export function childEnvironment(environment = process.env) {
|
|
|
125
226
|
}
|
|
126
227
|
|
|
127
228
|
/** @param {string} buffer @param {RunWorkerOptions["parse"]} parse @param {RunWorkerOptions["onEvent"]} onEvent @param {RunWorkerOptions["onRecord"]} onRecord */
|
|
128
|
-
function drainLines(buffer, parse, onEvent, onRecord) {
|
|
229
|
+
async function drainLines(buffer, parse, onEvent, onRecord) {
|
|
129
230
|
const lines = buffer.split("\n")
|
|
130
231
|
const tail = lines.pop() ?? ""
|
|
131
|
-
for (const line of lines) parseLine(line, parse, onEvent, onRecord)
|
|
232
|
+
for (const line of lines) await parseLine(line, parse, onEvent, onRecord)
|
|
132
233
|
return tail
|
|
133
234
|
}
|
|
134
235
|
|
|
135
236
|
/** @param {string} line @param {RunWorkerOptions["parse"]} parse @param {RunWorkerOptions["onEvent"]} onEvent @param {RunWorkerOptions["onRecord"]} onRecord */
|
|
136
|
-
function parseLine(line, parse, onEvent, onRecord) {
|
|
237
|
+
async function parseLine(line, parse, onEvent, onRecord) {
|
|
137
238
|
if (line.trim().length === 0) return
|
|
239
|
+
let record
|
|
240
|
+
try {
|
|
241
|
+
record = JSON.parse(line)
|
|
242
|
+
} catch {
|
|
243
|
+
await onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
await onRecord?.(record)
|
|
138
247
|
let events
|
|
139
248
|
try {
|
|
140
|
-
const record = JSON.parse(line)
|
|
141
|
-
onRecord?.(record)
|
|
142
249
|
events = parse(record)
|
|
143
250
|
} catch {
|
|
144
|
-
onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
|
|
251
|
+
await onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
|
|
145
252
|
return
|
|
146
253
|
}
|
|
147
|
-
for (const event of events) onEvent(event)
|
|
254
|
+
for (const event of events) await onEvent(event)
|
|
148
255
|
}
|
|
149
256
|
|
|
150
257
|
/** @param {NodeJS.Signals | null} signal */
|
|
@@ -153,6 +260,17 @@ function signalExitCode(signal) {
|
|
|
153
260
|
return 128 + (osConstants.signals[signal] ?? 0)
|
|
154
261
|
}
|
|
155
262
|
|
|
263
|
+
/** @param {unknown} value @returns {value is number} */
|
|
264
|
+
function isPositiveSafeInteger(value) {
|
|
265
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @param {ReturnType<typeof nodeSpawn>} child @param {number | null} processGroupPid @param {NodeJS.Signals} signal @param {(pid: number, signal: NodeJS.Signals) => boolean} killProcess */
|
|
269
|
+
function signalChildTree(child, processGroupPid, signal, killProcess) {
|
|
270
|
+
if (processGroupPid !== null) return killProcess(-processGroupPid, signal)
|
|
271
|
+
return child.kill(signal)
|
|
272
|
+
}
|
|
273
|
+
|
|
156
274
|
/** @param {() => void} onInterrupt @param {() => void} onTerminate */
|
|
157
275
|
function removeSignalHandlers(onInterrupt, onTerminate) {
|
|
158
276
|
process.removeListener("SIGINT", onInterrupt)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {PROVIDERS} from "../providers/index.js"
|
|
4
4
|
|
|
5
5
|
const COMMAND_PATTERN = /^\/code(?:@[A-Za-z0-9_]+)?\s+(codex|claude|opencode)\s+(\S[\s\S]*)$/u
|
|
6
|
+
const EVIDENCE_PATTERN = /^\/evidence(?:@[A-Za-z0-9_]+)?\s+(evidence_[A-Za-z0-9_-]{43})\s+(bytes|lines)\s+(\d+):(\d+)$/u
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* @typedef {{provider: "codex" | "claude" | "opencode", prompt: string}} CodeCommand
|
|
@@ -27,3 +28,18 @@ export function parseCodeCommand(text) {
|
|
|
27
28
|
prompt
|
|
28
29
|
}
|
|
29
30
|
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} text */
|
|
33
|
+
export function parseEvidenceCommand(text) {
|
|
34
|
+
if (typeof text !== "string") return null
|
|
35
|
+
const match = EVIDENCE_PATTERN.exec(text)
|
|
36
|
+
if (!match) return null
|
|
37
|
+
const handle = match[1]
|
|
38
|
+
const selector = match[2]
|
|
39
|
+
const start = Number(match[3])
|
|
40
|
+
const limit = Number(match[4])
|
|
41
|
+
if (!handle || !Number.isSafeInteger(start) || !Number.isSafeInteger(limit) || limit <= 0) return null
|
|
42
|
+
if (selector === "bytes" && start >= 0) return {handle, bytes: {offset: start, limit}}
|
|
43
|
+
if (selector === "lines" && start > 0) return {handle, lines: {start, limit}}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {DEFAULT_TELEGRAM_REQUEST_TIMEOUT_MS, MAX_TIMER_DELAY_MS} from "../notifiers/fetch-transport.js"
|
|
4
4
|
import {open as nodeOpen} from "node:fs/promises"
|
|
5
5
|
import {constants as fsConstants} from "node:fs"
|
|
6
|
+
import {isAbsolute, normalize, parse, relative} from "node:path"
|
|
6
7
|
|
|
7
8
|
const TELEGRAM_ID_PATTERN = /^-?[1-9]\d*$/u
|
|
8
9
|
const DEFAULT_PORT = 8787
|
|
@@ -10,6 +11,7 @@ const DEFAULT_HOST = "127.0.0.1"
|
|
|
10
11
|
const DEFAULT_MAX_CONCURRENT_WORKERS = 4
|
|
11
12
|
const DEFAULT_UPDATE_ID_CAPACITY = 10_000
|
|
12
13
|
const DEFAULT_UPDATE_ID_TTL_MS = 86_400_000
|
|
14
|
+
const DEFAULT_EVIDENCE_ROOT = "/var/lib/threadwire/evidence"
|
|
13
15
|
export const MIN_WEBHOOK_SECRET_LENGTH = 32
|
|
14
16
|
const MAX_SECRET_FILE_BYTES = 65_536
|
|
15
17
|
const FILE_BACKED_SETTINGS = [
|
|
@@ -34,25 +36,90 @@ export async function resolveFileBackedSettings(source, names, operations = {})
|
|
|
34
36
|
const file = environment[fileName]
|
|
35
37
|
if (value !== undefined && file !== undefined) throw new Error(`${name} and ${fileName} must not both be set`)
|
|
36
38
|
if (file === undefined) continue
|
|
37
|
-
let handle
|
|
38
39
|
try {
|
|
39
|
-
|
|
40
|
-
const stats = await handle.stat()
|
|
41
|
-
if (!stats.isFile() || stats.size > MAX_SECRET_FILE_BYTES) throw new Error("invalid setting file")
|
|
42
|
-
const buffer = Buffer.alloc(MAX_SECRET_FILE_BYTES + 1)
|
|
43
|
-
const {bytesRead} = await handle.read(buffer, 0, buffer.length, 0)
|
|
44
|
-
if (bytesRead > MAX_SECRET_FILE_BYTES) throw new Error("invalid setting file")
|
|
45
|
-
environment[name] = buffer.subarray(0, bytesRead).toString("utf8").trim()
|
|
40
|
+
environment[name] = await readSecureSettingFile(file, operations)
|
|
46
41
|
} catch {
|
|
47
42
|
throw new Error(`${fileName} could not be read`)
|
|
48
|
-
} finally {
|
|
49
|
-
await handle?.close().catch(() => {})
|
|
50
43
|
}
|
|
51
44
|
delete environment[fileName]
|
|
52
45
|
}
|
|
53
46
|
return environment
|
|
54
47
|
}
|
|
55
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Read one bounded regular secret file without following a final symlink.
|
|
51
|
+
* @param {string} file
|
|
52
|
+
* @param {{open?: (path: string, flags: number) => Promise<import("node:fs/promises").FileHandle>}} [operations]
|
|
53
|
+
*/
|
|
54
|
+
export async function readSecureSettingFile(file, operations = {}) {
|
|
55
|
+
if (!isAbsolute(file) || normalize(file) !== file || relative(parse(file).root, file).split("/").includes("..")) {
|
|
56
|
+
throw new Error("invalid setting file")
|
|
57
|
+
}
|
|
58
|
+
const openImpl = operations.open ?? nodeOpen
|
|
59
|
+
/** @type {import("node:fs/promises").FileHandle[]} */
|
|
60
|
+
const directories = []
|
|
61
|
+
let handle
|
|
62
|
+
try {
|
|
63
|
+
if (operations.open === undefined) {
|
|
64
|
+
let directory = await openImpl(parse(file).root, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW)
|
|
65
|
+
directories.push(directory)
|
|
66
|
+
for (const component of relative(parse(file).root, file).split("/").slice(0, -1)) {
|
|
67
|
+
const stats = await directory.stat()
|
|
68
|
+
if (!stats.isDirectory() || !trustedDirectory(stats)) throw new Error("invalid setting file")
|
|
69
|
+
directory = await openImpl(`/proc/self/fd/${directory.fd}/${component}`, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW)
|
|
70
|
+
directories.push(directory)
|
|
71
|
+
}
|
|
72
|
+
const parentStats = await directory.stat()
|
|
73
|
+
if (!parentStats.isDirectory() || !trustedDirectory(parentStats)) throw new Error("invalid setting file")
|
|
74
|
+
const name = relative(parse(file).root, file).split("/").at(-1)
|
|
75
|
+
handle = await openImpl(`/proc/self/fd/${directory.fd}/${name}`, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW)
|
|
76
|
+
} else {
|
|
77
|
+
handle = await openImpl(file, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW)
|
|
78
|
+
}
|
|
79
|
+
const stats = await handle.stat()
|
|
80
|
+
if (!stats.isFile() || stats.nlink !== 1 || stats.size > MAX_SECRET_FILE_BYTES || !trustedSecretFile(stats)) throw new Error("invalid setting file")
|
|
81
|
+
const buffer = Buffer.alloc(MAX_SECRET_FILE_BYTES + 1)
|
|
82
|
+
const {bytesRead} = await handle.read(buffer, 0, buffer.length, 0)
|
|
83
|
+
if (bytesRead > MAX_SECRET_FILE_BYTES) throw new Error("invalid setting file")
|
|
84
|
+
const value = buffer.subarray(0, bytesRead).toString("utf8").trim()
|
|
85
|
+
if (value.length === 0) throw new Error("invalid setting file")
|
|
86
|
+
return value
|
|
87
|
+
} finally {
|
|
88
|
+
await handle?.close().catch(() => {})
|
|
89
|
+
await Promise.all(directories.map((directory) => directory.close().catch(() => {})))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {import("node:fs").Stats} stats */
|
|
94
|
+
function trustedDirectory(stats) {
|
|
95
|
+
const mode = stats.mode & 0o7777
|
|
96
|
+
return (stats.uid === 0 || stats.uid === process.getuid?.())
|
|
97
|
+
&& ((mode & 0o022) === 0 || (stats.uid === 0 && (mode & 0o1000) !== 0))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @param {import("node:fs").Stats} stats */
|
|
101
|
+
function trustedSecretFile(stats) {
|
|
102
|
+
const mode = stats.mode & 0o777
|
|
103
|
+
if (stats.uid === 0) return (mode & 0o222) === 0
|
|
104
|
+
return stats.uid === process.getuid?.() && (mode & 0o077) === 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {NodeJS.ProcessEnv} environment */
|
|
108
|
+
export async function collectEvidenceRedactions(environment) {
|
|
109
|
+
const redactions = []
|
|
110
|
+
for (const [key, value] of Object.entries(environment)) {
|
|
111
|
+
if (value === undefined) continue
|
|
112
|
+
if (/(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)_FILE$/u.test(key)) {
|
|
113
|
+
if (value.length >= 4) redactions.push(value)
|
|
114
|
+
const secret = await readSecureSettingFile(value)
|
|
115
|
+
if (secret.length >= 4) redactions.push(secret)
|
|
116
|
+
} else if (/(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)$/u.test(key) && value.length >= 4) {
|
|
117
|
+
redactions.push(value)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return redactions
|
|
121
|
+
}
|
|
122
|
+
|
|
56
123
|
/**
|
|
57
124
|
* Resolve every supported ingress setting from a literal or bounded file.
|
|
58
125
|
* @param {NodeJS.ProcessEnv} source
|
|
@@ -75,6 +142,7 @@ export async function resolveIngressEnvironment(source, operations = {}) {
|
|
|
75
142
|
* telegramRequestTimeoutMs: number,
|
|
76
143
|
* updateIdCapacity: number,
|
|
77
144
|
* updateIdTtlMs: number
|
|
145
|
+
* evidenceRoot?: string
|
|
78
146
|
* }} IngressConfig
|
|
79
147
|
*/
|
|
80
148
|
|
|
@@ -109,6 +177,7 @@ export function parseIngressConfig(environment) {
|
|
|
109
177
|
"THREADWIRE_UPDATE_ID_TTL_MS",
|
|
110
178
|
DEFAULT_UPDATE_ID_TTL_MS
|
|
111
179
|
)
|
|
180
|
+
const evidenceRoot = parseEvidenceRoot(environment.THREADWIRE_EVIDENCE_ROOT)
|
|
112
181
|
return {
|
|
113
182
|
botToken,
|
|
114
183
|
webhookSecret,
|
|
@@ -120,10 +189,18 @@ export function parseIngressConfig(environment) {
|
|
|
120
189
|
toolMessages,
|
|
121
190
|
telegramRequestTimeoutMs,
|
|
122
191
|
updateIdCapacity,
|
|
123
|
-
updateIdTtlMs
|
|
192
|
+
updateIdTtlMs,
|
|
193
|
+
evidenceRoot
|
|
124
194
|
}
|
|
125
195
|
}
|
|
126
196
|
|
|
197
|
+
/** @param {string | undefined} value */
|
|
198
|
+
function parseEvidenceRoot(value) {
|
|
199
|
+
const root = value ?? DEFAULT_EVIDENCE_ROOT
|
|
200
|
+
if (!isAbsolute(root) || normalize(root) !== root) throw new Error("THREADWIRE_EVIDENCE_ROOT must be a normalized absolute path")
|
|
201
|
+
return root
|
|
202
|
+
}
|
|
203
|
+
|
|
127
204
|
/** @param {NodeJS.ProcessEnv} environment */
|
|
128
205
|
export function parseTelegramRequestTimeoutMs(environment) {
|
|
129
206
|
const timeoutMs = parsePositiveSafeInteger(
|
|
@@ -157,6 +234,13 @@ export function buildProviderEnvironment(source) {
|
|
|
157
234
|
/** @param {string} key */
|
|
158
235
|
function isIngressSecretKey(key) {
|
|
159
236
|
if (FILE_BACKED_SETTINGS.some((name) => key === `${name}_FILE`)) return true
|
|
237
|
+
if (
|
|
238
|
+
key.startsWith("THREADWIRE_ISOLATED_RUNTIME_")
|
|
239
|
+
|| key.startsWith("THREADWIRE_MODEL_BROKER_")
|
|
240
|
+
|| key === "THREADWIRE_RELAY_WORKER_IMAGE"
|
|
241
|
+
|| key === "THREADWIRE_ALLOWED_WORKTREE_ROOTS"
|
|
242
|
+
|| key === "THREADWIRE_WORKTREE_VOLUME"
|
|
243
|
+
) return true
|
|
160
244
|
return key === "TELEGRAM_BOT_TOKEN" ||
|
|
161
245
|
key === "THREADWIRE_TELEGRAM_BOT_TOKEN" ||
|
|
162
246
|
key === "THREADWIRE_WEBHOOK_SECRET" ||
|
|
@@ -165,6 +249,7 @@ function isIngressSecretKey(key) {
|
|
|
165
249
|
key === "THREADWIRE_WEBHOOK_PORT" ||
|
|
166
250
|
key === "THREADWIRE_WEBHOOK_HOST" ||
|
|
167
251
|
key === "THREADWIRE_MAX_CONCURRENT_WORKERS" ||
|
|
252
|
+
key === "THREADWIRE_EVIDENCE_ROOT" ||
|
|
168
253
|
key === "THREADWIRE_TOOL_MESSAGES" ||
|
|
169
254
|
key === "THREADWIRE_TELEGRAM_REQUEST_TIMEOUT_MS" ||
|
|
170
255
|
key === "THREADWIRE_UPDATE_ID_CAPACITY" ||
|