threadwire 0.1.24 → 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 CHANGED
@@ -2,6 +2,39 @@
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
+
29
+ - Harden `threadwire capacity` edge cases. Kimi quota remaining percent is now
30
+ computed with integer-safe arithmetic so values such as 29 % of 100 no longer
31
+ lose the inclusive reserve boundary to floating-point rounding. Kimi probe
32
+ body-read aborts, timeouts, and transport errors are classified as
33
+ `unavailable`, while oversized bodies and malformed JSON remain `protocol`;
34
+ no token or response detail is leaked. `--timeout-ms` is now rejected above
35
+ Node's maximum timer delay (`2147483647`), matching the existing positive
36
+ integer error convention and updated help text.
37
+
5
38
  - Fix a race in isolated-runtime worker cleanup: Docker's ContainerStop returns
6
39
  HTTP 304 when the owned container exits before the stop call, and
7
40
  ContainerKill returns HTTP 409 when the container exits between the post-stop
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -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",
@@ -75,6 +75,13 @@ export async function abortable(promise, signal) {
75
75
  })
76
76
  }
77
77
 
78
+ export class ResponseCapacityError extends Error {
79
+ constructor() {
80
+ super("Response exceeded capacity")
81
+ this.name = "ResponseCapacityError"
82
+ }
83
+ }
84
+
78
85
  export async function readResponseCapped(response, capacity, signal) {
79
86
  const reader = response.body?.getReader()
80
87
  if (!reader) return Buffer.alloc(0)
@@ -87,8 +94,8 @@ export async function readResponseCapped(response, capacity, signal) {
87
94
  const chunk = Buffer.from(value)
88
95
  size += chunk.length
89
96
  if (size > capacity) {
90
- await reader.cancel(new Error("Response exceeded capacity")).catch(() => {})
91
- throw new Error("Response exceeded capacity")
97
+ await reader.cancel(new ResponseCapacityError()).catch(() => {})
98
+ throw new ResponseCapacityError()
92
99
  }
93
100
  chunks.push(chunk)
94
101
  }
package/src/cli.js CHANGED
@@ -4,7 +4,7 @@ import {lstat, readFile, readlink, realpath} from "node:fs/promises"
4
4
  import {randomUUID} from "node:crypto"
5
5
  import {basename, dirname, isAbsolute, join, normalize, resolve} from "node:path"
6
6
  import {stdin, stderr, stdout} from "node:process"
7
- import {createFetchTransport} from "./notifiers/fetch-transport.js"
7
+ import {createFetchTransport, MAX_TIMER_DELAY_MS} from "./notifiers/fetch-transport.js"
8
8
  import {createTelegramSender, parseTelegramTarget} from "./notifiers/telegram.js"
9
9
  import {createProvider, PROVIDERS} from "./providers/index.js"
10
10
  import {runWorker} from "./run-worker.js"
@@ -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>]
@@ -45,11 +47,11 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
45
47
  (--summary | --events [--kind <kind>] [--after <order>] [--before <order>] [--limit <positive-integer>] | --failures [--after <order>] [--limit <positive-integer>])
46
48
  threadwire capacity [--provider <codex|kimi>]...
47
49
  [--short-reserve-percent <0-100>] [--long-reserve-percent <0-100>]
48
- [--timeout-ms <positive-integer>]
50
+ [--timeout-ms <1-2147483647>]
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
 
@@ -231,7 +236,7 @@ function parseCapacityArguments(arguments_) {
231
236
  providers.push(provider)
232
237
  } else if (option === "--short-reserve-percent") shortReservePercent = reservePercent(value, option)
233
238
  else if (option === "--long-reserve-percent") longReservePercent = reservePercent(value, option)
234
- else if (option === "--timeout-ms") timeoutMs = positiveInteger(value, option)
239
+ else if (option === "--timeout-ms") timeoutMs = capacityTimeoutMs(value, option)
235
240
  else throw new Error(HELP)
236
241
  }
237
242
  return {
@@ -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) {
@@ -843,6 +854,20 @@ function positiveInteger(value, option) {
843
854
  return number
844
855
  }
845
856
 
857
+ /** @param {string} value @param {string} option */
858
+ function capacityTimeoutMs(value, option) {
859
+ const number = positiveInteger(value, option)
860
+ if (number > MAX_TIMER_DELAY_MS) {
861
+ throw new Error(`${option} must be no greater than ${MAX_TIMER_DELAY_MS}`)
862
+ }
863
+ return number
864
+ }
865
+
866
+ /** @param {unknown} value */
867
+ function isRecord(value) {
868
+ return typeof value === "object" && value !== null && !Array.isArray(value)
869
+ }
870
+
846
871
  /** @param {string} value */
847
872
  function sessionId(value) {
848
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
@@ -1,7 +1,7 @@
1
1
  // @ts-nocheck
2
2
  /* eslint-disable jsdoc/require-jsdoc */
3
3
 
4
- import {AbsoluteDeadline, deadlineAfter, abortable, readResponseCapped} from "./absolute-deadline.js"
4
+ import {AbsoluteDeadline, deadlineAfter, abortable, readResponseCapped, ResponseCapacityError} from "./absolute-deadline.js"
5
5
 
6
6
  const MAX_RESPONSE_BYTES = 2_097_152
7
7
  const MAX_RAW_OUTPUT_BYTES = 1_048_576
@@ -207,7 +207,7 @@ export class IsolatedRuntimeClient {
207
207
  try {
208
208
  bytes = await readResponseCapped(response, MAX_RESPONSE_BYTES, signal)
209
209
  } catch (error) {
210
- if (error instanceof Error && error.message === "Response exceeded capacity") throw new Error("Isolated runtime response exceeded the configured capacity", {cause: error})
210
+ if (error instanceof ResponseCapacityError) throw new Error("Isolated runtime response exceeded the configured capacity", {cause: error})
211
211
  throw error
212
212
  }
213
213
  if (!response.ok) throw new Error(safeRemoteError(bytes))