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 +29 -0
- package/docs/delegated-result-protocol.md +7 -0
- package/docs/isolated-provider-runtime.md +13 -1
- 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/src/isolated-worker.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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=${
|
|
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:
|
|
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 */
|
package/src/model-broker.js
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
}
|