threadwire 0.1.25 → 0.1.27

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,35 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Require `THREADWIRE_KIMI_E2E_OUTER_BROKER_HOST` for
6
+ `npm run test:kimi-isolated-runtime-e2e`, publish the outer broker on a
7
+ Docker-assigned host port, and verify that exact endpoint from a task-DinD
8
+ container before launching the isolated runtime.
9
+
10
+ - Enforce per-run worker mutation boundaries in isolated runtime. `threadwire run
11
+ --relay-write` accepts a closed `--mutation-policy <json>` with
12
+ `{worktreeEdit, commit, push, githubWrite}` booleans; omitted capabilities
13
+ default to allowed to preserve existing behavior. The policy is sealed at
14
+ preflight and enforced at container creation: denied worktree edits make the
15
+ worktree mount read-only, denied commits mount `.git` read-only, denied pushes
16
+ run with no remote credentials and no egress, and denied GitHub writes remove
17
+ GitHub CLI/token surfaces. The same policy is now accepted, sealed, and
18
+ enforced for Kimi runs: denied worktree or commit access makes the Kimi source
19
+ volume read-only, push denial keeps the worker free of remote credentials, and
20
+ GitHub-write denial avoids broad GitHub CLI config while preserving the Kimi
21
+ model-broker relay and exact session-resume behavior. Pre/post run git
22
+ snapshots capture HEAD, refs, porcelain status, reflog delta, and
23
+ remote-tracking refs in a credential-free subprocess for both Codex and Kimi;
24
+ Kimi snapshots run through an offline helper container so source Git state can
25
+ be observed without exposing credentials. The runtime reconciles observations
26
+ with the sealed policy and any closed provider `mutation_claim`, failing with
27
+ exit code 2 when a denied mutation occurs or the provider claim contradicts
28
+ observations. The `mutation_claim` parser now requires the exact closed schema
29
+ (`{type, mutations}` with exactly the four boolean capabilities) and rejects
30
+ malformed or multiple claims as deterministic reconciliation failures. Add
31
+ focused unit tests and a Docker E2E fixture covering forbidden commit/push and
32
+ a false "no mutation" provider narrative.
33
+
5
34
  - Harden `threadwire capacity` edge cases. Kimi quota remaining percent is now
6
35
  computed with integer-safe arithmetic so values such as 29 % of 100 no longer
7
36
  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
@@ -315,7 +315,19 @@ state makes health and preflight fail closed with redacted errors.
315
315
  ### Kimi validation and rollback
316
316
 
317
317
  Run `npm run test:kimi-isolated-runtime-e2e` from an approved Docker supervisor
318
- boundary after supplying the documented immutable image variables. A real
318
+ boundary after supplying the documented immutable image variables and an
319
+ explicit outer broker address:
320
+
321
+ ```sh
322
+ THREADWIRE_KIMI_E2E_OUTER_BROKER_HOST='<outer-host-ipv4>' \
323
+ npm run test:kimi-isolated-runtime-e2e
324
+ ```
325
+
326
+ Replace `<outer-host-ipv4>` with an IPv4 address on the outer Docker host that
327
+ the outer daemon can bind and containers created by the task DinD can reach.
328
+ The E2E asks Docker to allocate the published broker host port, inspects that
329
+ exact binding, and probes it from a throwaway task-DinD container; do not infer
330
+ the address from a default gateway or reserve a fixed host port. A real
319
331
  account smoke must then cover fresh execution, capture of exactly one native
320
332
  session ID, exact resume with that ID, an approved non-default alias if used,
321
333
  Telegram DM/topic routing, cancellation, and confirmation that service logs and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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",
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