threadwire 0.1.20 → 0.1.22
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 +118 -0
- package/README.md +5 -3
- package/docs/container-runtime.md +2 -2
- package/docs/development-container.md +64 -20
- package/docs/evidence-artifacts.md +47 -0
- package/package.json +5 -3
- package/scripts/verify-package.js +2 -0
- package/src/cli.js +124 -23
- package/src/evidence-index.js +664 -0
- package/src/evidence-store.js +180 -44
- package/src/isolated-runtime.js +1 -1
- package/src/kimi-model-broker.js +18 -2
- package/src/redaction.js +50 -0
- package/src/run-worker.js +10 -3
- package/src/telegram-ingress/core.js +66 -6
package/src/evidence-store.js
CHANGED
|
@@ -2,10 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
import {createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual} from "node:crypto"
|
|
4
4
|
import {spawnSync} from "node:child_process"
|
|
5
|
-
import {constants as fsConstants} from "node:fs"
|
|
5
|
+
import {constants as fsConstants, unlinkSync} from "node:fs"
|
|
6
6
|
import {mkdir, open, readdir, rename, unlink} from "node:fs/promises"
|
|
7
7
|
import {basename, isAbsolute, join, parse, relative} from "node:path"
|
|
8
8
|
import {StringDecoder} from "node:string_decoder"
|
|
9
|
+
import {applyRedactionRanges, redactionRanges, redactText} from "./redaction.js"
|
|
10
|
+
import {
|
|
11
|
+
isIndexJournalName,
|
|
12
|
+
isPendingIndexName,
|
|
13
|
+
isReadyIndexName,
|
|
14
|
+
readyIndexRunHash,
|
|
15
|
+
runHash,
|
|
16
|
+
RunIndex,
|
|
17
|
+
validateInspectRequest,
|
|
18
|
+
inspectIndex
|
|
19
|
+
} from "./evidence-index.js"
|
|
20
|
+
|
|
21
|
+
export {redactText}
|
|
9
22
|
|
|
10
23
|
const HANDLE_PATTERN = /^evidence_([A-Za-z0-9_-]{43})$/u
|
|
11
24
|
const META_SUFFIX = ".meta.json"
|
|
@@ -92,6 +105,12 @@ export class EvidenceStore {
|
|
|
92
105
|
this.maintenance = Promise.resolve()
|
|
93
106
|
/** @type {Map<string, {bytes: number, events: number}>} */
|
|
94
107
|
this.runs = new Map()
|
|
108
|
+
/** @type {Map<string, RunIndex>} */
|
|
109
|
+
this.runIndices = new Map()
|
|
110
|
+
/** @type {Map<string, number>} */
|
|
111
|
+
this.runArtifactCounts = new Map()
|
|
112
|
+
/** @type {Map<string, number>} */
|
|
113
|
+
this.runIndexBytes = new Map()
|
|
95
114
|
}
|
|
96
115
|
|
|
97
116
|
/**
|
|
@@ -150,6 +169,9 @@ export class EvidenceStore {
|
|
|
150
169
|
async finishClose() {
|
|
151
170
|
await this.maintenance
|
|
152
171
|
if (this.activeOperations > 0) await new Promise((resolve) => this.operationWaiters.push(() => resolve(undefined)))
|
|
172
|
+
for (const index of this.runIndices.values()) {
|
|
173
|
+
if (!index.finalized) await index.abort().catch(() => {})
|
|
174
|
+
}
|
|
153
175
|
await this.assertPinnedRoot()
|
|
154
176
|
await this.lease.close()
|
|
155
177
|
this.closed = true
|
|
@@ -214,6 +236,8 @@ export class EvidenceStore {
|
|
|
214
236
|
// JavaScript runs through here without yielding: reserve the slot before
|
|
215
237
|
// the first await so concurrent creations observe this pending artifact.
|
|
216
238
|
this.artifactSlots += 1
|
|
239
|
+
const runId = metadata.owner.runId
|
|
240
|
+
this.runArtifactCounts.set(runId, (this.runArtifactCounts.get(runId) ?? 0) + 1)
|
|
217
241
|
/** @type {import("node:fs/promises").FileHandle | undefined} */
|
|
218
242
|
let file
|
|
219
243
|
try {
|
|
@@ -227,6 +251,7 @@ export class EvidenceStore {
|
|
|
227
251
|
await safeUnlink(this.path(id, META_SUFFIX))
|
|
228
252
|
this.reserve(metadata.owner.runId, -overheadBytes, 0)
|
|
229
253
|
this.artifactSlots -= 1
|
|
254
|
+
this.decrementRunArtifactCount(metadata.owner.runId)
|
|
230
255
|
finishOperation()
|
|
231
256
|
throw error
|
|
232
257
|
}
|
|
@@ -321,6 +346,100 @@ export class EvidenceStore {
|
|
|
321
346
|
}
|
|
322
347
|
}
|
|
323
348
|
|
|
349
|
+
/**
|
|
350
|
+
* @param {object} owner
|
|
351
|
+
* @param {{provider?: string}} [options]
|
|
352
|
+
* @returns {RunIndex}
|
|
353
|
+
*/
|
|
354
|
+
ensureRunIndex(owner, options = {}) {
|
|
355
|
+
this.assertScope(owner)
|
|
356
|
+
if (this.closed || this.closing) throw new Error("Evidence store is closed")
|
|
357
|
+
const runId = /** @type {{runId: string}} */ (owner).runId
|
|
358
|
+
let index = this.runIndices.get(runId)
|
|
359
|
+
if (index) {
|
|
360
|
+
if (options.provider && !index.provider) index.provider = options.provider
|
|
361
|
+
return index
|
|
362
|
+
}
|
|
363
|
+
const initialBytes = this.runIndexBytes.get(runId) ?? 0
|
|
364
|
+
index = new RunIndex(this, owner, options, initialBytes)
|
|
365
|
+
this.runIndices.set(runId, index)
|
|
366
|
+
return index
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** @param {object} owner @param {string[]} redactions */
|
|
370
|
+
setRunIndexRedactions(owner, redactions) {
|
|
371
|
+
this.assertScope(owner)
|
|
372
|
+
if (this.closed || this.closing) throw new Error("Evidence store is closed")
|
|
373
|
+
const index = this.ensureRunIndex(owner, {})
|
|
374
|
+
index.setRedactions([...this.runtimeRedactions, ...validateRedactions(redactions)])
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** @param {object} owner @param {import("./evidence-index.js").EvidenceIndexEvent} event */
|
|
378
|
+
async recordEvent(owner, event) {
|
|
379
|
+
this.assertScope(owner)
|
|
380
|
+
if (this.closed || this.closing) throw new Error("Evidence store is closed")
|
|
381
|
+
const index = this.ensureRunIndex(owner, event.provider ? {provider: event.provider} : {})
|
|
382
|
+
await index.recordEvent(event)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** @param {object} owner @param {{state: "completed" | "failed", exitCode: number}} terminal */
|
|
386
|
+
async finalizeRunIndex(owner, terminal) {
|
|
387
|
+
this.assertScope(owner)
|
|
388
|
+
if (this.closed || this.closing) throw new Error("Evidence store is closed")
|
|
389
|
+
const index = this.runIndices.get(/** @type {{runId: string}} */ (owner).runId)
|
|
390
|
+
if (index) await index.finalize(terminal)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** @param {object} owner @param {unknown} request @returns {Promise<import("./evidence-index.js").EvidenceInspectResult>} */
|
|
394
|
+
async inspect(owner, request) {
|
|
395
|
+
this.assertScope(owner)
|
|
396
|
+
const validated = validateInspectRequest(request)
|
|
397
|
+
const finishOperation = this.beginOperation()
|
|
398
|
+
try {
|
|
399
|
+
return await this.inspectLocked(owner, validated)
|
|
400
|
+
} finally {
|
|
401
|
+
finishOperation()
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** @param {object} owner @param {import("./evidence-index.js").EvidenceInspectRequest} validated */
|
|
406
|
+
async inspectLocked(owner, validated) {
|
|
407
|
+
const id = this.lookupCapability(validated.handle)
|
|
408
|
+
if (!id) throw new EvidenceAccessError()
|
|
409
|
+
const metadata = this.artifacts.get(id)
|
|
410
|
+
if (!metadata || metadata.state !== "ready" || metadata.expiresAt <= this.clock() || !sameOwner(metadata.owner, owner)) {
|
|
411
|
+
throw new EvidenceAccessError()
|
|
412
|
+
}
|
|
413
|
+
const indexPath = this.path(runHash(metadata.owner.runId), ".index")
|
|
414
|
+
const indexStats = await safeRegularFile(indexPath)
|
|
415
|
+
if (indexStats === null) throw new EvidenceAccessError()
|
|
416
|
+
try {
|
|
417
|
+
return inspectIndex(indexPath, validated, this)
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (error instanceof EvidenceAccessError) throw error
|
|
420
|
+
if (error instanceof Error && (error.name === "EvidenceAccessError" || /unavailable/u.test(error.message))) {
|
|
421
|
+
throw new EvidenceAccessError()
|
|
422
|
+
}
|
|
423
|
+
throw error
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** @param {unknown} request @returns {Promise<import("./evidence-index.js").EvidenceInspectResult>} */
|
|
428
|
+
async inspectBearer(request) {
|
|
429
|
+
const validated = validateInspectRequest(request)
|
|
430
|
+
const id = this.lookupCapability(validated.handle)
|
|
431
|
+
if (!id) throw new EvidenceAccessError()
|
|
432
|
+
const metadata = this.artifacts.get(id)
|
|
433
|
+
if (!metadata) throw new EvidenceAccessError()
|
|
434
|
+
const owner = Object.freeze({...metadata.owner})
|
|
435
|
+
this.scopes.add(owner)
|
|
436
|
+
try {
|
|
437
|
+
return await this.inspect(owner, validated)
|
|
438
|
+
} finally {
|
|
439
|
+
this.scopes.delete(owner)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
324
443
|
/** @param {object} owner @param {string} handle @param {"delivery_succeeded" | "delivery_failed"} state */
|
|
325
444
|
recordDelivery(owner, handle, state) {
|
|
326
445
|
const finish = this.beginOperation()
|
|
@@ -440,6 +559,7 @@ export class EvidenceStore {
|
|
|
440
559
|
const names = await readdir(this.root)
|
|
441
560
|
const candidates = new Map()
|
|
442
561
|
const abandonedTemps = []
|
|
562
|
+
const readyIndexNames = []
|
|
443
563
|
for (const name of names) {
|
|
444
564
|
const match = /^([A-Za-z0-9_-]{43})(\.meta\.json|\.payload|\.pending)$/u.exec(name)
|
|
445
565
|
if (name === LOCK_FILE || name === ROOT_MARKER) continue
|
|
@@ -447,6 +567,14 @@ export class EvidenceStore {
|
|
|
447
567
|
abandonedTemps.push(name)
|
|
448
568
|
continue
|
|
449
569
|
}
|
|
570
|
+
if (isPendingIndexName(name) || isIndexJournalName(name)) {
|
|
571
|
+
abandonedTemps.push(name)
|
|
572
|
+
continue
|
|
573
|
+
}
|
|
574
|
+
if (isReadyIndexName(name)) {
|
|
575
|
+
readyIndexNames.push(name)
|
|
576
|
+
continue
|
|
577
|
+
}
|
|
450
578
|
if (!match) {
|
|
451
579
|
throw new Error("Evidence store contains an unknown entry")
|
|
452
580
|
}
|
|
@@ -458,6 +586,8 @@ export class EvidenceStore {
|
|
|
458
586
|
candidates.set(id, group)
|
|
459
587
|
}
|
|
460
588
|
for (const name of abandonedTemps) await safeUnlink(join(this.root, basename(name)))
|
|
589
|
+
/** @type {Map<string, string>} */
|
|
590
|
+
const validRunHashes = new Map()
|
|
461
591
|
for (const [id, files] of candidates) {
|
|
462
592
|
let metadata
|
|
463
593
|
try {
|
|
@@ -474,10 +604,26 @@ export class EvidenceStore {
|
|
|
474
604
|
this.artifactSlots += 1
|
|
475
605
|
this.artifacts.set(id, metadata)
|
|
476
606
|
this.capabilities.set(metadata.capabilityHash, id)
|
|
607
|
+
const runId = metadata.owner.runId
|
|
608
|
+
this.runArtifactCounts.set(runId, (this.runArtifactCounts.get(runId) ?? 0) + 1)
|
|
609
|
+
validRunHashes.set(runHash(runId), runId)
|
|
477
610
|
} catch {
|
|
478
611
|
await Promise.all([...files].map((suffix) => safeUnlink(this.path(id, suffix))))
|
|
479
612
|
}
|
|
480
613
|
}
|
|
614
|
+
for (const name of readyIndexNames) {
|
|
615
|
+
const hash = readyIndexRunHash(name)
|
|
616
|
+
const runId = hash === undefined ? undefined : validRunHashes.get(hash)
|
|
617
|
+
if (runId === undefined) {
|
|
618
|
+
await safeUnlink(join(this.root, name))
|
|
619
|
+
continue
|
|
620
|
+
}
|
|
621
|
+
const stats = await safeRegularFile(join(this.root, name))
|
|
622
|
+
if (stats && stats.size > 0) {
|
|
623
|
+
this.reserve(runId, stats.size, 0)
|
|
624
|
+
this.runIndexBytes.set(runId, stats.size)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
481
627
|
}
|
|
482
628
|
|
|
483
629
|
/** @param {string} runId @param {number} bytes @param {number} events */
|
|
@@ -504,6 +650,39 @@ export class EvidenceStore {
|
|
|
504
650
|
run.events -= metadata.events
|
|
505
651
|
if (run.bytes === 0 && run.events === 0) this.runs.delete(metadata.owner.runId)
|
|
506
652
|
}
|
|
653
|
+
this.decrementRunArtifactCount(metadata.owner.runId)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** @param {string} runId */
|
|
657
|
+
decrementRunArtifactCount(runId) {
|
|
658
|
+
const count = (this.runArtifactCounts.get(runId) ?? 1) - 1
|
|
659
|
+
if (count <= 0) {
|
|
660
|
+
this.runArtifactCounts.delete(runId)
|
|
661
|
+
this.removeRunIndex(runId)
|
|
662
|
+
} else {
|
|
663
|
+
this.runArtifactCounts.set(runId, count)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** @param {string} runId */
|
|
668
|
+
removeRunIndex(runId) {
|
|
669
|
+
const index = this.runIndices.get(runId)
|
|
670
|
+
if (index) {
|
|
671
|
+
index.releaseAccounting()
|
|
672
|
+
this.runIndices.delete(runId)
|
|
673
|
+
this.runIndexBytes.delete(runId)
|
|
674
|
+
try { unlinkSync(index.readyPath) } catch { /* ignore */ }
|
|
675
|
+
try { unlinkSync(index.pendingPath) } catch { /* ignore */ }
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
const reservedBytes = this.runIndexBytes.get(runId) ?? 0
|
|
679
|
+
if (reservedBytes > 0) {
|
|
680
|
+
this.reserve(runId, -reservedBytes, 0)
|
|
681
|
+
}
|
|
682
|
+
this.runIndexBytes.delete(runId)
|
|
683
|
+
const hash = runHash(runId)
|
|
684
|
+
try { unlinkSync(this.path(hash, ".index")) } catch { /* ignore */ }
|
|
685
|
+
try { unlinkSync(this.path(hash, ".index.pending")) } catch { /* ignore */ }
|
|
507
686
|
}
|
|
508
687
|
|
|
509
688
|
/** @param {object} owner */
|
|
@@ -939,49 +1118,6 @@ function utf8End(buffer, offset) {
|
|
|
939
1118
|
return offset
|
|
940
1119
|
}
|
|
941
1120
|
|
|
942
|
-
/** @param {string} text @param {string[]} secrets */
|
|
943
|
-
function redactText(text, secrets) {
|
|
944
|
-
const merged = redactionRanges(text, secrets)
|
|
945
|
-
if (merged.length === 0) return {text, redacted: false}
|
|
946
|
-
return {text: applyRedactionRanges(text, merged, text.length), redacted: true}
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
/** @param {string} text @param {string[]} secrets */
|
|
950
|
-
function redactionRanges(text, secrets) {
|
|
951
|
-
/** @type {{start: number, end: number}[]} */
|
|
952
|
-
const ranges = []
|
|
953
|
-
for (const secret of secrets) {
|
|
954
|
-
let offset = 0
|
|
955
|
-
while (offset <= text.length - secret.length) {
|
|
956
|
-
const index = text.indexOf(secret, offset)
|
|
957
|
-
if (index < 0) break
|
|
958
|
-
ranges.push({start: index, end: index + secret.length})
|
|
959
|
-
offset = index + 1
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
ranges.sort((left, right) => left.start - right.start || left.end - right.end)
|
|
963
|
-
/** @type {{start: number, end: number}[]} */
|
|
964
|
-
const merged = []
|
|
965
|
-
for (const range of ranges) {
|
|
966
|
-
const previous = merged.at(-1)
|
|
967
|
-
if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end)
|
|
968
|
-
else merged.push({...range})
|
|
969
|
-
}
|
|
970
|
-
return merged
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
/** @param {string} text @param {{start: number, end: number}[]} ranges @param {number} end */
|
|
974
|
-
function applyRedactionRanges(text, ranges, end) {
|
|
975
|
-
let output = ""
|
|
976
|
-
let offset = 0
|
|
977
|
-
for (const range of ranges) {
|
|
978
|
-
if (range.start >= end) break
|
|
979
|
-
output += `${text.slice(offset, range.start)}[REDACTED]`
|
|
980
|
-
offset = range.end
|
|
981
|
-
}
|
|
982
|
-
return output + text.slice(offset, end)
|
|
983
|
-
}
|
|
984
|
-
|
|
985
1121
|
/** @param {string} text @param {string[]} secrets @param {number} safeEnd */
|
|
986
1122
|
function redactStablePrefix(text, secrets, safeEnd) {
|
|
987
1123
|
const ranges = redactionRanges(text, secrets)
|
package/src/isolated-runtime.js
CHANGED
|
@@ -1757,7 +1757,7 @@ function validWorkerSecurity(config, host, provider = "codex") {
|
|
|
1757
1757
|
]
|
|
1758
1758
|
return expected.every((name) => names.filter((entry) => entry === name).length === 1)
|
|
1759
1759
|
&& config.Env.includes("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
|
|
1760
|
-
&& config.Env.includes("NODE_VERSION=
|
|
1760
|
+
&& config.Env.includes("NODE_VERSION=24.19.0") && config.Env.includes("YARN_VERSION=1.22.22")
|
|
1761
1761
|
}
|
|
1762
1762
|
|
|
1763
1763
|
function ownedNetworkIdentity(network, labels, identity, brokerContainer) {
|
package/src/kimi-model-broker.js
CHANGED
|
@@ -25,6 +25,9 @@ const UPSTREAM_URL = "https://api.kimi.com/coding/v1/chat/completions"
|
|
|
25
25
|
export async function startKimiModelBroker(options = {}) {
|
|
26
26
|
const environment = options.environment ?? process.env
|
|
27
27
|
const host = safeHost(environment.THREADWIRE_KIMI_MODEL_BROKER_HOST ?? "0.0.0.0")
|
|
28
|
+
// Port 0 is accepted for test/development use: the broker binds an
|
|
29
|
+
// OS-assigned ephemeral port and exposes the actual selected port in the
|
|
30
|
+
// returned config. Production deployments normally use the fixed defaults.
|
|
28
31
|
const port = portValue(environment.THREADWIRE_KIMI_MODEL_BROKER_PORT, 8791)
|
|
29
32
|
const workerHost = safeHost(environment.THREADWIRE_KIMI_MODEL_BROKER_WORKER_HOST ?? "0.0.0.0")
|
|
30
33
|
const workerPort = portValue(environment.THREADWIRE_KIMI_MODEL_BROKER_WORKER_PORT, 8792)
|
|
@@ -143,10 +146,23 @@ export async function startKimiModelBroker(options = {}) {
|
|
|
143
146
|
socket.once("close", () => controlSockets.delete(socket))
|
|
144
147
|
})
|
|
145
148
|
await listen(workerServer, workerPort, workerHost)
|
|
149
|
+
const workerAddress = workerServer.address()
|
|
150
|
+
if (workerAddress === null || typeof workerAddress === "string") {
|
|
151
|
+
await boundedClose(workerServer)
|
|
152
|
+
throw new Error("Kimi model broker worker listener address unavailable")
|
|
153
|
+
}
|
|
154
|
+
const actualWorkerPort = workerAddress.port
|
|
146
155
|
try { await listen(server, port, host) } catch (error) {
|
|
147
156
|
await boundedClose(workerServer)
|
|
148
157
|
throw error
|
|
149
158
|
}
|
|
159
|
+
const address = server.address()
|
|
160
|
+
if (address === null || typeof address === "string") {
|
|
161
|
+
await boundedClose(server)
|
|
162
|
+
await boundedClose(workerServer)
|
|
163
|
+
throw new Error("Kimi model broker listener address unavailable")
|
|
164
|
+
}
|
|
165
|
+
const actualPort = address.port
|
|
150
166
|
return {
|
|
151
167
|
server,
|
|
152
168
|
workerServer,
|
|
@@ -159,7 +175,7 @@ export async function startKimiModelBroker(options = {}) {
|
|
|
159
175
|
for (const socket of workerSockets) socket.destroy()
|
|
160
176
|
await Promise.all([boundedClose(server), boundedClose(workerServer)])
|
|
161
177
|
},
|
|
162
|
-
config: {host, port, workerHost, workerPort, workerBind, models: [...approvedModels.keys()]}
|
|
178
|
+
config: {host, port: actualPort, workerHost, workerPort: actualWorkerPort, workerBind, models: [...approvedModels.keys()]}
|
|
163
179
|
}
|
|
164
180
|
}
|
|
165
181
|
|
|
@@ -326,7 +342,7 @@ function bindSetting(value, name) {
|
|
|
326
342
|
function portValue(value, fallback) {
|
|
327
343
|
if (value === undefined) return fallback
|
|
328
344
|
const number = Number(value)
|
|
329
|
-
if (!/^\d+$/u.test(value) || !Number.isSafeInteger(number) || number <
|
|
345
|
+
if (!/^\d+$/u.test(value) || !Number.isSafeInteger(number) || number < 0 || number > 65535) throw new Error("Invalid Kimi model broker port")
|
|
330
346
|
return number
|
|
331
347
|
}
|
|
332
348
|
/** @param {string | null} value @returns {string} */
|
package/src/redaction.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded literal-secret redaction helpers.
|
|
3
|
+
*
|
|
4
|
+
* These are used by both evidence artifacts and the per-run SQLite index, so
|
|
5
|
+
* they live in a separate module to avoid a circular dependency between the
|
|
6
|
+
* store and index implementations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @param {string} text @param {string[]} secrets */
|
|
10
|
+
export function redactText(text, secrets) {
|
|
11
|
+
const merged = redactionRanges(text, secrets)
|
|
12
|
+
if (merged.length === 0) return {text, redacted: false}
|
|
13
|
+
return {text: applyRedactionRanges(text, merged, text.length), redacted: true}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @param {string} text @param {string[]} secrets */
|
|
17
|
+
export function redactionRanges(text, secrets) {
|
|
18
|
+
/** @type {{start: number, end: number}[]} */
|
|
19
|
+
const ranges = []
|
|
20
|
+
for (const secret of secrets) {
|
|
21
|
+
let offset = 0
|
|
22
|
+
while (offset <= text.length - secret.length) {
|
|
23
|
+
const index = text.indexOf(secret, offset)
|
|
24
|
+
if (index < 0) break
|
|
25
|
+
ranges.push({start: index, end: index + secret.length})
|
|
26
|
+
offset = index + 1
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
ranges.sort((left, right) => left.start - right.start || left.end - right.end)
|
|
30
|
+
/** @type {{start: number, end: number}[]} */
|
|
31
|
+
const merged = []
|
|
32
|
+
for (const range of ranges) {
|
|
33
|
+
const previous = merged.at(-1)
|
|
34
|
+
if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end)
|
|
35
|
+
else merged.push({...range})
|
|
36
|
+
}
|
|
37
|
+
return merged
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {string} text @param {{start: number, end: number}[]} ranges @param {number} end */
|
|
41
|
+
export function applyRedactionRanges(text, ranges, end) {
|
|
42
|
+
let output = ""
|
|
43
|
+
let offset = 0
|
|
44
|
+
for (const range of ranges) {
|
|
45
|
+
if (range.start >= end) break
|
|
46
|
+
output += `${text.slice(offset, range.start)}[REDACTED]`
|
|
47
|
+
offset = range.end
|
|
48
|
+
}
|
|
49
|
+
return output + text.slice(offset, end)
|
|
50
|
+
}
|
package/src/run-worker.js
CHANGED
|
@@ -27,13 +27,15 @@ export function runWorker(options) {
|
|
|
27
27
|
const spawnImplementation = options.spawnImplementation ?? nodeSpawn
|
|
28
28
|
const killProcess = options.killProcess ?? ((pid, signal) => process.kill(pid, signal))
|
|
29
29
|
// Attempt-owned liveness probe: signal 0 against the exact process group.
|
|
30
|
-
// Never a name match or a broad process scan.
|
|
30
|
+
// Never a name match or a broad process scan. Only ESRCH/EINVAL mean the
|
|
31
|
+
// group is actually gone; EPERM means a process exists but we cannot signal
|
|
32
|
+
// it, so it must be treated as alive to avoid false settlement.
|
|
31
33
|
const probeProcessGroup = options.probeProcessGroup ?? ((pid) => {
|
|
32
34
|
try {
|
|
33
35
|
process.kill(-pid, 0)
|
|
34
36
|
return true
|
|
35
|
-
} catch {
|
|
36
|
-
return
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return !isProcessGoneError(error)
|
|
37
39
|
}
|
|
38
40
|
})
|
|
39
41
|
const setTimer = options.setTimer ?? ((callback, milliseconds) => setTimeout(callback, milliseconds))
|
|
@@ -491,6 +493,11 @@ function signalExitCode(signal) {
|
|
|
491
493
|
return 128 + (osConstants.signals[signal] ?? 0)
|
|
492
494
|
}
|
|
493
495
|
|
|
496
|
+
/** @param {unknown} error @returns {boolean} */
|
|
497
|
+
function isProcessGoneError(error) {
|
|
498
|
+
return error instanceof Error && "code" in error && (error.code === "ESRCH" || error.code === "EINVAL")
|
|
499
|
+
}
|
|
500
|
+
|
|
494
501
|
/** @param {unknown} value @returns {value is number} */
|
|
495
502
|
function isPositiveSafeInteger(value) {
|
|
496
503
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
@@ -4,6 +4,7 @@ import {createHash, randomUUID, timingSafeEqual} from "node:crypto"
|
|
|
4
4
|
import {abortable} from "../absolute-deadline.js"
|
|
5
5
|
import {DelegatedResultAdmission} from "../delegated-result-admission.js"
|
|
6
6
|
import {evidenceTelegramDestination} from "../evidence-store.js"
|
|
7
|
+
import {workerEventToIndexEvent} from "../evidence-index.js"
|
|
7
8
|
import {NoticeQueue} from "../notice-queue.js"
|
|
8
9
|
import {createFetchTransport} from "../notifiers/fetch-transport.js"
|
|
9
10
|
import {createTelegramSender} from "../notifiers/telegram.js"
|
|
@@ -164,7 +165,13 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
164
165
|
runId: deliveryIdentity
|
|
165
166
|
})
|
|
166
167
|
if (evidenceOwner !== undefined) {
|
|
168
|
+
if (typeof dependencies.evidenceStore?.ensureRunIndex === "function") {
|
|
169
|
+
dependencies.evidenceStore.ensureRunIndex(evidenceOwner, {provider: job.provider})
|
|
170
|
+
}
|
|
167
171
|
const redactions = await collectEvidenceRedactions(dependencies.providerEnvironment ?? {})
|
|
172
|
+
if (typeof dependencies.evidenceStore?.setRunIndexRedactions === "function") {
|
|
173
|
+
dependencies.evidenceStore.setRunIndexRedactions(evidenceOwner, redactions)
|
|
174
|
+
}
|
|
168
175
|
evidence = await dependencies.evidenceStore?.createArtifact(evidenceOwner, {
|
|
169
176
|
contentType: "text/plain; charset=utf-8",
|
|
170
177
|
redactions
|
|
@@ -188,6 +195,7 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
188
195
|
/** @param {import("../types.js").WorkerEvent} event */
|
|
189
196
|
const acceptEvent = async (event) => {
|
|
190
197
|
if (event.type === "text-delta") admission.acceptConclusionEvent(event)
|
|
198
|
+
await recordEvidenceEvent(workerEventToIndexEvent(event, job.provider))
|
|
191
199
|
try {
|
|
192
200
|
await control.accept(event)
|
|
193
201
|
} catch (error) {
|
|
@@ -204,6 +212,11 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
204
212
|
dependencies.activity?.recordSession(provider.name, id)
|
|
205
213
|
}
|
|
206
214
|
}
|
|
215
|
+
/** @param {import("../evidence-index.js").EvidenceIndexEvent} event */
|
|
216
|
+
const recordEvidenceEvent = (event) => {
|
|
217
|
+
if (evidenceOwner === undefined || typeof dependencies.evidenceStore?.recordEvent !== "function") return Promise.resolve()
|
|
218
|
+
return dependencies.evidenceStore.recordEvent(evidenceOwner, event)
|
|
219
|
+
}
|
|
207
220
|
/** @param {number} exitCode */
|
|
208
221
|
const finalizeEvidence = async (exitCode) => {
|
|
209
222
|
if (resultEvidence === undefined || resultEvidenceFinalized) return
|
|
@@ -223,9 +236,23 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
223
236
|
state: /** @type {"completed" | "failed"} */ (exitCode === 0 ? "completed" : "failed"),
|
|
224
237
|
exitCode
|
|
225
238
|
}
|
|
226
|
-
|
|
239
|
+
const providerResult = `${JSON.stringify(admission.createProviderResult(terminal))}\n`
|
|
240
|
+
await resultEvidence.append("provider-result", providerResult)
|
|
241
|
+
await recordEvidenceEvent({
|
|
242
|
+
kind: "provider_result",
|
|
243
|
+
artifactId: resultEvidence.metadata?.id,
|
|
244
|
+
provider: job.provider,
|
|
245
|
+
metadata: {terminal, length: Buffer.byteLength(providerResult, "utf8")}
|
|
246
|
+
})
|
|
227
247
|
await resultEvidence.finalize()
|
|
228
248
|
resultEvidenceFinalized = true
|
|
249
|
+
if (evidenceOwner !== undefined && typeof dependencies.evidenceStore?.finalizeRunIndex === "function") {
|
|
250
|
+
try {
|
|
251
|
+
await dependencies.evidenceStore.finalizeRunIndex(evidenceOwner, terminal)
|
|
252
|
+
} catch (error) {
|
|
253
|
+
evidenceError ??= error
|
|
254
|
+
}
|
|
255
|
+
}
|
|
229
256
|
}
|
|
230
257
|
const finishDelivery = async () => {
|
|
231
258
|
if (resultEvidence !== undefined && deliveryError === undefined) {
|
|
@@ -254,7 +281,16 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
254
281
|
}
|
|
255
282
|
let evidenceTransferred = false
|
|
256
283
|
try {
|
|
257
|
-
|
|
284
|
+
if (evidence !== undefined) {
|
|
285
|
+
const promptEvidence = `prompt\n${job.prompt}\nprovider-stream\n`
|
|
286
|
+
await evidence.append("prompt", promptEvidence)
|
|
287
|
+
await recordEvidenceEvent({
|
|
288
|
+
kind: "prompt",
|
|
289
|
+
artifactId: evidence.metadata?.id,
|
|
290
|
+
provider: job.provider,
|
|
291
|
+
metadata: {length: Buffer.byteLength(promptEvidence, "utf8")}
|
|
292
|
+
})
|
|
293
|
+
}
|
|
258
294
|
|
|
259
295
|
if (isolatedRuntimeClient !== undefined) {
|
|
260
296
|
evidenceTransferred = true
|
|
@@ -277,8 +313,20 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
277
313
|
onEvent: acceptEvent,
|
|
278
314
|
onRecord: async (record) => acceptRecord(record),
|
|
279
315
|
...(job.provider === "kimi" ? {} : {
|
|
280
|
-
onStdoutChunk: (chunk) =>
|
|
281
|
-
|
|
316
|
+
onStdoutChunk: (chunk) => {
|
|
317
|
+
if (evidence === undefined) return undefined
|
|
318
|
+
return Promise.all([
|
|
319
|
+
evidence.append("provider-stdout", chunk),
|
|
320
|
+
recordEvidenceEvent({kind: "provider_stdout", artifactId: evidence.metadata?.id, provider: job.provider, metadata: {length: chunk.length}})
|
|
321
|
+
]).then(() => {})
|
|
322
|
+
},
|
|
323
|
+
onStderrChunk: (chunk) => {
|
|
324
|
+
if (evidence === undefined) return undefined
|
|
325
|
+
return Promise.all([
|
|
326
|
+
evidence.append("provider-stderr", chunk),
|
|
327
|
+
recordEvidenceEvent({kind: "provider_stderr", artifactId: evidence.metadata?.id, provider: job.provider, metadata: {length: chunk.length}})
|
|
328
|
+
]).then(() => {})
|
|
329
|
+
}
|
|
282
330
|
})
|
|
283
331
|
})
|
|
284
332
|
let exitCode = 2
|
|
@@ -382,8 +430,20 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
382
430
|
spawnGate.resolve()
|
|
383
431
|
},
|
|
384
432
|
onRecord: async (record) => acceptRecord(record),
|
|
385
|
-
onStdoutChunk: (chunk) =>
|
|
386
|
-
|
|
433
|
+
onStdoutChunk: (chunk) => {
|
|
434
|
+
if (evidence === undefined) return undefined
|
|
435
|
+
return Promise.all([
|
|
436
|
+
evidence.append("provider-stdout", chunk),
|
|
437
|
+
recordEvidenceEvent({kind: "provider_stdout", artifactId: evidence.metadata?.id, provider: job.provider, metadata: {length: chunk.length}})
|
|
438
|
+
]).then(() => {})
|
|
439
|
+
},
|
|
440
|
+
onStderrChunk: (chunk) => {
|
|
441
|
+
if (evidence === undefined) return undefined
|
|
442
|
+
return Promise.all([
|
|
443
|
+
evidence.append("provider-stderr", chunk),
|
|
444
|
+
recordEvidenceEvent({kind: "provider_stderr", artifactId: evidence.metadata?.id, provider: job.provider, metadata: {length: chunk.length}})
|
|
445
|
+
]).then(() => {})
|
|
446
|
+
}
|
|
387
447
|
})
|
|
388
448
|
|
|
389
449
|
// Detached observer: never resolves the spawn gate from process exit — only
|