@nifrajs/coding-agent 3.3.0 → 3.5.0

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.
Files changed (49) hide show
  1. package/dist/context.js +29 -1
  2. package/dist/context.js.map +1 -1
  3. package/dist/diff.js +38 -2
  4. package/dist/diff.js.map +1 -1
  5. package/dist/host.d.ts.map +1 -1
  6. package/dist/host.js +146 -23
  7. package/dist/host.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/isolated.js +37 -2
  13. package/dist/isolated.js.map +1 -1
  14. package/dist/native.d.ts +5 -0
  15. package/dist/native.d.ts.map +1 -1
  16. package/dist/native.js +138 -34
  17. package/dist/native.js.map +1 -1
  18. package/dist/replay.d.ts +4 -0
  19. package/dist/replay.d.ts.map +1 -1
  20. package/dist/replay.js +189 -24
  21. package/dist/replay.js.map +1 -1
  22. package/dist/rpc.d.ts +10 -0
  23. package/dist/rpc.d.ts.map +1 -1
  24. package/dist/rpc.js +208 -29
  25. package/dist/rpc.js.map +1 -1
  26. package/dist/sessions.d.ts +14 -2
  27. package/dist/sessions.d.ts.map +1 -1
  28. package/dist/sessions.js +253 -59
  29. package/dist/sessions.js.map +1 -1
  30. package/dist/verification.d.ts +23 -0
  31. package/dist/verification.d.ts.map +1 -1
  32. package/dist/verification.js +113 -1
  33. package/dist/verification.js.map +1 -1
  34. package/dist/workflows.d.ts +1 -0
  35. package/dist/workflows.d.ts.map +1 -1
  36. package/dist/workflows.js +43 -21
  37. package/dist/workflows.js.map +1 -1
  38. package/package.json +6 -5
  39. package/src/context.ts +32 -1
  40. package/src/diff.ts +40 -2
  41. package/src/host.ts +146 -23
  42. package/src/index.ts +5 -0
  43. package/src/isolated.ts +39 -2
  44. package/src/native.ts +173 -32
  45. package/src/replay.ts +206 -25
  46. package/src/rpc.ts +261 -33
  47. package/src/sessions.ts +271 -56
  48. package/src/verification.ts +140 -1
  49. package/src/workflows.ts +37 -25
package/src/sessions.ts CHANGED
@@ -1,6 +1,20 @@
1
- import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"
1
+ import { constants as FS_CONSTANTS } from "node:fs"
2
+ import { type FileHandle, lstat, mkdir, open, rename, unlink, writeFile } from "node:fs/promises"
2
3
  import { dirname, join } from "node:path"
3
4
 
5
+ const DEFAULT_MAX_SESSION_ENTRIES = 4_096
6
+ const DEFAULT_MAX_SESSION_BYTES = 8 * 1024 * 1024
7
+ const MAX_SESSION_ENTRIES = 100_000
8
+ const MAX_SESSION_BYTES = 128 * 1024 * 1024
9
+ // `node:constants` does not expose the POSIX-only named export on Windows in Bun.
10
+ // Read it from the platform-safe `node:fs` constants object and fall back to zero;
11
+ // the Windows open path uses its lstat/open/lstat guard instead of this flag.
12
+ const O_APPEND = FS_CONSTANTS.O_APPEND
13
+ const O_CREAT = FS_CONSTANTS.O_CREAT
14
+ const O_RDONLY = FS_CONSTANTS.O_RDONLY
15
+ const O_WRONLY = FS_CONSTANTS.O_WRONLY
16
+ const O_NOFOLLOW = FS_CONSTANTS.O_NOFOLLOW ?? 0
17
+
4
18
  export interface SessionLogEntry {
5
19
  readonly version: 1
6
20
  readonly sessionId: string
@@ -18,7 +32,8 @@ export interface SessionStore {
18
32
  payload?: unknown,
19
33
  options?: { readonly pinned?: boolean },
20
34
  ): Promise<SessionLogEntry>
21
- read(sessionId: string): Promise<readonly SessionLogEntry[]>
35
+ /** Read at most `limit` retained entries from the tail of the session log. */
36
+ read(sessionId: string, limit?: number): Promise<readonly SessionLogEntry[]>
22
37
  checkpoint(sessionId: string, payload: unknown): Promise<void>
23
38
  fork(sessionId: string, targetSessionId?: string): Promise<string>
24
39
  }
