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.
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import {isAbsolute, join} from "node:path"
4
- import {readResponseCapped} from "./absolute-deadline.js"
4
+ import {readResponseCapped, ResponseCapacityError} from "./absolute-deadline.js"
5
5
  import {openKimiOAuthStore} from "./kimi-oauth-store.js"
6
6
  import {CapacityProbeError, DEFAULT_CAPACITY_TIMEOUT_MS, normalizeKimiUsages} from "./provider-capacity.js"
7
7
 
@@ -64,13 +64,22 @@ export async function probeKimiCapacity(options) {
64
64
  if (contentType === null || !contentType.toLowerCase().includes("application/json")) {
65
65
  throw new CapacityProbeError("protocol", "Kimi usages response has an unexpected content type")
66
66
  }
67
+ /** @type {Buffer} */
68
+ let bytes
69
+ try {
70
+ bytes = await readResponseCapped(response, MAX_USAGES_BYTES, signal)
71
+ } catch (error) {
72
+ if (error instanceof ResponseCapacityError) {
73
+ throw new CapacityProbeError("protocol", "Kimi usages response body exceeded the size bound")
74
+ }
75
+ throw new CapacityProbeError("unavailable", "Kimi usages response body is unavailable")
76
+ }
67
77
  /** @type {unknown} */
68
78
  let parsed
69
79
  try {
70
- const bytes = await readResponseCapped(response, MAX_USAGES_BYTES, signal)
71
80
  parsed = JSON.parse(bytes.toString("utf8"))
72
81
  } catch {
73
- throw new CapacityProbeError("protocol", "Kimi usages response is not valid bounded JSON")
82
+ throw new CapacityProbeError("protocol", "Kimi usages response is not valid JSON")
74
83
  }
75
84
  return normalizeKimiUsages(parsed)
76
85
  }
@@ -213,7 +213,7 @@ function kimiQuotaWindow(value, windowDurationSeconds) {
213
213
  const used = quotaValue(value.used)
214
214
  const remaining = quotaValue(value.remaining)
215
215
  if (limit < 1 || used + remaining !== limit) throw kimiProtocolError()
216
- const remainingPercent = Math.floor((remaining / limit) * 100)
216
+ const remainingPercent = Number((BigInt(remaining) * 100n) / BigInt(limit))
217
217
  return {
218
218
  usedPercent: 100 - remainingPercent,
219
219
  remainingPercent,
@@ -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
+ }