threadwire 0.1.5 → 0.1.6
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/README.md +5 -3
- 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/package.json +1 -1
- package/scripts/verify-package.js +7 -1
- package/src/cli.js +250 -29
- package/src/context-budget-metrics.js +180 -0
- package/src/delegated-result-admission.js +377 -0
- package/src/evidence-store.js +1472 -0
- package/src/notice-queue.js +22 -7
- package/src/providers/opencode.js +42 -18
- 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 +89 -11
- package/src/telegram-ingress/core.js +162 -90
- package/src/telegram-ingress/http.js +17 -1
- package/src/telegram-webhook.js +16 -0
- package/src/types.js +2 -2
- package/src/worker-control.js +294 -0
- package/src/hermes-protocol.js +0 -126
|
@@ -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(
|
|
@@ -165,6 +242,7 @@ function isIngressSecretKey(key) {
|
|
|
165
242
|
key === "THREADWIRE_WEBHOOK_PORT" ||
|
|
166
243
|
key === "THREADWIRE_WEBHOOK_HOST" ||
|
|
167
244
|
key === "THREADWIRE_MAX_CONCURRENT_WORKERS" ||
|
|
245
|
+
key === "THREADWIRE_EVIDENCE_ROOT" ||
|
|
168
246
|
key === "THREADWIRE_TOOL_MESSAGES" ||
|
|
169
247
|
key === "THREADWIRE_TELEGRAM_REQUEST_TIMEOUT_MS" ||
|
|
170
248
|
key === "THREADWIRE_UPDATE_ID_CAPACITY" ||
|
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import {createHash, timingSafeEqual} from "node:crypto"
|
|
3
|
+
import {createHash, randomUUID, timingSafeEqual} from "node:crypto"
|
|
4
|
+
import {evidenceTelegramDestination} from "../evidence-store.js"
|
|
4
5
|
import {NoticeQueue} from "../notice-queue.js"
|
|
5
6
|
import {createFetchTransport} from "../notifiers/fetch-transport.js"
|
|
6
7
|
import {createTelegramSender} from "../notifiers/telegram.js"
|
|
7
8
|
import {createProvider} from "../providers/index.js"
|
|
8
9
|
import {Relay} from "../relay.js"
|
|
9
10
|
import {runWorker} from "../run-worker.js"
|
|
11
|
+
import {WorkerControl} from "../worker-control.js"
|
|
10
12
|
import {resolveWorkspaceProfile} from "../workspace-profile.js"
|
|
11
|
-
import {buildProviderEnvironment} from "./config.js"
|
|
12
|
-
import {parseCodeCommand} from "./command.js"
|
|
13
|
+
import {buildProviderEnvironment, collectEvidenceRedactions} from "./config.js"
|
|
14
|
+
import {parseCodeCommand, parseEvidenceCommand} from "./command.js"
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* @typedef {{platform: "telegram", chatId: string, threadId: number | null}} TelegramTarget
|
|
16
|
-
* @typedef {{provider: "codex" | "claude" | "opencode", prompt: string, target: TelegramTarget}} AcceptedJob
|
|
18
|
+
* @typedef {{provider: "codex" | "claude" | "opencode", prompt: string, target: TelegramTarget, senderId: string} | {type: "evidence", request: unknown, target: TelegramTarget, senderId: string}} AcceptedJob
|
|
17
19
|
* @typedef {{
|
|
18
20
|
* createProvider?: typeof createProvider,
|
|
19
21
|
* createFetchTransport?: typeof createFetchTransport,
|
|
20
22
|
* createTelegramSender?: typeof createTelegramSender,
|
|
21
23
|
* runWorker?: typeof runWorker,
|
|
24
|
+
* WorkerControl?: typeof WorkerControl,
|
|
22
25
|
* NoticeQueue?: typeof NoticeQueue,
|
|
23
26
|
* Relay?: typeof Relay,
|
|
24
27
|
* processNumber?: number,
|
|
25
28
|
* providerEnvironment?: NodeJS.ProcessEnv,
|
|
26
29
|
* workspaceProfileOperations?: import("../workspace-profile.js").WorkspaceProfileOperations,
|
|
27
30
|
* activity?: Pick<import("../activity-log.js").ActivityLog, "recordWorkspace" | "recordStarted" | "recordSession" | "close">,
|
|
31
|
+
* evidenceStore?: import("../evidence-store.js").EvidenceStore,
|
|
28
32
|
* onWorkerFailure?: (message: string) => void,
|
|
29
33
|
* onWorkerSettled?: () => void
|
|
30
34
|
* }} IngressDependencies
|
|
@@ -55,11 +59,12 @@ export function interpretUpdate(update, config) {
|
|
|
55
59
|
if (!isRecord(update)) return null
|
|
56
60
|
const message = update.message
|
|
57
61
|
if (!isRecord(message)) return null
|
|
62
|
+
if ([
|
|
63
|
+
"forward_origin", "forward_from", "forward_from_chat", "forward_signature",
|
|
64
|
+
"forward_sender_name", "is_automatic_forward"
|
|
65
|
+
].some((field) => Object.prototype.hasOwnProperty.call(message, field))) return null
|
|
58
66
|
if (typeof message.text !== "string") return null
|
|
59
67
|
|
|
60
|
-
const command = parseCodeCommand(message.text)
|
|
61
|
-
if (!command) return null
|
|
62
|
-
|
|
63
68
|
const chatId = telegramIdString(isRecord(message.chat) ? message.chat.id : undefined)
|
|
64
69
|
if (chatId === null || !config.allowedChatIds.has(chatId)) return null
|
|
65
70
|
|
|
@@ -69,7 +74,11 @@ export function interpretUpdate(update, config) {
|
|
|
69
74
|
const target = deriveTarget(chatId, message.message_thread_id)
|
|
70
75
|
if (target === null) return null
|
|
71
76
|
|
|
72
|
-
|
|
77
|
+
const command = parseCodeCommand(message.text)
|
|
78
|
+
if (command) return {provider: command.provider, prompt: command.prompt, target, senderId}
|
|
79
|
+
const evidence = parseEvidenceCommand(message.text)
|
|
80
|
+
if (evidence) return {type: "evidence", request: evidence, target, senderId}
|
|
81
|
+
return null
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/**
|
|
@@ -83,10 +92,12 @@ export function interpretUpdate(update, config) {
|
|
|
83
92
|
* @returns {Promise<void>}
|
|
84
93
|
*/
|
|
85
94
|
export async function dispatchWorker(job, config, dependencies = {}) {
|
|
95
|
+
if ("type" in job) throw new Error("Worker dispatch requires a worker job")
|
|
86
96
|
const createProviderImpl = dependencies.createProvider ?? createProvider
|
|
87
97
|
const createFetchTransportImpl = dependencies.createFetchTransport ?? createFetchTransport
|
|
88
98
|
const createTelegramSenderImpl = dependencies.createTelegramSender ?? createTelegramSender
|
|
89
99
|
const runWorkerImpl = dependencies.runWorker ?? runWorker
|
|
100
|
+
const WorkerControlImpl = dependencies.WorkerControl ?? WorkerControl
|
|
90
101
|
const NoticeQueueImpl = dependencies.NoticeQueue ?? NoticeQueue
|
|
91
102
|
const RelayImpl = dependencies.Relay ?? Relay
|
|
92
103
|
const processNumber = dependencies.processNumber ?? process.pid
|
|
@@ -106,111 +117,172 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
106
117
|
)
|
|
107
118
|
const transport = createFetchTransportImpl(config.botToken, undefined, config.telegramRequestTimeoutMs)
|
|
108
119
|
const sender = createTelegramSenderImpl(job.target, transport)
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
spawnGate.reject = (error) => {
|
|
125
|
-
if (spawnGate.settled) return
|
|
126
|
-
spawnGate.settled = true
|
|
127
|
-
reject(error)
|
|
128
|
-
}
|
|
120
|
+
const control = new WorkerControlImpl({
|
|
121
|
+
sender,
|
|
122
|
+
processNumber,
|
|
123
|
+
toolMessages: config.toolMessages ?? false,
|
|
124
|
+
NoticeQueueClass: NoticeQueueImpl,
|
|
125
|
+
RelayClass: RelayImpl
|
|
126
|
+
})
|
|
127
|
+
const evidenceOwner = dependencies.evidenceStore?.createOwnerScope({
|
|
128
|
+
destinationId: evidenceTelegramDestination(job.target, job.senderId),
|
|
129
|
+
runId: randomUUID()
|
|
130
|
+
})
|
|
131
|
+
const evidence = evidenceOwner === undefined ? undefined : await dependencies.evidenceStore?.createArtifact(evidenceOwner, {
|
|
132
|
+
contentType: "text/plain; charset=utf-8",
|
|
133
|
+
redactions: await collectEvidenceRedactions(dependencies.providerEnvironment ?? {})
|
|
129
134
|
})
|
|
135
|
+
let evidenceTransferred = false
|
|
136
|
+
try {
|
|
137
|
+
await evidence?.append("prompt", `prompt\n${job.prompt}\nprovider-stream\n`)
|
|
130
138
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
} catch {
|
|
137
|
-
/* Observer errors must not become unhandled rejections. */
|
|
139
|
+
/** @type {{resolve: () => void, reject: (error: Error) => void, settled: boolean}} */
|
|
140
|
+
const spawnGate = {
|
|
141
|
+
settled: false,
|
|
142
|
+
resolve() {},
|
|
143
|
+
reject() {}
|
|
138
144
|
}
|
|
139
|
-
|
|
145
|
+
const spawned = new Promise((resolve, reject) => {
|
|
146
|
+
spawnGate.resolve = () => {
|
|
147
|
+
if (spawnGate.settled) return
|
|
148
|
+
spawnGate.settled = true
|
|
149
|
+
resolve(undefined)
|
|
150
|
+
}
|
|
151
|
+
spawnGate.reject = (error) => {
|
|
152
|
+
if (spawnGate.settled) return
|
|
153
|
+
spawnGate.settled = true
|
|
154
|
+
reject(error)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
140
157
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
158
|
+
/** @param {unknown} error */
|
|
159
|
+
const reportBackgroundFailure = (error) => {
|
|
160
|
+
const failure = error instanceof Error ? error : new Error(safeFailureMessage(error))
|
|
161
|
+
try {
|
|
162
|
+
dependencies.onWorkerFailure?.(safeFailureMessage(failure))
|
|
163
|
+
} catch {
|
|
145
164
|
/* Observer errors must not become unhandled rejections. */
|
|
165
|
+
}
|
|
146
166
|
}
|
|
147
|
-
}
|
|
148
167
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
onEvent: (event) => relay.accept(event),
|
|
156
|
-
onSpawn: (pid) => {
|
|
157
|
-
if (pid !== undefined) dependencies.activity?.recordStarted(provider.name, pid)
|
|
158
|
-
spawnGate.resolve()
|
|
159
|
-
},
|
|
160
|
-
onRecord: (record) => {
|
|
161
|
-
const id = provider.sessionId(record)
|
|
162
|
-
if (id !== undefined) dependencies.activity?.recordSession(provider.name, id)
|
|
168
|
+
const notifySettled = () => {
|
|
169
|
+
try {
|
|
170
|
+
dependencies.onWorkerSettled?.()
|
|
171
|
+
} catch {
|
|
172
|
+
/* Observer errors must not become unhandled rejections. */
|
|
173
|
+
}
|
|
163
174
|
}
|
|
164
|
-
})
|
|
165
175
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
const running = runWorkerImpl({
|
|
177
|
+
executable: provider.executable,
|
|
178
|
+
arguments: provider.arguments,
|
|
179
|
+
cwd: workspace.cwd,
|
|
180
|
+
environment: providerEnvironment,
|
|
181
|
+
parse: provider.parse,
|
|
182
|
+
onEvent: async (event) => control.accept(event),
|
|
183
|
+
onSpawn: (pid) => {
|
|
184
|
+
if (pid !== undefined) dependencies.activity?.recordStarted(provider.name, pid)
|
|
185
|
+
spawnGate.resolve()
|
|
186
|
+
},
|
|
187
|
+
onRecord: async (record) => {
|
|
188
|
+
const id = provider.sessionId(record)
|
|
189
|
+
if (id !== undefined) dependencies.activity?.recordSession(provider.name, id)
|
|
190
|
+
},
|
|
191
|
+
onStdoutChunk: (chunk) => evidence?.append("provider-stdout", chunk),
|
|
192
|
+
onStderrChunk: (chunk) => evidence?.append("provider-stderr", chunk)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Detached observer: never resolves the spawn gate from process exit — only
|
|
196
|
+
// from onSpawn. Pre-spawn failure rejects the gate; post-spawn failure is
|
|
197
|
+
// reported. Always notify settlement so concurrency slots are released.
|
|
198
|
+
evidenceTransferred = true
|
|
199
|
+
void (async () => {
|
|
171
200
|
try {
|
|
172
|
-
await running
|
|
173
|
-
} catch (error) {
|
|
174
|
-
const failure = error instanceof Error ? error : new Error(safeFailureMessage(error))
|
|
175
|
-
if (spawnGate.settled) {
|
|
176
|
-
reportBackgroundFailure(failure)
|
|
177
|
-
} else {
|
|
178
|
-
spawnGate.reject(failure)
|
|
179
|
-
}
|
|
180
201
|
try {
|
|
181
|
-
await
|
|
182
|
-
} catch {
|
|
202
|
+
await running
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const failure = error instanceof Error ? error : new Error(safeFailureMessage(error))
|
|
205
|
+
if (spawnGate.settled) {
|
|
206
|
+
reportBackgroundFailure(failure)
|
|
207
|
+
} else {
|
|
208
|
+
spawnGate.reject(failure)
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
await control.close()
|
|
212
|
+
} catch {
|
|
183
213
|
/* Best-effort close after worker failure. */
|
|
214
|
+
}
|
|
215
|
+
return
|
|
184
216
|
}
|
|
185
|
-
return
|
|
186
|
-
}
|
|
187
217
|
|
|
188
|
-
|
|
189
|
-
|
|
218
|
+
try {
|
|
219
|
+
await control.close()
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (spawnGate.settled) {
|
|
222
|
+
reportBackgroundFailure(error)
|
|
223
|
+
} else {
|
|
224
|
+
spawnGate.reject(error instanceof Error ? error : new Error(safeFailureMessage(error)))
|
|
225
|
+
}
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!spawnGate.settled) {
|
|
230
|
+
spawnGate.reject(new Error("Worker exited before spawn was observed"))
|
|
231
|
+
}
|
|
190
232
|
} catch (error) {
|
|
191
233
|
if (spawnGate.settled) {
|
|
192
234
|
reportBackgroundFailure(error)
|
|
193
235
|
} else {
|
|
194
236
|
spawnGate.reject(error instanceof Error ? error : new Error(safeFailureMessage(error)))
|
|
195
237
|
}
|
|
196
|
-
|
|
238
|
+
} finally {
|
|
239
|
+
if (evidence !== undefined) {
|
|
240
|
+
try {
|
|
241
|
+
await evidence.finalize()
|
|
242
|
+
await sender.send(`Evidence: ${evidence.handle}`)
|
|
243
|
+
} catch (error) {
|
|
244
|
+
await evidence.abort()
|
|
245
|
+
reportBackgroundFailure(error)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
notifySettled()
|
|
197
249
|
}
|
|
250
|
+
})()
|
|
198
251
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
reportBackgroundFailure(error)
|
|
205
|
-
} else {
|
|
206
|
-
spawnGate.reject(error instanceof Error ? error : new Error(safeFailureMessage(error)))
|
|
207
|
-
}
|
|
208
|
-
} finally {
|
|
209
|
-
notifySettled()
|
|
210
|
-
}
|
|
211
|
-
})()
|
|
252
|
+
await spawned
|
|
253
|
+
} finally {
|
|
254
|
+
if (!evidenceTransferred) await evidence?.abort()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
212
257
|
|
|
213
|
-
|
|
258
|
+
/** @param {Extract<AcceptedJob, {type: "evidence"}>} job @param {{botToken: string, telegramRequestTimeoutMs?: number}} config @param {IngressDependencies} dependencies */
|
|
259
|
+
export async function dispatchEvidenceRead(job, config, dependencies) {
|
|
260
|
+
if (!dependencies.evidenceStore) throw new Error("Evidence retrieval is unavailable")
|
|
261
|
+
const transport = (dependencies.createFetchTransport ?? createFetchTransport)(config.botToken, undefined, config.telegramRequestTimeoutMs)
|
|
262
|
+
const sender = (dependencies.createTelegramSender ?? createTelegramSender)(job.target, transport)
|
|
263
|
+
const result = await dependencies.evidenceStore.readTelegram({...job.target, senderId: job.senderId}, job.request)
|
|
264
|
+
const label = result.encoding === "base64"
|
|
265
|
+
? `Evidence (${result.contentType}, base64, truncated):\n`
|
|
266
|
+
: `Evidence (${result.contentType}, redacted, truncated):\n`
|
|
267
|
+
const boundedData = telegramEvidenceText(result.data, Math.max(0, 3_000 - Buffer.byteLength(label)))
|
|
268
|
+
await sender.send({
|
|
269
|
+
text: result.encoding === "base64"
|
|
270
|
+
? `Evidence (${result.contentType}, base64${boundedData.truncated ? ", truncated" : ""}):\n${boundedData.text}`
|
|
271
|
+
: `Evidence (${result.contentType}${result.redacted ? ", redacted" : ""}${result.truncated || boundedData.truncated ? ", truncated" : ""}):\n${boundedData.text}`
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** @param {string} value @param {number} maxBytes */
|
|
276
|
+
function telegramEvidenceText(value, maxBytes) {
|
|
277
|
+
let text = ""
|
|
278
|
+
let bytes = 0
|
|
279
|
+
for (const character of value) {
|
|
280
|
+
const width = Buffer.byteLength(character, "utf8")
|
|
281
|
+
if (bytes + width > maxBytes) return {text, truncated: true}
|
|
282
|
+
text += character
|
|
283
|
+
bytes += width
|
|
284
|
+
}
|
|
285
|
+
return {text, truncated: false}
|
|
214
286
|
}
|
|
215
287
|
|
|
216
288
|
/** @param {string} chatId @param {unknown} threadId @returns {TelegramTarget | null} */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import {createConcurrencyLimiter} from "./concurrency.js"
|
|
4
|
-
import {dispatchWorker, interpretUpdate, secretsEqual} from "./core.js"
|
|
4
|
+
import {dispatchEvidenceRead, dispatchWorker, interpretUpdate, secretsEqual} from "./core.js"
|
|
5
5
|
import {createUpdateGuard, parseUpdateId} from "./update-guard.js"
|
|
6
6
|
import {WorkspaceProviderMismatchError} from "../workspace-profile.js"
|
|
7
7
|
|
|
@@ -112,6 +112,22 @@ async function handleRequest(request, response, config, dependencies) {
|
|
|
112
112
|
return
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
if ("type" in job) {
|
|
116
|
+
try {
|
|
117
|
+
await dispatchEvidenceRead(job, {
|
|
118
|
+
botToken: config.botToken,
|
|
119
|
+
telegramRequestTimeoutMs: config.telegramRequestTimeoutMs
|
|
120
|
+
}, dependencies)
|
|
121
|
+
dependencies.updateGuard.complete(updateId)
|
|
122
|
+
send(response, 200)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
dependencies.updateGuard.release(updateId)
|
|
125
|
+
dependencies.onOperationalError?.(error instanceof Error ? error.message : "Evidence retrieval failed")
|
|
126
|
+
send(response, 500)
|
|
127
|
+
}
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
115
131
|
if (!dependencies.concurrency.tryAcquire()) {
|
|
116
132
|
dependencies.updateGuard.release(updateId)
|
|
117
133
|
send(response, 503)
|
package/src/telegram-webhook.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {createServer} from "node:http"
|
|
4
4
|
import {ActivityLog} from "./activity-log.js"
|
|
5
|
+
import {EvidenceStore} from "./evidence-store.js"
|
|
5
6
|
import {buildProviderEnvironment, parseIngressConfig, resolveIngressEnvironment} from "./telegram-ingress/config.js"
|
|
6
7
|
import {createWebhookHandler} from "./telegram-ingress/http.js"
|
|
7
8
|
|
|
@@ -15,6 +16,7 @@ import {createWebhookHandler} from "./telegram-ingress/http.js"
|
|
|
15
16
|
* onWorkerFailure?: (message: string) => void,
|
|
16
17
|
* providerEnvironment?: NodeJS.ProcessEnv,
|
|
17
18
|
* activity?: Pick<ActivityLog, "recordWorkspace" | "recordStarted" | "recordSession" | "close">,
|
|
19
|
+
* evidenceStore?: EvidenceStore,
|
|
18
20
|
* handlerDependencies?: import("./telegram-ingress/http.js").WebhookDependencies
|
|
19
21
|
* }} [options]
|
|
20
22
|
* @returns {Promise<{server: import("node:http").Server, config: import("./telegram-ingress/config.js").IngressConfig, handler: (request: import("node:http").IncomingMessage, response: import("node:http").ServerResponse) => void}>}
|
|
@@ -33,15 +35,29 @@ export async function startTelegramWebhook(options = {}) {
|
|
|
33
35
|
options.providerEnvironment ?? environment
|
|
34
36
|
)
|
|
35
37
|
const activity = options.activity ?? options.handlerDependencies?.activity ?? new ActivityLog("/var/lib/threadwire/activity/threadwire.jsonl")
|
|
38
|
+
const evidenceStore = options.evidenceStore ?? options.handlerDependencies?.evidenceStore ?? await EvidenceStore.open({
|
|
39
|
+
root: config.evidenceRoot ?? "/var/lib/threadwire/evidence"
|
|
40
|
+
})
|
|
36
41
|
|
|
37
42
|
const handler = createWebhookHandler(config, {
|
|
38
43
|
...options.handlerDependencies,
|
|
39
44
|
activity,
|
|
45
|
+
evidenceStore,
|
|
40
46
|
providerEnvironment,
|
|
41
47
|
onOperationalError,
|
|
42
48
|
onWorkerFailure
|
|
43
49
|
})
|
|
44
50
|
const server = createServerImpl(handler)
|
|
51
|
+
if (options.evidenceStore === undefined && options.handlerDependencies?.evidenceStore === undefined) {
|
|
52
|
+
const cleanupTimer = setInterval(() => {
|
|
53
|
+
void evidenceStore.cleanup().catch((error) => onOperationalError(error instanceof Error ? error.message : "Evidence cleanup failed"))
|
|
54
|
+
}, 60 * 60 * 1000)
|
|
55
|
+
cleanupTimer.unref()
|
|
56
|
+
server.once("close", () => {
|
|
57
|
+
clearInterval(cleanupTimer)
|
|
58
|
+
void evidenceStore.close()
|
|
59
|
+
})
|
|
60
|
+
}
|
|
45
61
|
|
|
46
62
|
if (options.listen) {
|
|
47
63
|
await options.listen(server, config.port, config.host)
|
package/src/types.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* @typedef {{type: "diagnostic", level: "warning" | "error", summary: string}} DiagnosticEvent
|
|
8
8
|
* @typedef {TextDeltaEvent | ToolEvent | LifecycleEvent | DiagnosticEvent} WorkerEvent
|
|
9
9
|
* @typedef {{platform: "telegram", chatId: string, threadId: number | null}} TelegramTarget
|
|
10
|
-
* @typedef {{text: string, format?: "javascript-code", label?: string, batchSeparator?: string}} TextNotice
|
|
11
|
-
* @typedef {{activity: {key: string, phase: "started" | "finished"}, text: string, parseMode?: "HTML"}} ActivityNotice
|
|
10
|
+
* @typedef {{text: string, format?: "javascript-code", label?: string, batchSeparator?: string, metricClass?: string}} TextNotice
|
|
11
|
+
* @typedef {{activity: {key: string, phase: "started" | "finished"}, text: string, parseMode?: "HTML", metricClass?: string}} ActivityNotice
|
|
12
12
|
* @typedef {TextNotice | ActivityNotice} RenderedNotice
|
|
13
13
|
* @typedef {{text: string, parseMode?: "HTML"}} OutgoingMessage
|
|
14
14
|
* @typedef {{messageId: number}} SentMessage
|