@@ -26,6 +41,10 @@ export interface SessionStore {
26
41
  export interface FileSessionStoreOptions {
27
42
  readonly root: string
28
43
  readonly maxEntryBytes?: number
44
+ /** Maximum retained JSONL entries per session. Defaults to 4,096. */
45
+ readonly maxEntries?: number
46
+ /** Maximum retained JSONL bytes per session. Defaults to 8 MiB. */
47
+ readonly maxBytes?: number
29
48
  }
30
49
 
31
50
  /**
@@ -35,13 +54,32 @@ export interface FileSessionStoreOptions {
35
54
  export class FileSessionStore implements SessionStore {
36
55
  private readonly root: string
37
56
  private readonly maxEntryBytes: number
57
+ private readonly maxEntries: number
58
+ private readonly maxBytes: number
38
59
  private readonly sequences = new Map<string, number>()
60
+ private readonly locks = new Map<string, Promise<void>>()
39
61
 
40
62
  constructor(options: FileSessionStoreOptions) {
41
63
  this.root = options.root
42
64
  this.maxEntryBytes = options.maxEntryBytes ?? 256 * 1024
43
65
  if (!Number.isSafeInteger(this.maxEntryBytes) || this.maxEntryBytes < 1024)
44
66
  throw new RangeError("session store: maxEntryBytes must be at least 1024")
67
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_SESSION_ENTRIES
68
+ if (
69
+ !Number.isSafeInteger(this.maxEntries) ||
70
+ this.maxEntries < 1 ||
71
+ this.maxEntries > MAX_SESSION_ENTRIES
72
+ )
73
+ throw new RangeError(`session store: maxEntries must be between 1 and ${MAX_SESSION_ENTRIES}`)
74
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_SESSION_BYTES
75
+ if (
76
+ !Number.isSafeInteger(this.maxBytes) ||
77
+ this.maxBytes < this.maxEntryBytes + 1 ||
78
+ this.maxBytes > MAX_SESSION_BYTES
79
+ )
80
+ throw new RangeError(
81
+ `session store: maxBytes must be between maxEntryBytes+1 and ${MAX_SESSION_BYTES}`,
82
+ )
45
83
  }
46
84
 
47
85
  async append(
@@ -53,59 +91,65 @@ export class FileSessionStore implements SessionStore {
53
91
  validateToken(sessionId, "sessionId")
54
92
  if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(type))
55
93
  throw new TypeError("session store: invalid event type")
56
- await mkdir(this.root, { recursive: true })
57
- const nextSeq = await this.nextSequence(sessionId)
58
- const entry: SessionLogEntry = Object.freeze({
59
- version: 1,
60
- sessionId,
61
- seq: nextSeq,
62
- at: Date.now(),
63
- type,
64
- ...(payload === undefined
65
- ? {}
66
- : { payload: boundValue(redactValue(payload), this.maxEntryBytes) }),
67
- ...(options.pinned === true ? { pinned: true } : {}),
94
+ return this.withSessionLock(sessionId, async () => {
95
+ await mkdir(this.root, { recursive: true })
96
+ const nextSeq = await this.nextSequence(sessionId)
97
+ const entry: SessionLogEntry = Object.freeze({
98
+ version: 1,
99
+ sessionId,
100
+ seq: nextSeq,
101
+ at: Date.now(),
102
+ type,
103
+ ...(payload === undefined
104
+ ? {}
105
+ : { payload: boundValue(redactValue(payload), this.maxEntryBytes) }),
106
+ ...(options.pinned === true ? { pinned: true } : {}),
107
+ })
108
+ const line = JSON.stringify(entry)
109
+ if (Buffer.byteLength(line, "utf8") > this.maxEntryBytes)
110
+ throw new RangeError("session store: event exceeds maxEntryBytes")
111
+ const path = this.pathFor(sessionId)
112
+ const file = await openSessionFile(path, O_APPEND | O_CREAT | O_WRONLY, 0o600)
113
+ try {
114
+ await file.writeFile(`${line}\n`, "utf8")
115
+ } finally {
116
+ await file.close()
117
+ }
118
+ this.sequences.set(sessionId, nextSeq)
119
+ await this.compactIfNeeded(sessionId)
120
+ return entry
68
121
  })
69
- const line = JSON.stringify(entry)
70
- if (Buffer.byteLength(line, "utf8") > this.maxEntryBytes)
71
- throw new RangeError("session store: event exceeds maxEntryBytes")
72
- await appendFile(this.pathFor(sessionId), `${line}\n`, "utf8")
73
- this.sequences.set(sessionId, nextSeq)
74
- return entry
75
122
  }
76
123
 
77
- async read(sessionId: string): Promise<readonly SessionLogEntry[]> {
124
+ async read(sessionId: string, limit = this.maxEntries): Promise<readonly SessionLogEntry[]> {
78
125
  validateToken(sessionId, "sessionId")
79
- try {
80
- const text = await readFile(this.pathFor(sessionId), "utf8")
81
- const entries: SessionLogEntry[] = []
82
- for (const line of text.split("\n")) {
83
- if (line.trim().length === 0) continue
84
- const value: unknown = JSON.parse(line)
85
- if (isSessionLogEntry(value, sessionId)) entries.push(Object.freeze(value))
86
- }
87
- const last = entries.at(-1)?.seq
88
- if (last !== undefined) this.sequences.set(sessionId, last)
89
- return Object.freeze(entries)
90
- } catch (error) {
91
- if (isNotFound(error)) return Object.freeze([])
92
- throw error
93
- }
126
+ if (!Number.isSafeInteger(limit) || limit < 1)
127
+ throw new RangeError("session store: read limit must be a positive safe integer")
128
+ const boundedLimit = Math.min(limit, this.maxEntries)
129
+ return this.withSessionLock(sessionId, () => this.readUnlocked(sessionId, boundedLimit))
94
130
  }
95
131
 
96
132
  async checkpoint(sessionId: string, payload: unknown): Promise<void> {
97
133
  validateToken(sessionId, "sessionId")
98
- const target = join(this.root, `${sessionId}.checkpoint.json`)
99
- const temporary = `${target}.${process.pid}.${Date.now()}.tmp`
100
- await mkdir(dirname(target), { recursive: true })
101
- const content = JSON.stringify({
102
- version: 1,
103
- sessionId,
104
- at: Date.now(),
105
- payload: boundValue(redactValue(payload), this.maxEntryBytes),
134
+ await this.withSessionLock(sessionId, async () => {
135
+ const target = join(this.root, `${sessionId}.checkpoint.json`)
136
+ const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`
137
+ await mkdir(dirname(target), { recursive: true })
138
+ const content = JSON.stringify({
139
+ version: 1,
140
+ sessionId,
141
+ at: Date.now(),
142
+ payload: boundValue(redactValue(payload), this.maxEntryBytes),
143
+ })
144
+ // Exclusive creation prevents a local peer from planting a symlink at the temporary path.
145
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 })
146
+ try {
147
+ await rename(temporary, target)
148
+ } catch (error) {
149
+ await unlink(temporary).catch(() => {})
150
+ throw error
151
+ }
106
152
  })
107
- await writeFile(temporary, content, "utf8")
108
- await rename(temporary, target)
109
153
  }
110
154
 
111
155
  async fork(
@@ -114,15 +158,23 @@ export class FileSessionStore implements SessionStore {
114
158
  ): Promise<string> {
115
159
  validateToken(sessionId, "sessionId")
116
160
  validateToken(targetSessionId, "targetSessionId")
117
- const entries = await this.read(sessionId)
118
- await mkdir(this.root, { recursive: true })
119
- const target = this.pathFor(targetSessionId)
120
- const lines = entries.map((entry, index) =>
121
- JSON.stringify({ ...entry, sessionId: targetSessionId, seq: index }),
122
- )
123
- await writeFile(target, lines.length === 0 ? "" : `${lines.join("\n")}\n`, "utf8")
124
- this.sequences.set(targetSessionId, Math.max(0, lines.length - 1))
125
- return targetSessionId
161
+ return this.withSessionLock(sessionId, async () => {
162
+ const entries = await this.readUnlocked(sessionId, this.maxEntries)
163
+ await mkdir(this.root, { recursive: true })
164
+ const target = this.pathFor(targetSessionId)
165
+ const lines = entries.map((entry, index) =>
166
+ JSON.stringify({ ...entry, sessionId: targetSessionId, seq: index }),
167
+ )
168
+ // A caller must never be able to overwrite an existing session by choosing its id. The
169
+ // exclusive create also refuses a pre-planted symlink at the destination.
170
+ await writeFile(target, lines.length === 0 ? "" : `${lines.join("\n")}\n`, {
171
+ encoding: "utf8",
172
+ flag: "wx",
173
+ mode: 0o600,
174
+ })
175
+ this.sequences.set(targetSessionId, Math.max(0, lines.length - 1))
176
+ return targetSessionId
177
+ })
126
178
  }
127
179
 
128
180
  private pathFor(sessionId: string): string {
@@ -132,9 +184,108 @@ export class FileSessionStore implements SessionStore {
132
184
  private async nextSequence(sessionId: string): Promise<number> {
133
185
  const cached = this.sequences.get(sessionId)
134
186
  if (cached !== undefined) return cached + 1
135
- const entries = await this.read(sessionId)
187
+ const entries = await this.readUnlocked(sessionId, 1)
136
188
  return (entries.at(-1)?.seq ?? -1) + 1
137
189
  }
190
+
191
+ private async compactIfNeeded(sessionId: string): Promise<void> {
192
+ const path = this.pathFor(sessionId)
193
+ const file = await openSessionFile(path, O_RDONLY)
194
+ let size: number
195
+ try {
196
+ size = (await file.stat()).size
197
+ } finally {
198
+ await file.close()
199
+ }
200
+ if (size <= this.maxBytes) {
201
+ const recent = parseSessionEntries(
202
+ await this.readTailText(path, this.maxEntries + 1, this.maxBytes),
203
+ sessionId,
204
+ )
205
+ if (recent.length <= this.maxEntries) return
206
+ }
207
+ // The tail read is bounded by the retention cap plus one maximum-sized line. This keeps
208
+ // compaction memory bounded even if a pre-existing log is already oversized.
209
+ const recent = parseSessionEntries(
210
+ await this.readTailText(path, this.maxEntries + 1, this.maxBytes + this.maxEntryBytes),
211
+ sessionId,
212
+ )
213
+ const retained = retainEntries(recent, this.maxEntries, this.maxBytes)
214
+ if (retained.length === 0) return
215
+ const content = `${retained.map((entry) => JSON.stringify(entry)).join("\n")}\n`
216
+ if (Buffer.byteLength(content, "utf8") >= size && size <= this.maxBytes) return
217
+ const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`
218
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 })
219
+ try {
220
+ await rename(temporary, path)
221
+ } catch (error) {
222
+ await unlink(temporary).catch(() => {})
223
+ throw error
224
+ }
225
+ this.sequences.set(sessionId, retained.at(-1)!.seq)
226
+ }
227
+
228
+ private async readTailText(
229
+ path: string,
230
+ minimumLines: number,
231
+ maxBytes: number,
232
+ ): Promise<string> {
233
+ const file = await openSessionFile(path, O_RDONLY)
234
+ try {
235
+ const size = (await file.stat()).size
236
+ let end = size
237
+ let remaining = Math.min(size, maxBytes)
238
+ const chunks: Buffer[] = []
239
+ const chunkBytes = 64 * 1024
240
+ while (remaining > 0) {
241
+ const take = Math.min(remaining, chunkBytes)
242
+ end -= take
243
+ const bytes = Buffer.allocUnsafe(take)
244
+ const result = await file.read(bytes, 0, take, end)
245
+ chunks.unshift(bytes.subarray(0, result.bytesRead))
246
+ remaining -= result.bytesRead
247
+ const text = Buffer.concat(chunks).toString("utf8")
248
+ const lines = text.split("\n")
249
+ const completeLines = end > 0 ? lines.length - 1 : lines.length
250
+ if (completeLines >= minimumLines || end === 0 || result.bytesRead === 0) return text
251
+ }
252
+ return Buffer.concat(chunks).toString("utf8")
253
+ } finally {
254
+ await file.close()
255
+ }
256
+ }
257
+
258
+ private async readUnlocked(
259
+ sessionId: string,
260
+ boundedLimit: number,
261
+ ): Promise<readonly SessionLogEntry[]> {
262
+ try {
263
+ const text = await this.readTailText(this.pathFor(sessionId), boundedLimit, this.maxBytes)
264
+ const entries = parseSessionEntries(text, sessionId)
265
+ const last = entries.at(-1)?.seq
266
+ if (last !== undefined) this.sequences.set(sessionId, last)
267
+ return Object.freeze(entries.slice(-boundedLimit))
268
+ } catch (error) {
269
+ if (isNotFound(error)) return Object.freeze([])
270
+ throw error
271
+ }
272
+ }
273
+
274
+ private async withSessionLock<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
275
+ const previous = this.locks.get(sessionId) ?? Promise.resolve()
276
+ let release!: () => void
277
+ const current = new Promise<void>((resolve) => {
278
+ release = resolve
279
+ })
280
+ this.locks.set(sessionId, current)
281
+ await previous
282
+ try {
283
+ return await operation()
284
+ } finally {
285
+ release()
286
+ if (this.locks.get(sessionId) === current) this.locks.delete(sessionId)
287
+ }
288
+ }
138
289
  }
139
290
 
140
291
  export interface ContextRecord {
@@ -235,6 +386,38 @@ export class ContextWindow {
235
386
  }
236
387
  }
237
388
 
389
+ /**
390
+ * Open a session file without following a final-component symlink. Windows Bun does not support
391
+ * the POSIX `O_NOFOLLOW` flag, so it gets a bounded lstat/open/lstat guard instead; the descriptor is
392
+ * checked before any caller write or read. POSIX keeps the kernel-enforced flag for the race-free path.
393
+ */
394
+ async function openSessionFile(path: string, flags: number, mode?: number): Promise<FileHandle> {
395
+ if (process.platform !== "win32") {
396
+ return mode === undefined
397
+ ? open(path, flags | O_NOFOLLOW)
398
+ : open(path, flags | O_NOFOLLOW, mode)
399
+ }
400
+ await rejectSessionSymlink(path)
401
+ const file = mode === undefined ? await open(path, flags) : await open(path, flags, mode)
402
+ try {
403
+ await rejectSessionSymlink(path)
404
+ return file
405
+ } catch (error) {
406
+ await file.close()
407
+ throw error
408
+ }
409
+ }
410
+
411
+ async function rejectSessionSymlink(path: string): Promise<void> {
412
+ try {
413
+ if ((await lstat(path)).isSymbolicLink())
414
+ throw new Error("session store: refusing a symbolic-link session file")
415
+ } catch (error) {
416
+ if (isNotFound(error)) return
417
+ throw error
418
+ }
419
+ }
420
+
238
421
  function validateToken(value: string, name: string): void {
239
422
  if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(value))
240
423
  throw new TypeError(`session store: ${name} must be a bounded token`)
@@ -257,6 +440,38 @@ function isSessionLogEntry(value: unknown, sessionId: string): value is SessionL
257
440
  )
