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
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, onSpawn?: (pid: number) => void, onRecord?: (record: unknown) => void, spawnImplementation?: typeof nodeSpawn, maxStdoutRecordBytes?: number}} RunWorkerOptions
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.kill(signal)
52
+ const forwardSignal = (signal) => signalChildTree(child, processGroupPid, signal, killProcess)
33
53
  const onInterrupt = () => forwardSignal("SIGINT")
34
54
  const onTerminate = () => forwardSignal("SIGTERM")
35
- /** @param {Error} error @param {boolean} [terminateChild] */
36
- const fail = (error, terminateChild = true) => {
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
- try {
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
- } catch (error) {
54
- fail(toError(error))
55
- }
142
+ stdoutBuffer = await drainLines(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
143
+ }, child.stdout)
56
144
  }
57
- const onStderr = () => {
58
- if (settled || stderrSeen) return
59
- stderrSeen = true
60
- try {
61
- options.onEvent({type: "diagnostic", level: "warning", summary: "Worker wrote diagnostics to stderr"})
62
- } catch (error) {
63
- fail(toError(error))
64
- }
65
- }
66
- const cleanup = () => {
67
- child.stdout?.removeListener("data", onStdout)
68
- child.stderr?.removeListener("data", onStderr)
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
- /** @param {number | null} code @param {NodeJS.Signals | null} signal */
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(code ?? signalExitCode(signal))
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
- handle = await (operations.open ?? nodeOpen)(file, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW)
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" ||