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.
@@ -1,7 +1,8 @@
1
1
  // @ts-check
2
2
 
3
- import {isAbsolute, normalize} from "node:path"
3
+ import {isAbsolute, join, normalize} from "node:path"
4
4
  import {parseThreadwireBinding} from "./threadwire-binding.js"
5
+ import {applyMutationPolicyDefaults} from "./mutation-policy.js"
5
6
 
6
7
  const DIGEST_IMAGE = /^(?:[^@\s]+@)?sha256:[0-9a-f]{64}$/u
7
8
  const SAFE_VALUE = /^[^\0\r\n]+$/u
@@ -18,6 +19,7 @@ const MAX_PROMPT_LENGTH = 524_288
18
19
  * resumeSession?: string,
19
20
  * brokerUrl?: string
20
21
  * worktreeVolume?: {name: string, subpath: string}
22
+ * gitOverlayVolume?: {name: string}
21
23
  * workingDirectory: string,
22
24
  * providerArguments: string[],
23
25
  * model?: string,
@@ -29,7 +31,8 @@ const MAX_PROMPT_LENGTH = 524_288
29
31
  * namespace?: string
30
32
  * runId?: string
31
33
  * taskHash?: string
32
- * runSeal?: string
34
+ * runSeal?: string,
35
+ * mutationPolicy?: import("./mutation-policy.js").MutationPolicy
33
36
  * }} WorkerSpecOptions
34
37
  */
35
38
 