258
441
  }
259
442
 
443
+ function parseSessionEntries(text: string, sessionId: string): SessionLogEntry[] {
444
+ const entries: SessionLogEntry[] = []
445
+ for (const line of text.split("\n")) {
446
+ if (line.trim().length === 0) continue
447
+ try {
448
+ const value: unknown = JSON.parse(line)
449
+ if (isSessionLogEntry(value, sessionId)) entries.push(Object.freeze(value))
450
+ } catch {
451
+ // A tail read can begin in the middle of an old line. Invalid records are ignored so a
452
+ // damaged/partially rotated log cannot turn every history request into an unbounded retry.
453
+ }
454
+ }
455
+ return entries
456
+ }
457
+
458
+ function retainEntries(
459
+ entries: readonly SessionLogEntry[],
460
+ maxEntries: number,
461
+ maxBytes: number,
462
+ ): readonly SessionLogEntry[] {
463
+ const retained: SessionLogEntry[] = []
464
+ let bytes = 0
465
+ for (let index = entries.length - 1; index >= 0 && retained.length < maxEntries; index--) {
466
+ const entry = entries[index]!
467
+ const size = Buffer.byteLength(JSON.stringify(entry), "utf8") + 1
468
+ if (bytes + size > maxBytes) break
469
+ retained.unshift(entry)
470
+ bytes += size
471
+ }
472
+ return retained
473
+ }
474
+
260
475
  function redactValue(value: unknown, key = "", depth = 0): unknown {
261
476
  if (depth > 5) return "[depth-limited]"
262
477
  if (/(?:secret|token|password|passwd|authorization|api[-_]?key|private[-_]?key)/i.test(key))
@@ -53,6 +53,114 @@ export interface VerificationOptions {
53
53
  readonly exposeErrorStacks?: boolean
54
54
  }
55
55
 
56
+ export type ReviewExecutionErrorCode =
57
+ | "timeout"
58
+ | "output-truncated"
59
+ | "invalid-report"
60
+ | "spawn-failed"
61
+
62
+ export interface ReviewOptions {
63
+ readonly cwd: string
64
+ readonly command?: string
65
+ readonly commandArgs?: readonly string[]
66
+ readonly strict?: boolean
67
+ readonly diff?: string
68
+ readonly timeoutMs?: number
69
+ readonly maxOutputBytes?: number
70
+ readonly env?: Readonly<Record<string, string | undefined>>
71
+ }
72
+
73
+ export interface ReviewExecutionResult {
74
+ readonly name: "review"
75
+ readonly ok: boolean
76
+ readonly status: number | null
77
+ /** A parsed, content-free ReviewReport. Raw stdout/stderr is intentionally never returned. */
78
+ readonly report?: unknown
79
+ readonly errorCode?: ReviewExecutionErrorCode
80
+ }
81
+
82
+ const REVIEW_GIT_REF = /^[A-Za-z0-9][A-Za-z0-9._/@+-]{0,255}$/
83
+
84
+ /** Validate the only repository selector exposed by the host review RPC. */
85
+ export function isSafeReviewDiff(value: unknown): value is string {
86
+ return (
87
+ typeof value === "string" &&
88
+ REVIEW_GIT_REF.test(value) &&
89
+ !value.includes("..") &&
90
+ !value.includes("@{") &&
91
+ !value.includes("//") &&
92
+ !value.endsWith(".") &&
93
+ !value.endsWith("/") &&
94
+ !value
95
+ .split("/")
96
+ .some((part) => part.length === 0 || part === "." || part === ".." || part.startsWith("."))
97
+ )
98
+ }
99
+
100
+ /** Run `nifra review --json` with the same bounded process discipline as the existing gates. */
101
+ export async function runNifraReview(options: ReviewOptions): Promise<ReviewExecutionResult> {
102
+ if (options.strict !== undefined && typeof options.strict !== "boolean")
103
+ throw new TypeError("review strict must be a boolean")
104
+ if (options.diff !== undefined && !isSafeReviewDiff(options.diff))
105
+ throw new TypeError("review diff is not a safe Git ref")
106
+ const command = options.command ?? "nifra"
107
+ const commandArgs = options.commandArgs ?? []
108
+ const args = ["review", "--json"]
109
+ if (options.strict === true) args.push("--strict")
110
+ if (options.diff !== undefined) args.push("--diff", options.diff)
111
+ let proc: ReturnType<typeof Bun.spawn>
112
+ try {
113
+ proc = Bun.spawn([command, ...commandArgs, ...args], {
114
+ cwd: options.cwd,
115
+ stdin: "ignore",
116
+ stdout: "pipe",
117
+ stderr: "pipe",
118
+ env: safeEnv(options.env),
119
+ })
120
+ } catch {
121
+ return { name: "review", ok: false, status: null, errorCode: "spawn-failed" }
122
+ }
123
+ const timeoutMs = options.timeoutMs ?? 120_000
124
+ let timedOut = false
125
+ const timer = setTimeout(() => {
126
+ timedOut = true
127
+ proc.kill()
128
+ }, timeoutMs)
129
+ try {
130
+ const [stdout, stderr, status] = await Promise.all([
131
+ readBoundedText(proc.stdout, options.maxOutputBytes ?? 1_048_576),
132
+ readBoundedText(proc.stderr, options.maxOutputBytes ?? 1_048_576),
133
+ proc.exited,
134
+ ])
135
+ void stderr
136
+ if (timedOut) return { name: "review", ok: false, status, errorCode: "timeout" }
137
+ if (stdout.truncated || stderr.truncated)
138
+ return { name: "review", ok: false, status, errorCode: "output-truncated" }
139
+ try {
140
+ const raw = JSON.parse(stdout.text) as unknown
141
+ const { parseReviewReport } = await import("@nifrajs/agent-review")
142
+ const report = await parseReviewReport(raw)
143
+ return {
144
+ name: "review",
145
+ ok: status === 0 && report.ok,
146
+ status,
147
+ report,
148
+ }
149
+ } catch {
150
+ return { name: "review", ok: false, status, errorCode: "invalid-report" }
151
+ }
152
+ } catch {
153
+ return {
154
+ name: "review",
155
+ ok: false,
156
+ status: null,
157
+ errorCode: timedOut ? "timeout" : "spawn-failed",
158
+ }
159
+ } finally {
160
+ clearTimeout(timer)
161
+ }
162
+ }
163
+
56
164
  /** Run an existing Nifra gate without importing the large framework CLI into the agent runtime. */
57
165
  export async function runNifraVerification(
58
166
  name: "check" | "assure" | "test",
@@ -124,10 +232,41 @@ function safeEnv(
124
232
  "LC_ALL",
125
233
  "TERM",
126
234
  "CI",
235
+ ...(process.platform === "win32"
236
+ ? [
237
+ "SystemRoot",
238
+ "WINDIR",
239
+ "TEMP",
240
+ "TMP",
241
+ "USERPROFILE",
242
+ "LOCALAPPDATA",
243
+ "APPDATA",
244
+ "COMSPEC",
245
+ "PATHEXT",
246
+ "BUN_INSTALL",
247
+ "HOMEDRIVE",
248
+ "HOMEPATH",
249
+ "USERNAME",
250
+ "USERDOMAIN",
251
+ "ProgramData",
252
+ "ProgramFiles",
253
+ "ProgramFiles(x86)",
254
+ ]
255
+ : []),
127
256
  ...Object.keys(overrides ?? {}),
128
257
  ])) {
129
- const value = overrides?.[name] ?? process.env[name]
258
+ const value =
259
+ overrides !== undefined && Object.hasOwn(overrides, name) ? overrides[name] : readEnv(name)
130
260
  if (value !== undefined) result[name] = value
131
261
  }
132
262
  return result
133
263
  }
264
+
265
+ function readEnv(name: string): string | undefined {
266
+ const exact = process.env[name]
267
+ if (exact !== undefined || process.platform !== "win32") return exact
268
+ const key = Object.keys(process.env).find(
269
+ (candidate) => candidate.toLowerCase() === name.toLowerCase(),
270
+ )
271
+ return key === undefined ? undefined : process.env[key]
272
+ }