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
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {spawn} from "node:child_process"
|
|
4
|
+
import {createHash} from "node:crypto"
|
|
5
|
+
import {createReadStream} from "node:fs"
|
|
6
|
+
import {stat} from "node:fs/promises"
|
|
7
|
+
import {join} from "node:path"
|
|
8
|
+
|
|
9
|
+
const MAX_REFLOG_LINES = 256
|
|
10
|
+
const MAX_STATUS_LINES = 10_000
|
|
11
|
+
const MAX_REF_LINES = 10_000
|
|
12
|
+
const MAX_REMOTE_REF_LINES = 1_000
|
|
13
|
+
const MAX_OUTPUT_BYTES = 1_048_576
|
|
14
|
+
const MAX_IDENTITY_BYTES = 1_048_576
|
|
15
|
+
const SNAPSHOT_HASH_ALGORITHM = "sha256"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {import("./mutation-policy.js").MutationPolicy} MutationPolicy
|
|
19
|
+
* @typedef {{head: {ref?: string, sha: string}, refs: Record<string, string>, status: string[], reflog: string[], remoteRefs: Record<string, string>, identities: Record<string, string>}} GitSnapshot
|
|
20
|
+
* @typedef {{worktreeEdit: boolean, commit: boolean, push: boolean, githubWrite: boolean}} ObservedMutations
|
|
21
|
+
* @typedef {{worktreeEdit: boolean, commit: boolean, push: boolean, githubWrite: boolean}} MutationClaim
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Capture a bounded, credential-free snapshot of a git repository's observable
|
|
26
|
+
* mutation surface. All subprocess output is capped and no config, remote URL,
|
|
27
|
+
* or identity secret is included in the returned snapshot.
|
|
28
|
+
* @param {string} repositoryRoot
|
|
29
|
+
* @param {{gitExecutable?: string, maxOutputBytes?: number, timeoutMs?: number}} [options]
|
|
30
|
+
* @returns {Promise<GitSnapshot>}
|
|
31
|
+
*/
|
|
32
|
+
export async function gitSnapshot(repositoryRoot, options = {}) {
|
|
33
|
+
const git = options.gitExecutable ?? "git"
|
|
34
|
+
const timeoutMs = options.timeoutMs ?? 10_000
|
|
35
|
+
const maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES
|
|
36
|
+
const [head, refs, status, reflog, remoteRefs] = await Promise.all([
|
|
37
|
+
readHead(git, repositoryRoot, timeoutMs, maxOutputBytes),
|
|
38
|
+
readRefs(git, repositoryRoot, timeoutMs, maxOutputBytes),
|
|
39
|
+
readStatus(git, repositoryRoot, timeoutMs, maxOutputBytes),
|
|
40
|
+
readReflog(git, repositoryRoot, timeoutMs, maxOutputBytes),
|
|
41
|
+
readRemoteRefs(git, repositoryRoot, timeoutMs, maxOutputBytes)
|
|
42
|
+
])
|
|
43
|
+
const identities = await readIdentities(repositoryRoot, status)
|
|
44
|
+
return {head, refs, status, reflog, remoteRefs, identities}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Derive the observed mutation flags from two snapshots. Detection is
|
|
49
|
+
* conservative: any observable change in the corresponding git surface is
|
|
50
|
+
* treated as a mutation of that capability. GitHub writes are not observable
|
|
51
|
+
* through git and are always reported as false.
|
|
52
|
+
* @param {GitSnapshot} before
|
|
53
|
+
* @param {GitSnapshot} after
|
|
54
|
+
* @returns {ObservedMutations}
|
|
55
|
+
*/
|
|
56
|
+
export function snapshotMutations(before, after) {
|
|
57
|
+
return {
|
|
58
|
+
worktreeEdit: !arraysEqual(before.status, after.status)
|
|
59
|
+
|| !recordsEqual(before.identities, after.identities),
|
|
60
|
+
commit: before.head.sha !== after.head.sha
|
|
61
|
+
|| !recordsEqual(before.refs, after.refs)
|
|
62
|
+
|| !arraysEqual(before.reflog, after.reflog),
|
|
63
|
+
push: !recordsEqual(before.remoteRefs, after.remoteRefs),
|
|
64
|
+
githubWrite: false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reconcile observed mutations against the sealed per-run policy and an
|
|
70
|
+
* optional closed provider mutation claim. The provider claim contract is a
|
|
71
|
+
* `{type: "mutation_claim", mutations: {...}}` record; any claim that
|
|
72
|
+
* contradicts observations is a reconciliation failure.
|
|
73
|
+
* @param {{policy: MutationPolicy, observed: ObservedMutations, providerExitCode: number, providerClaim?: MutationClaim}} options
|
|
74
|
+
* @returns {{state: "completed" | "failed", exitCode: number, observedMutations: ObservedMutations, blocker?: string}}
|
|
75
|
+
*/
|
|
76
|
+
export function reconcileMutationResult(options) {
|
|
77
|
+
const {policy, observed, providerExitCode, providerClaim} = options
|
|
78
|
+
const denied = /** @type {(keyof MutationPolicy)[]} */ (Object.keys(policy)).filter((capability) => policy[capability] === false && observed[capability])
|
|
79
|
+
if (denied.length > 0) {
|
|
80
|
+
return {
|
|
81
|
+
state: "failed",
|
|
82
|
+
exitCode: 2,
|
|
83
|
+
observedMutations: observed,
|
|
84
|
+
blocker: `Denied mutation observed: ${denied.join(", ")}`
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (providerClaim !== undefined && !claimMatchesObservations(providerClaim, observed)) {
|
|
88
|
+
return {
|
|
89
|
+
state: "failed",
|
|
90
|
+
exitCode: 2,
|
|
91
|
+
observedMutations: observed,
|
|
92
|
+
blocker: "Provider mutation claim contradicts observed mutations"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
state: providerExitCode === 0 ? "completed" : "failed",
|
|
97
|
+
exitCode: providerExitCode,
|
|
98
|
+
observedMutations: observed
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Hash a snapshot for identity comparison without exposing its content.
|
|
104
|
+
* @param {GitSnapshot} snapshot
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function hashSnapshot(snapshot) {
|
|
108
|
+
const hash = createHash(SNAPSHOT_HASH_ALGORITHM)
|
|
109
|
+
hash.update(JSON.stringify({
|
|
110
|
+
head: snapshot.head,
|
|
111
|
+
refs: snapshot.refs,
|
|
112
|
+
status: snapshot.status,
|
|
113
|
+
reflog: snapshot.reflog,
|
|
114
|
+
remoteRefs: snapshot.remoteRefs,
|
|
115
|
+
identities: snapshot.identities
|
|
116
|
+
}))
|
|
117
|
+
return hash.digest("hex")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {MutationClaim} claim
|
|
122
|
+
* @param {ObservedMutations} observed
|
|
123
|
+
*/
|
|
124
|
+
function claimMatchesObservations(claim, observed) {
|
|
125
|
+
for (const capability of /** @type {(keyof MutationPolicy)[]} */ (Object.keys(observed))) {
|
|
126
|
+
if (claim[capability] !== observed[capability]) return false
|
|
127
|
+
}
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {string[]} first @param {string[]} second */
|
|
132
|
+
function arraysEqual(first, second) {
|
|
133
|
+
if (first.length !== second.length) return false
|
|
134
|
+
for (let index = 0; index < first.length; index += 1) {
|
|
135
|
+
if (first[index] !== second[index]) return false
|
|
136
|
+
}
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @param {Record<string, string>} first @param {Record<string, string>} second */
|
|
141
|
+
function recordsEqual(first, second) {
|
|
142
|
+
const firstKeys = Object.keys(first)
|
|
143
|
+
const secondKeys = Object.keys(second)
|
|
144
|
+
if (firstKeys.length !== secondKeys.length) return false
|
|
145
|
+
for (const key of firstKeys) {
|
|
146
|
+
if (first[key] !== second[key]) return false
|
|
147
|
+
}
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {string} git @param {string} repositoryRoot @param {number} timeoutMs @param {number} maxBytes */
|
|
152
|
+
async function readHead(git, repositoryRoot, timeoutMs, maxBytes) {
|
|
153
|
+
const sha = (await runGit(git, repositoryRoot, ["rev-parse", "HEAD"], timeoutMs, maxBytes)).trim()
|
|
154
|
+
const ref = await runGit(git, repositoryRoot, ["symbolic-ref", "--quiet", "HEAD"], timeoutMs, maxBytes).then((text) => text.trim()).catch(() => undefined)
|
|
155
|
+
return {sha, ...(ref === undefined || ref.length === 0 ? {} : {ref})}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @param {string} git @param {string} repositoryRoot @param {number} timeoutMs @param {number} maxBytes */
|
|
159
|
+
async function readRefs(git, repositoryRoot, timeoutMs, maxBytes) {
|
|
160
|
+
const text = await runGit(git, repositoryRoot, ["for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/tags"], timeoutMs, maxBytes)
|
|
161
|
+
return parseRefLines(text, MAX_REF_LINES)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @param {string} git @param {string} repositoryRoot @param {number} timeoutMs @param {number} maxBytes */
|
|
165
|
+
async function readStatus(git, repositoryRoot, timeoutMs, maxBytes) {
|
|
166
|
+
// Use commands that never refresh or rewrite the index so the snapshot works
|
|
167
|
+
// when .git is mounted read-only. `git status` would try to update the index
|
|
168
|
+
// and fail on a read-only git directory.
|
|
169
|
+
const [tracked, untracked] = await Promise.all([
|
|
170
|
+
runGit(git, repositoryRoot, ["diff", "--name-status", "--no-renames", "HEAD"], timeoutMs, maxBytes),
|
|
171
|
+
runGit(git, repositoryRoot, ["ls-files", "--others", "--exclude-standard"], timeoutMs, maxBytes)
|
|
172
|
+
])
|
|
173
|
+
const lines = boundedLines(tracked, MAX_STATUS_LINES).map((line) => line.replace(/\t/u, " "))
|
|
174
|
+
for (const line of boundedLines(untracked, MAX_STATUS_LINES)) lines.push(`? ${line}`)
|
|
175
|
+
lines.sort()
|
|
176
|
+
if (lines.length > MAX_STATUS_LINES) return lines.slice(0, MAX_STATUS_LINES)
|
|
177
|
+
return lines
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {string} git @param {string} repositoryRoot @param {number} timeoutMs @param {number} maxBytes */
|
|
181
|
+
async function readReflog(git, repositoryRoot, timeoutMs, maxBytes) {
|
|
182
|
+
const text = await runGit(git, repositoryRoot, ["reflog", "--all"], timeoutMs, maxBytes)
|
|
183
|
+
return boundedLines(text, MAX_REFLOG_LINES)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @param {string} git @param {string} repositoryRoot @param {number} timeoutMs @param {number} maxBytes */
|
|
187
|
+
async function readRemoteRefs(git, repositoryRoot, timeoutMs, maxBytes) {
|
|
188
|
+
const text = await runGit(git, repositoryRoot, ["for-each-ref", "--format=%(refname) %(objectname)", "refs/remotes"], timeoutMs, maxBytes)
|
|
189
|
+
return parseRefLines(text, MAX_REMOTE_REF_LINES)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Compute bounded content identities for dirty tracked and untracked files.
|
|
194
|
+
* Deleted files have no identity; identities are sorted by path and capped to
|
|
195
|
+
* the same line budget as status. The hash covers a bounded prefix of the file
|
|
196
|
+
* and its size so large files cannot exhaust memory or leak raw content.
|
|
197
|
+
* @param {string} repositoryRoot
|
|
198
|
+
* @param {string[]} statusLines
|
|
199
|
+
*/
|
|
200
|
+
async function readIdentities(repositoryRoot, statusLines) {
|
|
201
|
+
const limit = Math.min(statusLines.length, MAX_STATUS_LINES)
|
|
202
|
+
/** @type {[string, string][]} */
|
|
203
|
+
const entries = []
|
|
204
|
+
for (let index = 0; index < limit; index += 1) {
|
|
205
|
+
const line = statusLines[index]
|
|
206
|
+
if (!line || line.length < 2) continue
|
|
207
|
+
const status = line[0]
|
|
208
|
+
const path = line.slice(2)
|
|
209
|
+
if (status === "D" || path.length === 0) continue
|
|
210
|
+
try {
|
|
211
|
+
const identity = await hashWorktreeFile(join(repositoryRoot, path))
|
|
212
|
+
entries.push([path, identity])
|
|
213
|
+
} catch {
|
|
214
|
+
// Missing directories or unreadable files are omitted from the identity set.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
entries.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
218
|
+
return Object.fromEntries(entries)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {string} absolutePath */
|
|
222
|
+
async function hashWorktreeFile(absolutePath) {
|
|
223
|
+
const metadata = await stat(absolutePath)
|
|
224
|
+
if (!metadata.isFile()) throw new Error("Not a regular file")
|
|
225
|
+
const size = metadata.size
|
|
226
|
+
const hash = createHash(SNAPSHOT_HASH_ALGORITHM)
|
|
227
|
+
await new Promise((resolve, reject) => {
|
|
228
|
+
const stream = createReadStream(absolutePath, {start: 0, end: MAX_IDENTITY_BYTES - 1})
|
|
229
|
+
stream.on("data", (chunk) => { hash.update(chunk) })
|
|
230
|
+
stream.on("end", () => resolve(undefined))
|
|
231
|
+
stream.on("error", reject)
|
|
232
|
+
})
|
|
233
|
+
hash.update(`:${size}`)
|
|
234
|
+
return hash.digest("hex")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** @param {string} text @param {number} limit @returns {Record<string, string>} */
|
|
238
|
+
function parseRefLines(text, limit) {
|
|
239
|
+
const refs = /** @type {Record<string, string>} */ ({})
|
|
240
|
+
for (const line of boundedLines(text, limit)) {
|
|
241
|
+
const space = line.indexOf(" ")
|
|
242
|
+
if (space < 0) continue
|
|
243
|
+
refs[line.slice(0, space)] = line.slice(space + 1)
|
|
244
|
+
}
|
|
245
|
+
return refs
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** @param {string} text @param {number} limit */
|
|
249
|
+
function boundedLines(text, limit) {
|
|
250
|
+
const all = text.split("\n")
|
|
251
|
+
const lines = all[all.length - 1] === "" ? all.slice(0, -1) : all
|
|
252
|
+
if (lines.length > limit) return [...lines.slice(0, limit), `... (${lines.length - limit} lines truncated)`]
|
|
253
|
+
return lines
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Run git in a credential-free environment. The subprocess inherits PATH and
|
|
258
|
+
* HOME only; git-specific config/env variables are cleared so the snapshot
|
|
259
|
+
* cannot accidentally read credentials or remote URLs from the host.
|
|
260
|
+
* @param {string} git
|
|
261
|
+
* @param {string} repositoryRoot
|
|
262
|
+
* @param {string[]} args
|
|
263
|
+
* @param {number} timeoutMs
|
|
264
|
+
* @param {number} maxBytes
|
|
265
|
+
*/
|
|
266
|
+
function runGit(git, repositoryRoot, args, timeoutMs, maxBytes) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const child = spawn(git, ["--no-optional-locks", "-c", "safe.directory=*", "-C", repositoryRoot, ...args], {
|
|
269
|
+
env: {
|
|
270
|
+
PATH: process.env.PATH ?? "",
|
|
271
|
+
HOME: process.env.HOME ?? ""
|
|
272
|
+
},
|
|
273
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
274
|
+
})
|
|
275
|
+
let stdout = ""
|
|
276
|
+
let stderr = ""
|
|
277
|
+
let killed = false
|
|
278
|
+
const timer = setTimeout(() => {
|
|
279
|
+
killed = true
|
|
280
|
+
child.kill("SIGKILL")
|
|
281
|
+
reject(new Error("Git snapshot timeout"))
|
|
282
|
+
}, timeoutMs)
|
|
283
|
+
child.stdout.on("data", (chunk) => {
|
|
284
|
+
stdout += chunk.toString("utf8")
|
|
285
|
+
if (Buffer.byteLength(stdout, "utf8") > maxBytes && !killed) {
|
|
286
|
+
killed = true
|
|
287
|
+
child.kill("SIGKILL")
|
|
288
|
+
clearTimeout(timer)
|
|
289
|
+
reject(new Error("Git snapshot output exceeded capacity"))
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8") })
|
|
293
|
+
child.on("error", (error) => {
|
|
294
|
+
clearTimeout(timer)
|
|
295
|
+
reject(error)
|
|
296
|
+
})
|
|
297
|
+
child.on("close", (code, signal) => {
|
|
298
|
+
clearTimeout(timer)
|
|
299
|
+
if (signal !== null && killed) return
|
|
300
|
+
if (code !== 0) {
|
|
301
|
+
reject(new Error(`Git snapshot failed: ${stderr.trim() || signal || code}`))
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
resolve(stdout)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
}
|
package/src/providers/kimi.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import {exactKeys, isRecord} from "../record-helpers.js"
|
|
4
|
+
|
|
3
5
|
const EXECUTABLE = "kimi"
|
|
4
6
|
const ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,63}$/u
|
|
5
7
|
const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u
|
|
@@ -212,14 +214,6 @@ function withStarted(events, state) {
|
|
|
212
214
|
}
|
|
213
215
|
/** @returns {KimiRecognition} */
|
|
214
216
|
function emptyRecognition() { return {trusted: false, sessionId: undefined, events: []} }
|
|
215
|
-
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
216
|
-
function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value) }
|
|
217
|
-
/** @param {Record<string, unknown>} value @param {readonly string[]} expected @returns {boolean} */
|
|
218
|
-
function exactKeys(value, expected) {
|
|
219
|
-
const actual = Object.keys(value).sort()
|
|
220
|
-
const wanted = [...expected].sort()
|
|
221
|
-
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index])
|
|
222
|
-
}
|
|
223
217
|
/** @param {Record<string, unknown>} value @param {readonly string[]} required @param {readonly string[]} optional @returns {boolean} */
|
|
224
218
|
function exactOptionalKeys(value, required, optional) {
|
|
225
219
|
const actual = Object.keys(value)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
2
|
+
export function isRecord(value) {
|
|
3
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** @param {Record<string, unknown>} value @param {readonly string[]} expected */
|
|
7
|
+
export function exactKeys(value, expected) {
|
|
8
|
+
const actual = Object.keys(value).sort()
|
|
9
|
+
const wanted = [...expected].sort()
|
|
10
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index])
|
|
11
|
+
}
|