threadwire 0.1.25 → 0.1.26
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 +24 -0
- package/docs/delegated-result-protocol.md +7 -0
- package/package.json +1 -1
- package/scripts/verify-package.js +3 -0
- package/src/cli.js +18 -2
- package/src/delegated-result-admission.js +44 -4
- package/src/isolated-runtime.js +367 -68
- package/src/isolated-worker.js +139 -11
- package/src/model-broker-policy.js +14 -3
- package/src/model-broker.js +50 -13
- package/src/mutation-policy.js +65 -0
- package/src/mutation-snapshot.js +307 -0
- package/src/providers/kimi.js +2 -8
- package/src/record-helpers.js +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Enforce per-run worker mutation boundaries in isolated runtime. `threadwire run
|
|
6
|
+
--relay-write` accepts a closed `--mutation-policy <json>` with
|
|
7
|
+
`{worktreeEdit, commit, push, githubWrite}` booleans; omitted capabilities
|
|
8
|
+
default to allowed to preserve existing behavior. The policy is sealed at
|
|
9
|
+
preflight and enforced at container creation: denied worktree edits make the
|
|
10
|
+
worktree mount read-only, denied commits mount `.git` read-only, denied pushes
|
|
11
|
+
run with no remote credentials and no egress, and denied GitHub writes remove
|
|
12
|
+
GitHub CLI/token surfaces. The same policy is now accepted, sealed, and
|
|
13
|
+
enforced for Kimi runs: denied worktree or commit access makes the Kimi source
|
|
14
|
+
volume read-only, push denial keeps the worker free of remote credentials, and
|
|
15
|
+
GitHub-write denial avoids broad GitHub CLI config while preserving the Kimi
|
|
16
|
+
model-broker relay and exact session-resume behavior. Pre/post run git
|
|
17
|
+
snapshots capture HEAD, refs, porcelain status, reflog delta, and
|
|
18
|
+
remote-tracking refs in a credential-free subprocess for both Codex and Kimi;
|
|
19
|
+
Kimi snapshots run through an offline helper container so source Git state can
|
|
20
|
+
be observed without exposing credentials. The runtime reconciles observations
|
|
21
|
+
with the sealed policy and any closed provider `mutation_claim`, failing with
|
|
22
|
+
exit code 2 when a denied mutation occurs or the provider claim contradicts
|
|
23
|
+
observations. The `mutation_claim` parser now requires the exact closed schema
|
|
24
|
+
(`{type, mutations}` with exactly the four boolean capabilities) and rejects
|
|
25
|
+
malformed or multiple claims as deterministic reconciliation failures. Add
|
|
26
|
+
focused unit tests and a Docker E2E fixture covering forbidden commit/push and
|
|
27
|
+
a false "no mutation" provider narrative.
|
|
28
|
+
|
|
5
29
|
- Harden `threadwire capacity` edge cases. Kimi quota remaining percent is now
|
|
6
30
|
computed with integer-safe arithmetic so values such as 29 % of 100 no longer
|
|
7
31
|
lose the inclusive reserve boundary to floating-point rounding. Kimi probe
|
|
@@ -100,6 +100,13 @@ The only optional fields are:
|
|
|
100
100
|
| `artifactHandles` | array of opaque ASCII IDs or safe POSIX-style paths | 16 entries; 512 characters each |
|
|
101
101
|
| `references` | array of `{kind,value}` | 16 entries; 2,048 code points per value |
|
|
102
102
|
| `validationSummary` | string | 2,048 Unicode code points |
|
|
103
|
+
| `observedMutations` | object | closed `{worktreeEdit,commit,push,githubWrite}` booleans |
|
|
104
|
+
|
|
105
|
+
`observedMutations` is populated only by the isolated runtime after reconciling
|
|
106
|
+
pre/post run git snapshots with the sealed per-run mutation policy and any closed
|
|
107
|
+
provider `mutation_claim`. A claim that contradicts the observed state makes the
|
|
108
|
+
terminal state `failed` with exit code 2. When the field is absent, callers must
|
|
109
|
+
not infer any mutation policy.
|
|
103
110
|
|
|
104
111
|
A reference `kind` is exactly `commit` or `url`. Commit values are 7–64
|
|
105
112
|
hexadecimal characters. URL values are non-whitespace HTTP or HTTPS URLs
|
package/package.json
CHANGED
|
@@ -61,6 +61,8 @@ const EXPECTED_FILES = [
|
|
|
61
61
|
"src/model-broker-policy.js",
|
|
62
62
|
"src/model-broker.js",
|
|
63
63
|
"src/mount-policy.js",
|
|
64
|
+
"src/mutation-policy.js",
|
|
65
|
+
"src/mutation-snapshot.js",
|
|
64
66
|
"src/notice-queue.js",
|
|
65
67
|
"src/notifiers/fetch-transport.js",
|
|
66
68
|
"src/notifiers/telegram.js",
|
|
@@ -71,6 +73,7 @@ const EXPECTED_FILES = [
|
|
|
71
73
|
"src/providers/index.js",
|
|
72
74
|
"src/providers/kimi.js",
|
|
73
75
|
"src/providers/opencode.js",
|
|
76
|
+
"src/record-helpers.js",
|
|
74
77
|
"src/redaction.js",
|
|
75
78
|
"src/provider-capacity-codex.js",
|
|
76
79
|
"src/provider-capacity-kimi.js",
|
package/src/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import {ContextBudgetMetrics} from "./context-budget-metrics.js"
|
|
|
18
18
|
import {workerEventToIndexEvent} from "./evidence-index.js"
|
|
19
19
|
import {isolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
|
|
20
20
|
import {validateRelayWriteProviderArguments} from "./relay-write.js"
|
|
21
|
+
import {parseMutationPolicy} from "./mutation-policy.js"
|
|
21
22
|
import {abortable} from "./absolute-deadline.js"
|
|
22
23
|
import {NormalizedOutput} from "./normalized-output.js"
|
|
23
24
|
import {
|
|
@@ -34,6 +35,7 @@ import {probeKimiCapacity} from "./provider-capacity-kimi.js"
|
|
|
34
35
|
const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id> | file:<absolute-path>
|
|
35
36
|
[--process-number <positive-integer>] [--cwd <directory>]
|
|
36
37
|
[--relay-write]
|
|
38
|
+
[--mutation-policy <json>]
|
|
37
39
|
[--tool-messages] [--max-output-length <positive-integer>]
|
|
38
40
|
[--resume-session <provider-session-id>] [--transcript <normalized-jsonl-path>]
|
|
39
41
|
[--activity-log <local-jsonl-path>]
|
|
@@ -49,7 +51,7 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
|
|
|
49
51
|
threadwire status --activity-log <absolute-path>
|
|
50
52
|
(Emits one closed versioned JSON status document from the activity log.)`
|
|
51
53
|
|
|
52
|
-
/** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
|
|
54
|
+
/** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, mutationPolicy?: import("./mutation-policy.js").MutationPolicy | undefined, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
|
|
53
55
|
/** @typedef {{evidenceRead: true, request: unknown} | {evidenceInspect: true, request: unknown}} EvidenceParsedArguments */
|
|
54
56
|
/** @typedef {{capacity: true, providers: import("./provider-capacity.js").CapacityProvider[], shortReservePercent: number, longReservePercent: number, timeoutMs: number}} CapacityParsedArguments */
|
|
55
57
|
/** @typedef {{probe: (provider: import("./provider-capacity.js").CapacityProvider, timeoutMs?: number) => Promise<import("./provider-capacity.js").CapacityCandidate>}} CapacityProbeDependency */
|
|
@@ -114,6 +116,8 @@ export function parseArguments(arguments_) {
|
|
|
114
116
|
if (value.includes("\0")) throw new Error("--transcript path is invalid")
|
|
115
117
|
parsed.transcript = resolve(value)
|
|
116
118
|
transcriptProvided = true
|
|
119
|
+
} else if (option === "--mutation-policy") {
|
|
120
|
+
parsed.mutationPolicy = parseMutationPolicy(value)
|
|
117
121
|
} else throw new Error(`Unknown option: ${option}`)
|
|
118
122
|
index += 2
|
|
119
123
|
}
|
|
@@ -127,6 +131,7 @@ export function parseArguments(arguments_) {
|
|
|
127
131
|
throw new Error("--transcript and --activity-log must resolve to different paths")
|
|
128
132
|
}
|
|
129
133
|
if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
|
|
134
|
+
if (parsed.mutationPolicy !== undefined && !parsed.relayWrite) throw new Error("--mutation-policy requires --relay-write")
|
|
130
135
|
return /** @type {ParsedArguments} */ (parsed)
|
|
131
136
|
}
|
|
132
137
|
|
|
@@ -383,7 +388,8 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
383
388
|
repositoryRoot: parsed.cwd,
|
|
384
389
|
cwd: parsed.cwd,
|
|
385
390
|
providerArguments: parsed.providerArguments,
|
|
386
|
-
...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
|
|
391
|
+
...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession}),
|
|
392
|
+
...(parsed.mutationPolicy === undefined ? {} : {mutationPolicy: parsed.mutationPolicy})
|
|
387
393
|
})
|
|
388
394
|
: undefined
|
|
389
395
|
const admission = /** @type {DelegatedResultAdmission} */ (runAdmission)
|
|
@@ -532,6 +538,11 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
532
538
|
if (activity && pid !== undefined) activity.recordStarted(provider.name, pid)
|
|
533
539
|
},
|
|
534
540
|
onRecord: (record) => {
|
|
541
|
+
const recordObject = /** @type {Record<string, unknown> | undefined} */ (isRecord(record) ? record : undefined)
|
|
542
|
+
if (recordObject !== undefined && recordObject.type === "observed_mutations" && isRecord(recordObject.mutations)) {
|
|
543
|
+
admission.setObservedMutations(/** @type {import("./mutation-policy.js").MutationPolicy} */ (recordObject.mutations))
|
|
544
|
+
return
|
|
545
|
+
}
|
|
535
546
|
metrics.recordParsedProviderRecord("provider_stdout")
|
|
536
547
|
const id = provider.sessionId(record)
|
|
537
548
|
if (id !== undefined) {
|
|
@@ -852,6 +863,11 @@ function capacityTimeoutMs(value, option) {
|
|
|
852
863
|
return number
|
|
853
864
|
}
|
|
854
865
|
|
|
866
|
+
/** @param {unknown} value */
|
|
867
|
+
function isRecord(value) {
|
|
868
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
869
|
+
}
|
|
870
|
+
|
|
855
871
|
/** @param {string} value */
|
|
856
872
|
function sessionId(value) {
|
|
857
873
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import {types as utilTypes} from "node:util"
|
|
4
|
+
import {MUTATION_CAPABILITIES} from "./mutation-policy.js"
|
|
4
5
|
import {sanitizeOutput} from "./relay.js"
|
|
5
6
|
|
|
6
7
|
export const CONCLUSION_LIMIT = 16_384
|
|
@@ -24,7 +25,8 @@ const CANDIDATE_FIELDS = new Set([
|
|
|
24
25
|
"continuationHandle",
|
|
25
26
|
"artifactHandles",
|
|
26
27
|
"references",
|
|
27
|
-
"validationSummary"
|
|
28
|
+
"validationSummary",
|
|
29
|
+
"observedMutations"
|
|
28
30
|
])
|
|
29
31
|
const STATES = new Set(["completed", "failed", "blocked", "needs_decision"])
|
|
30
32
|
|
|
@@ -40,7 +42,8 @@ const STATES = new Set(["completed", "failed", "blocked", "needs_decision"])
|
|
|
40
42
|
* continuationHandle?: string,
|
|
41
43
|
* artifactHandles?: string[],
|
|
42
44
|
* references?: AdmissionReference[],
|
|
43
|
-
* validationSummary?: string
|
|
45
|
+
* validationSummary?: string,
|
|
46
|
+
* observedMutations?: import("./mutation-policy.js").MutationPolicy
|
|
44
47
|
* }} DelegatedResultCandidate
|
|
45
48
|
*/
|
|
46
49
|
|
|
@@ -57,6 +60,8 @@ export class DelegatedResultAdmission {
|
|
|
57
60
|
this.artifactHandles = []
|
|
58
61
|
/** @type {AdmissionReference[]} */
|
|
59
62
|
this.references = []
|
|
63
|
+
/** @type {import("./mutation-policy.js").MutationPolicy | undefined} */
|
|
64
|
+
this.observedMutations = undefined
|
|
60
65
|
this.completed = false
|
|
61
66
|
}
|
|
62
67
|
|
|
@@ -100,6 +105,17 @@ export class DelegatedResultAdmission {
|
|
|
100
105
|
this.artifactHandles = this.artifactHandles.filter((candidate) => candidate !== handle)
|
|
101
106
|
}
|
|
102
107
|
|
|
108
|
+
/** @param {import("./mutation-policy.js").MutationPolicy} value */
|
|
109
|
+
setObservedMutations(value) {
|
|
110
|
+
if (
|
|
111
|
+
typeof value !== "object" || value === null || Array.isArray(value)
|
|
112
|
+
|| !MUTATION_CAPABILITIES.every((capability) => typeof value[capability] === "boolean")
|
|
113
|
+
) {
|
|
114
|
+
throw new Error("Invalid observed mutations")
|
|
115
|
+
}
|
|
116
|
+
this.observedMutations = value
|
|
117
|
+
}
|
|
118
|
+
|
|
103
119
|
/** @param {{state: TerminalState, exitCode: number}} terminal */
|
|
104
120
|
preview(terminal) {
|
|
105
121
|
return this.createEnvelope(terminal)
|
|
@@ -140,7 +156,8 @@ export class DelegatedResultAdmission {
|
|
|
140
156
|
...(terminal.state !== "completed" || safeConclusion === undefined ? {} : {conclusion: safeConclusion}),
|
|
141
157
|
...(this.continuationHandle === undefined ? {} : {continuationHandle: this.continuationHandle}),
|
|
142
158
|
...(this.artifactHandles.length === 0 ? {} : {artifactHandles: this.artifactHandles}),
|
|
143
|
-
...(this.references.length === 0 ? {} : {references: this.references})
|
|
159
|
+
...(this.references.length === 0 ? {} : {references: this.references}),
|
|
160
|
+
...(this.observedMutations === undefined ? {} : {observedMutations: this.observedMutations})
|
|
144
161
|
})
|
|
145
162
|
}
|
|
146
163
|
}
|
|
@@ -166,6 +183,7 @@ export function createDelegatedResultEnvelope(candidate) {
|
|
|
166
183
|
const validationSummary = optionalBoundedString(clonedCandidate.validationSummary, "validationSummary", VALIDATION_SUMMARY_LIMIT)
|
|
167
184
|
const artifactHandles = optionalArtifactHandles(clonedCandidate.artifactHandles)
|
|
168
185
|
const references = optionalReferences(clonedCandidate.references)
|
|
186
|
+
const observedMutations = optionalObservedMutations(clonedCandidate.observedMutations)
|
|
169
187
|
|
|
170
188
|
const envelope = {
|
|
171
189
|
version: 1,
|
|
@@ -178,7 +196,8 @@ export function createDelegatedResultEnvelope(candidate) {
|
|
|
178
196
|
...(continuationHandle === undefined ? {} : {continuationHandle}),
|
|
179
197
|
...(artifactHandles === undefined ? {} : {artifactHandles}),
|
|
180
198
|
...(references === undefined ? {} : {references}),
|
|
181
|
-
...(validationSummary === undefined ? {} : {validationSummary})
|
|
199
|
+
...(validationSummary === undefined ? {} : {validationSummary}),
|
|
200
|
+
...(observedMutations === undefined ? {} : {observedMutations})
|
|
182
201
|
}
|
|
183
202
|
if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > MAX_ENVELOPE_BYTES) {
|
|
184
203
|
throw new Error("Delegated result candidate exceeds the envelope byte limit")
|
|
@@ -256,6 +275,27 @@ function optionalReferences(value) {
|
|
|
256
275
|
})
|
|
257
276
|
}
|
|
258
277
|
|
|
278
|
+
/** @param {unknown} value */
|
|
279
|
+
function optionalObservedMutations(value) {
|
|
280
|
+
if (value === undefined) return undefined
|
|
281
|
+
if (!isPlainObject(value)) throw new Error("Invalid delegated result observedMutations")
|
|
282
|
+
if (!hasExactEnumerableFields(value, [...MUTATION_CAPABILITIES])) {
|
|
283
|
+
throw new Error("Invalid delegated result observedMutations")
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
worktreeEdit: booleanCapability(value.worktreeEdit),
|
|
287
|
+
commit: booleanCapability(value.commit),
|
|
288
|
+
push: booleanCapability(value.push),
|
|
289
|
+
githubWrite: booleanCapability(value.githubWrite)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** @param {unknown} value */
|
|
294
|
+
function booleanCapability(value) {
|
|
295
|
+
if (typeof value !== "boolean") throw new Error("Invalid delegated result observedMutations")
|
|
296
|
+
return value
|
|
297
|
+
}
|
|
298
|
+
|
|
259
299
|
/**
|
|
260
300
|
* @param {unknown} value
|
|
261
301
|
* @param {"artifactHandles" | "references"} field
|