@@ -48,6 +51,9 @@ export function buildWorkerContainerSpec(options) {
48
51
  if (!validPrompt(options.prompt)) throw new Error("Worker prompt is invalid")
49
52
  if (options.resumeSession !== undefined && !SAFE_VALUE.test(options.resumeSession)) throw new Error("Worker resume session is invalid")
50
53
  if (!isAbsolute(options.workingDirectory) || !withinWorktree(options.workingDirectory)) throw new Error("Worker cwd is invalid")
54
+ const policy = applyMutationPolicyDefaults(options.mutationPolicy)
55
+ const worktreeReadOnly = !policy.worktreeEdit
56
+ const gitOverlayReadOnly = policy.worktreeEdit && !policy.commit
51
57
  const commonEnvironment = [
52
58
  "HOME=/home/worker",
53
59
  "TMPDIR=/tmp",
@@ -62,23 +68,44 @@ export function buildWorkerContainerSpec(options) {
62
68
  `OPENAI_BASE_URL=${options.brokerUrl ?? "http://model-broker:8789/v1"}`,
63
69
  `OPENAI_API_KEY=${options.brokerToken}`,
64
70
  `CODEX_API_KEY=${options.brokerToken}`,
65
- `THREADWIRE_CODEX_ARGUMENTS=${JSON.stringify(options.providerArguments)}`
71
+ `THREADWIRE_CODEX_ARGUMENTS=${JSON.stringify(options.providerArguments)}`,
72
+ ...(gitOverlayReadOnly ? ["THREADWIRE_GIT_OVERLAY_READ_ONLY=true"] : [])
66
73
  ]
67
74
  const worktreeMount = options.worktreeVolume === undefined
68
75
  ? {
69
76
  Type: "bind",
70
77
  Source: options.worktree,
71
78
  Target: "/worktree",
72
- ReadOnly: false,
79
+ ReadOnly: worktreeReadOnly,
73
80
  BindOptions: {Propagation: "rprivate"}
74
81
  }
75
82
  : {
76
83
  Type: "volume",
77
84
  Source: options.worktreeVolume.name,
78
85
  Target: "/worktree",
79
- ReadOnly: false,
86
+ ReadOnly: worktreeReadOnly,
80
87
  VolumeOptions: {Subpath: options.worktreeVolume.subpath}
81
88
  }
89
+ const mounts = [worktreeMount, {Type: "volume", Source: options.stateVolume, Target: "/home/worker", ReadOnly: false}]
90
+ if (gitOverlayReadOnly) {
91
+ if (options.worktreeVolume === undefined) {
92
+ mounts.push({
93
+ Type: "bind",
94
+ Source: join(options.worktree, ".git"),
95
+ Target: "/worktree/.git",
96
+ ReadOnly: true,
97
+ BindOptions: {Propagation: "rprivate"}
98
+ })
99
+ } else {
100
+ if (options.gitOverlayVolume === undefined) throw new Error("Git metadata volume is required for read-only overlay in volume mode")
101
+ mounts.push({
102
+ Type: "volume",
103
+ Source: options.gitOverlayVolume.name,
104
+ Target: "/worktree/.git",
105
+ ReadOnly: true
106
+ })
107
+ }
108
+ }
82
109
  return {
83
110
  Image: options.image,
84
111
  Labels: {
@@ -101,10 +128,7 @@ export function buildWorkerContainerSpec(options) {
101
128
  NetworkDisabled: false,
102
129
  HostConfig: {
103
130
  AutoRemove: false,
104
- Mounts: [
105
- worktreeMount,
106
- {Type: "volume", Source: options.stateVolume, Target: "/home/worker", ReadOnly: false}
107
- ],
131
+ Mounts: mounts,
108
132
  CapDrop: ["ALL"],
109
133
  NetworkMode: options.networkName,
110
134
  ReadonlyRootfs: true,
@@ -151,6 +175,12 @@ export function buildKimiWorkerContainerSpec(options) {
151
175
  const user = `${binding.runtime.uid}:${binding.runtime.gid}`
152
176
  const uid = binding.runtime.uid
153
177
  const gid = binding.runtime.gid
178
+ const policy = applyMutationPolicyDefaults(options.mutationPolicy)
179
+ // Kimi source is a single named volume and the entrypoint rejects any nested
180
+ // mount beneath it. The only filesystem boundary that prevents git metadata
181
+ // mutation is a read-only source mount, so denying commits also makes the
182
+ // source read-only; callers that need edits must allow both edit and commit.
183
+ const effectiveSourceReadOnly = binding.source.readOnly || !policy.worktreeEdit || !policy.commit
154
184
  const environment = [
155
185
  "PATH=/usr/local/bin:/usr/bin:/bin",
156
186
  "NODE_VERSION=",
@@ -165,7 +195,7 @@ export function buildKimiWorkerContainerSpec(options) {
165
195
  `THREADWIRE_SOURCE_TARGET=${binding.source.target}`,
166
196
  `THREADWIRE_CONTEXT_TARGET=${binding.context.target}`,
167
197
  `THREADWIRE_TASK_ID=${binding.taskId}`,
168
- `THREADWIRE_SOURCE_READ_ONLY=${binding.source.readOnly}`,
198
+ `THREADWIRE_SOURCE_READ_ONLY=${effectiveSourceReadOnly}`,
169
199
  `THREADWIRE_EXPECTED_REVISION=${binding.source.revision}`,
170
200
  `THREADWIRE_CONTEXT_MANIFEST_DIGEST=${binding.context.digests.manifest}`,
171
201
  `THREADWIRE_CONTEXT_CONTENT_DIGEST=${binding.context.digests.content}`,
@@ -183,7 +213,7 @@ export function buildKimiWorkerContainerSpec(options) {
183
213
  HostConfig: {
184
214
  ...workerHostConfig(options.networkName, uid, gid),
185
215
  Mounts: [
186
- {Type: "volume", Source: binding.source.volume, Target: binding.source.target, ReadOnly: binding.source.readOnly},
216
+ {Type: "volume", Source: binding.source.volume, Target: binding.source.target, ReadOnly: effectiveSourceReadOnly},
187
217
  {Type: "volume", Source: binding.context.volume, Target: binding.context.target, ReadOnly: true},
188
218
  {Type: "volume", Source: options.stateVolume, Target: "/state", ReadOnly: false}
189
219
  ]
@@ -191,6 +221,104 @@ export function buildKimiWorkerContainerSpec(options) {
191
221
  }
192
222
  }
193
223
 
224
+ /**
225
+ * Build an offline, credential-free helper container that snapshots a Kimi
226
+ * source volume using the runtime image's git snapshot script. The helper has
227
+ * no network, no secrets, and mounts only the source volume read-only. It runs
228
+ * as the binding's runtime identity so it can read the source volume.
229
+ * @param {{image: string, sourceVolume: string, sourceTarget: string, name: string, labels: Record<string, string>, uid: number, gid: number}} options
230
+ */
231
+ export function buildKimiSourceSnapshotSpec(options) {
232
+ if (!DIGEST_IMAGE.test(options.image)) throw new Error("Snapshot image must use an immutable digest")
233
+ if (!SAFE_VALUE.test(options.sourceVolume)) throw new Error("Snapshot source volume is invalid")
234
+ if (!SAFE_VALUE.test(options.sourceTarget)) throw new Error("Snapshot source target is invalid")
235
+ if (!SAFE_VALUE.test(options.name)) throw new Error("Snapshot name is invalid")
236
+ if (!plainStringRecord(options.labels)) throw new Error("Snapshot labels are invalid")
237
+ if (!Number.isSafeInteger(options.uid) || options.uid < 1) throw new Error("Snapshot uid is invalid")
238
+ if (!Number.isSafeInteger(options.gid) || options.gid < 1) throw new Error("Snapshot gid is invalid")
239
+ return {
240
+ Image: options.image,
241
+ Labels: {...options.labels},
242
+ Entrypoint: ["node"],
243
+ Cmd: ["/opt/threadwire/docker/kimi-source-snapshot.mjs", options.sourceTarget],
244
+ Env: [
245
+ "HOME=/tmp",
246
+ "PATH=/usr/local/bin:/usr/bin:/bin"
247
+ ],
248
+ WorkingDir: "/tmp",
249
+ User: `${options.uid}:${options.gid}`,
250
+ NetworkDisabled: true,
251
+ HostConfig: {
252
+ AutoRemove: false,
253
+ Mounts: [
254
+ {Type: "volume", Source: options.sourceVolume, Target: options.sourceTarget, ReadOnly: true}
255
+ ],
256
+ CapDrop: ["ALL"],
257
+ NetworkMode: "none",
258
+ ReadonlyRootfs: true,
259
+ SecurityOpt: ["no-new-privileges"],
260
+ PidsLimit: 32,
261
+ Memory: 268_435_456,
262
+ NanoCpus: 500_000_000,
263
+ Tmpfs: {"/tmp": `rw,noexec,nosuid,nodev,size=33554432,uid=${options.uid},gid=${options.gid}`},
264
+ Ulimits: [
265
+ {Name: "nofile", Soft: 256, Hard: 256},
266
+ {Name: "core", Soft: 0, Hard: 0},
267
+ {Name: "fsize", Soft: 67_108_864, Hard: 67_108_864}
268
+ ]
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Build an offline, credential-free helper container that copies a Codex
275
+ * worktree's .git metadata into a per-run Docker volume. The helper mounts only
276
+ * the source worktree volume read-only (at the exact subpath) and the target
277
+ * git-metadata volume read-write. It has no network and carries no credentials.
278
+ * @param {{image: string, sourceVolume: string, sourceSubpath: string, targetVolume: string, name: string, labels: Record<string, string>}} options
279
+ */
280
+ export function buildGitMetadataSnapshotSpec(options) {
281
+ if (!DIGEST_IMAGE.test(options.image)) throw new Error("Git metadata snapshot image must use an immutable digest")
282
+ if (!SAFE_VALUE.test(options.sourceVolume)) throw new Error("Git metadata snapshot source volume is invalid")
283
+ if (!SAFE_VALUE.test(options.sourceSubpath)) throw new Error("Git metadata snapshot source subpath is invalid")
284
+ if (!SAFE_VALUE.test(options.targetVolume)) throw new Error("Git metadata snapshot target volume is invalid")
285
+ if (!SAFE_VALUE.test(options.name)) throw new Error("Git metadata snapshot name is invalid")
286
+ if (!plainStringRecord(options.labels)) throw new Error("Git metadata snapshot labels are invalid")
287
+ return {
288
+ Image: options.image,
289
+ Labels: {...options.labels},
290
+ Entrypoint: ["node"],
291
+ Cmd: ["/opt/threadwire/docker/git-metadata-snapshot.mjs", "/source", "/git-metadata"],
292
+ Env: [
293
+ "HOME=/tmp",
294
+ "PATH=/usr/local/bin:/usr/bin:/bin"
295
+ ],
296
+ WorkingDir: "/tmp",
297
+ User: "0:0",
298
+ NetworkDisabled: true,
299
+ HostConfig: {
300
+ AutoRemove: false,
301
+ Mounts: [
302
+ {Type: "volume", Source: options.sourceVolume, Target: "/source", ReadOnly: true, VolumeOptions: {Subpath: options.sourceSubpath}},
303
+ {Type: "volume", Source: options.targetVolume, Target: "/git-metadata", ReadOnly: false}
304
+ ],
305
+ CapDrop: ["ALL"],
306
+ NetworkMode: "none",
307
+ ReadonlyRootfs: true,
308
+ SecurityOpt: ["no-new-privileges"],
309
+ PidsLimit: 32,
310
+ Memory: 268_435_456,
311
+ NanoCpus: 500_000_000,
312
+ Tmpfs: {"/tmp": "rw,noexec,nosuid,nodev,size=33554432,uid=0,gid=0"},
313
+ Ulimits: [
314
+ {Name: "nofile", Soft: 256, Hard: 256},
315
+ {Name: "core", Soft: 0, Hard: 0},
316
+ {Name: "fsize", Soft: 67_108_864, Hard: 67_108_864}
317
+ ]
318
+ }
319
+ }
320
+ }
321
+
194
322
  /**
195
323
  * A credential-free, offline validator run before a Kimi broker grant exists.
196
324
  * @param {{image: string, binding: unknown, labels: Record<string, string>}} options
@@ -11,8 +11,19 @@ const ALLOWED_HEADERS = new Set([
11
11
  "x-stainless-arch", "x-stainless-lang", "x-stainless-os",
12
12
  "x-stainless-package-version", "x-stainless-runtime",
13
13
  "x-stainless-runtime-version", "x-stainless-retry-count", "x-stainless-timeout",
14
- "x-stainless-async", "originator", "conversation_id", "session_id",
15
- "version", "x-codex-turn-metadata"
14
+ "x-stainless-async", "originator", "conversation_id", "session_id", "session-id",
15
+ "version", "x-codex-turn-metadata",
16
+ // Codex CLI Responses API headers observed from @openai/codex 0.147.0. These are replayed
17
+ // to the upstream OpenAI endpoint only when present; the broker still authorizes the
18
+ // capability token and validates the body.
19
+ "openai-beta", "openai-organization", "openai-project",
20
+ "thread-id", "thread_id",
21
+ "x-client-request-id",
22
+ "x-codex-beta-features",
23
+ "x-codex-installation-id", "x-codex-routing-hint", "x-codex-turn-state",
24
+ "x-codex-parent-thread-id", "x-codex-window-id",
25
+ "x-openai-memgen-request", "x-openai-subagent",
26
+ "x-responsesapi-include-timing-metrics", "x-openai-internal-codex-responses-lite"
16
27
  ])
17
28
 
18
29
  /** @param {{now?: () => number, random?: (bytes: number) => Buffer}} [options] */
@@ -84,7 +95,7 @@ const RESPONSE_KEYS = new Set([
84
95
  "model", "input", "instructions", "stream", "tools", "tool_choice",
85
96
  "parallel_tool_calls", "previous_response_id", "reasoning", "store",
86
97
  "include", "metadata", "max_output_tokens", "temperature", "top_p", "truncation",
87
- "prompt_cache_key"
98
+ "prompt_cache_key", "client_metadata"
88
99
  ])
89
100
 
90
101
  /** @param {Buffer} body @param {string} allowedModel */
@@ -29,6 +29,7 @@ export async function startModelBroker(options = {}) {
29
29
  /** @type {Map<string, {controller: AbortController, timer: NodeJS.Timeout, sockets: Set<import("node:net").Socket>}>} */
30
30
  const grantRuntimes = new Map()
31
31
  const fetchImplementation = options.fetchImplementation ?? fetch
32
+ const diagnosticEnabled = /^(?:1|true)$/iu.test(environment.THREADWIRE_MODEL_BROKER_DIAGNOSTIC ?? "")
32
33
  const revokeGrant = async (token) => {
33
34
  grants.revoke(token)
34
35
  const runtime = grantRuntimes.get(token)
@@ -62,7 +63,7 @@ export async function startModelBroker(options = {}) {
62
63
  const listener = createServer((workerRequest, workerResponse) => {
63
64
  proxyWorkerRequest(workerRequest, workerResponse, {
64
65
  grants, token, grant, credential: () => credential, upstream, fetchImplementation,
65
- signal: controller.signal
66
+ signal: controller.signal, diagnosticEnabled
66
67
  }).catch((error) => {
67
68
  if (workerResponse.destroyed) return
68
69
  process.stderr.write(`threadwire-model-broker: ${error instanceof Error ? error.message : "request failed"}\n`)
@@ -187,18 +188,27 @@ async function proxyWorkerRequest(request, response, options) {
187
188
  delete policyHeaders.host
188
189
  delete policyHeaders.connection
189
190
  delete policyHeaders["transfer-encoding"]
190
- validateBrokerRequest({method: request.method, path: request.url, headers: policyHeaders})
191
- const suppliedToken = bearerToken(request.headers.authorization)
192
- if (suppliedToken !== options.token) throw new Error("Broker grant token mismatch")
193
- const grant = options.grants.authorize(suppliedToken, {
194
- provider: "codex", networkId: options.grant.networkId, runId: options.grant.runId
195
- })
196
- const body = await readBody(request, options.signal)
197
- throwIfAborted(options.signal)
198
- options.grants.authorize(suppliedToken, {
199
- provider: "codex", networkId: options.grant.networkId, runId: options.grant.runId
200
- })
201
- validateResponsesBody(body, grant.model)
191
+ /** @type {Buffer | undefined} */
192
+ let body
193
+ try {
194
+ validateBrokerRequest({method: request.method, path: request.url, headers: policyHeaders})
195
+ const suppliedToken = bearerToken(request.headers.authorization)
196
+ if (suppliedToken !== options.token) throw new Error("Broker grant token mismatch")
197
+ const grant = options.grants.authorize(suppliedToken, {
198
+ provider: "codex", networkId: options.grant.networkId, runId: options.grant.runId
199
+ })
200
+ body = await readBody(request, options.signal)
201
+ throwIfAborted(options.signal)
202
+ options.grants.authorize(suppliedToken, {
203
+ provider: "codex", networkId: options.grant.networkId, runId: options.grant.runId
204
+ })
205
+ validateResponsesBody(body, grant.model)
206
+ } catch (error) {
207
+ if (options.diagnosticEnabled) {
208
+ emitBrokerDiagnostic(request.method ?? "", request.url ?? "", policyHeaders, body)
209
+ }
210
+ throw error
211
+ }
202
212
  throwIfAborted(options.signal)
203
213
  const upstreamResponse = await abortable(options.fetchImplementation(new URL("/v1/responses", options.upstream), {
204
214
  method: "POST",
@@ -312,6 +322,33 @@ function isClientError(error) {
312
322
  return error instanceof Error && /denied|grant|Invalid request|capacity/u.test(error.message)
313
323
  }
314
324
 
325
+ /**
326
+ * @param {string} method
327
+ * @param {string} path
328
+ * @param {Record<string, unknown>} headers
329
+ * @param {Buffer | undefined} body
330
+ */
331
+ function emitBrokerDiagnostic(method, path, headers, body) {
332
+ const redacted = {
333
+ method,
334
+ path,
335
+ headers: Object.keys(headers).map((header) => header.toLowerCase()).sort(),
336
+ keys: body === undefined ? [] : topLevelJsonKeys(body.toString("utf8"))
337
+ }
338
+ process.stderr.write(`threadwire-model-broker: diagnostic: ${JSON.stringify(redacted)}\n`)
339
+ }
340
+
341
+ /** @param {string} text */
342
+ function topLevelJsonKeys(text) {
343
+ try {
344
+ const value = JSON.parse(text)
345
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) return Object.keys(value).sort()
346
+ } catch {
347
+ // ignore malformed body
348
+ }
349
+ return []
350
+ }
351
+
315
352
  function isRecord(value) {
316
353
  return typeof value === "object" && value !== null && !Array.isArray(value)
317
354
  }
@@ -0,0 +1,65 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Per-run worker mutation capability policy.
5
+ *
6
+ * The four capabilities are orthogonal:
7
+ * - worktreeEdit: modify files in the checked-out worktree
8
+ * - commit: create local git commits (requires mutating .git metadata)
9
+ * - push: send refs/objects to a remote git repository
10
+ * - githubWrite: mutate GitHub state via the GitHub CLI or API
11
+ *
12
+ * Omitting a capability is not default-deny: callers that do not supply a
13
+ * policy retain existing behavior (all capabilities allowed). The security
14
+ * outcome comes from callers explicitly supplying `false` for a capability.
15
+ */
16
+
17
+ /** @typedef {{worktreeEdit: boolean, commit: boolean, push: boolean, githubWrite: boolean}} MutationPolicy */
18
+
19
+ export const MUTATION_CAPABILITIES = /** @type {const} */ (["worktreeEdit", "commit", "push", "githubWrite"])
20
+
21
+ /**
22
+ * Parse a JSON-encoded mutation policy with a closed schema.
23
+ * Unknown fields and non-boolean values are rejected.
24
+ * @param {string | undefined} value
25
+ * @returns {MutationPolicy | undefined}
26
+ */
27
+ export function parseMutationPolicy(value) {
28
+ if (value === undefined) return undefined
29
+ let parsed
30
+ try {
31
+ parsed = JSON.parse(value)
32
+ } catch {
33
+ throw new Error("Invalid mutation policy JSON")
34
+ }
35
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
36
+ throw new Error("Mutation policy must be an object")
37
+ }
38
+ for (const key of Object.keys(parsed)) {
39
+ if (!MUTATION_CAPABILITIES.includes(/** @type {typeof MUTATION_CAPABILITIES[number]} */ (key))) {
40
+ throw new Error(`Unknown mutation policy field: ${key}`)
41
+ }
42
+ }
43
+ const policy = applyMutationPolicyDefaults(/** @type {Partial<MutationPolicy>} */ (parsed))
44
+ for (const capability of MUTATION_CAPABILITIES) {
45
+ if (typeof policy[capability] !== "boolean") {
46
+ throw new Error(`Mutation policy ${capability} must be a boolean`)
47
+ }
48
+ }
49
+ return policy
50
+ }
51
+
52
+ /**
53
+ * Fill omitted capabilities with the existing-behavior default (allowed).
54
+ * Does not mutate the input object.
55
+ * @param {Partial<MutationPolicy> | undefined} value
56
+ * @returns {MutationPolicy}
57
+ */
58
+ export function applyMutationPolicyDefaults(value) {
59
+ return {
60
+ worktreeEdit: value?.worktreeEdit ?? true,
61
+ commit: value?.commit ?? true,
62
+ push: value?.push ?? true,
63
+ githubWrite: value?.githubWrite ?? true
64
+ }
65
+ }