@dsh-cc/plugin-cc-grok-bridge 0.8.1
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/.claude-plugin/plugin.json +5 -0
- package/LICENSE +201 -0
- package/README.i18n.yaml +6 -0
- package/README.md +120 -0
- package/README.zh.md +97 -0
- package/commands/review.md +25 -0
- package/hooks/grok-review-allow.mjs +75 -0
- package/hooks/grok-review-context.mjs +38 -0
- package/hooks/hooks.json +27 -0
- package/package.json +22 -0
- package/scripts/grok-review-run.mjs +508 -0
- package/scripts/lib/argv.mjs +380 -0
- package/scripts/lib/canonical.mjs +103 -0
- package/scripts/lib/home.mjs +240 -0
- package/scripts/lib/lexer.mjs +145 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* §3.1 launcher for the cc-grok-bridge.
|
|
4
|
+
*
|
|
5
|
+
* grok-review-run.mjs [--last] -- <single-line prompt> # first char
|
|
6
|
+
* # not '-'; no \n or \r
|
|
7
|
+
* grok-review-run.mjs [--last] --prompt-file <path>
|
|
8
|
+
*
|
|
9
|
+
* Redirects GROK_HOME to a per-cwd shadow home under the canonical tmpdir
|
|
10
|
+
* (§3.1 steps 5–6, scripts/lib/home.mjs: D14 allowlist sweep then D19
|
|
11
|
+
* credential sync), resolves and validates the grok CLI, spawns
|
|
12
|
+
* `grok --permission-mode bypassPermissions --output-format json` with the
|
|
13
|
+
* prompt as the final `-p` argv value (or the bridge-owned
|
|
14
|
+
* H/.prompt-current.txt via native `--prompt-file`, unlinked in finalize),
|
|
15
|
+
* and prints the JSON document's `.text` (raw captured bytes on formatter
|
|
16
|
+
* fallthrough). The termination contract is the §3.1-T state machine:
|
|
17
|
+
* memoized `finalize()` / `terminate(reason)`, ordered group reap with a
|
|
18
|
+
* 2 s SIGKILL grace inside a 5 s total reap budget, and an `H/.orphaned`
|
|
19
|
+
* poison marker on budget expiry gating the next launch.
|
|
20
|
+
*
|
|
21
|
+
* Testability seam: `runLauncher(argv, deps?)` — `deps` carries every
|
|
22
|
+
* nondeterministic collaborator (fs-level ops, `spawn`, `now`, signal
|
|
23
|
+
* registration, `exitWith`) with production defaults.
|
|
24
|
+
*/
|
|
25
|
+
import { spawn as nodeSpawn } from 'node:child_process'
|
|
26
|
+
import {
|
|
27
|
+
closeSync,
|
|
28
|
+
constants as fsConstants,
|
|
29
|
+
existsSync,
|
|
30
|
+
fsyncSync,
|
|
31
|
+
fstatSync,
|
|
32
|
+
openSync,
|
|
33
|
+
readSync,
|
|
34
|
+
realpathSync,
|
|
35
|
+
renameSync,
|
|
36
|
+
rmSync,
|
|
37
|
+
unlinkSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
writeSync,
|
|
40
|
+
} from 'node:fs'
|
|
41
|
+
import { randomBytes } from 'node:crypto'
|
|
42
|
+
import { fileURLToPath } from 'node:url'
|
|
43
|
+
import os from 'node:os'
|
|
44
|
+
import path from 'node:path'
|
|
45
|
+
import { parseArgv } from './lib/argv.mjs'
|
|
46
|
+
import { acquireLock, buildChildEnv, prepareHome, syncCredentials, sweepHome } from './lib/home.mjs'
|
|
47
|
+
|
|
48
|
+
const USAGE =
|
|
49
|
+
'usage: grok-review-run.mjs [--last] -- <single-line prompt>\n' +
|
|
50
|
+
' grok-review-run.mjs [--last] --prompt-file <path>'
|
|
51
|
+
|
|
52
|
+
const PROMPT_CAP = 262144 // 256 KiB; the read itself enforces cap+1
|
|
53
|
+
export const STDOUT_CAP = 16 * 1024 * 1024 // capture buffer cap; breach byte terminates
|
|
54
|
+
const REAP_GRACE_MS = 2_000 // TERM → SIGKILL escalation
|
|
55
|
+
const REAP_BUDGET_MS = 5_000 // total reap budget → H/.orphaned + proceed
|
|
56
|
+
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP']
|
|
57
|
+
|
|
58
|
+
const die = (msg, code = 1) => {
|
|
59
|
+
const e = new Error(`grok-review: ${msg}`)
|
|
60
|
+
e.exitCode = code
|
|
61
|
+
throw e
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Canonicalized outer writable roots: {cwd, tmpdir, /tmp}. */
|
|
65
|
+
export function writableRoots(cwd = process.cwd(), tmp = os.tmpdir()) {
|
|
66
|
+
const roots = []
|
|
67
|
+
for (const p of [cwd, tmp, '/tmp']) {
|
|
68
|
+
try {
|
|
69
|
+
roots.push(realpathSync(p))
|
|
70
|
+
} catch {
|
|
71
|
+
/* absent root — nothing to confine against */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return roots
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const outsideRoots = (p, roots) => !roots.some((r) => p === r || p.startsWith(r + path.sep))
|
|
78
|
+
|
|
79
|
+
/** PATH scan + realpath; first hit outside every writable root wins. */
|
|
80
|
+
export function resolveGrok(roots, env = process.env) {
|
|
81
|
+
for (const dir of (env.PATH ?? '').split(path.delimiter)) {
|
|
82
|
+
if (!dir) continue
|
|
83
|
+
let real
|
|
84
|
+
try {
|
|
85
|
+
real = realpathSync(path.join(dir, 'grok'))
|
|
86
|
+
} catch {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
// The executed binary must sit outside every canonicalized writable
|
|
90
|
+
// root: nothing the model can write may become the CLI.
|
|
91
|
+
if (!outsideRoots(real, roots)) continue
|
|
92
|
+
return real
|
|
93
|
+
}
|
|
94
|
+
die('grok CLI not found on PATH outside the writable roots')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fsIo = { openSync, fstatSync, readSync, closeSync }
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* --prompt-file: O_NOFOLLOW open, fstat regular-file, bounded read of at
|
|
101
|
+
* most PROMPT_CAP + 1 bytes from THAT same fd — the cap is enforced by the
|
|
102
|
+
* read itself (a file grown past the cap between fstat and read still
|
|
103
|
+
* dies). The check→open ancestor-swap race is the §4 accepted residual —
|
|
104
|
+
* this channel is hygiene-only (prompt text the model could already read
|
|
105
|
+
* and inline itself), data egress into a networked model run, not an
|
|
106
|
+
* egress gate.
|
|
107
|
+
*/
|
|
108
|
+
export function readPromptFile(p, io = fsIo) {
|
|
109
|
+
let fd
|
|
110
|
+
try {
|
|
111
|
+
fd = io.openSync(p, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW)
|
|
112
|
+
} catch (e) {
|
|
113
|
+
die(`prompt-file unreadable: ${e.message}`)
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
if (!io.fstatSync(fd).isFile()) die('prompt-file is not a regular file')
|
|
117
|
+
const chunks = []
|
|
118
|
+
let total = 0
|
|
119
|
+
const buf = Buffer.alloc(65536)
|
|
120
|
+
for (;;) {
|
|
121
|
+
const n = io.readSync(fd, buf, 0, buf.length, null)
|
|
122
|
+
if (n === 0) break
|
|
123
|
+
if (total + n > PROMPT_CAP) die(`prompt-file exceeds the ${PROMPT_CAP}-byte cap`)
|
|
124
|
+
chunks.push(Buffer.from(buf.subarray(0, n)))
|
|
125
|
+
total += n
|
|
126
|
+
}
|
|
127
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
128
|
+
} finally {
|
|
129
|
+
closeSync(fd)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Atomic 0600 write into the shadow home; temp residue never survives. */
|
|
134
|
+
export function writePromptShadow(H, data) {
|
|
135
|
+
const finalPath = path.join(H, '.prompt-current.txt')
|
|
136
|
+
const tmpPath = path.join(H, `.tmp-${randomBytes(8).toString('hex')}`)
|
|
137
|
+
let fd
|
|
138
|
+
try {
|
|
139
|
+
fd = openSync(tmpPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600)
|
|
140
|
+
writeSync(fd, Buffer.from(data, 'utf8'))
|
|
141
|
+
fsyncSync(fd)
|
|
142
|
+
closeSync(fd)
|
|
143
|
+
fd = undefined
|
|
144
|
+
renameSync(tmpPath, finalPath)
|
|
145
|
+
} catch (e) {
|
|
146
|
+
if (fd !== undefined) {
|
|
147
|
+
try { closeSync(fd) } catch { /* already closed */ }
|
|
148
|
+
}
|
|
149
|
+
try { rmSync(tmpPath, { force: true }) } catch { /* best-effort */ }
|
|
150
|
+
throw e
|
|
151
|
+
}
|
|
152
|
+
return finalPath
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Output formatter (§10): a parsed document with a string `.text` (empty
|
|
157
|
+
* qualifies) prints as `.text` with newline normalization; EVERYTHING else
|
|
158
|
+
* — parse failure, primitives, arrays, null, object without string `.text`
|
|
159
|
+
* — passes the captured buffer through verbatim.
|
|
160
|
+
*/
|
|
161
|
+
export function formatOutput(captured) {
|
|
162
|
+
let doc
|
|
163
|
+
try {
|
|
164
|
+
doc = JSON.parse(captured.toString('utf8'))
|
|
165
|
+
} catch {
|
|
166
|
+
doc = undefined
|
|
167
|
+
}
|
|
168
|
+
if (doc !== null && typeof doc === 'object' && !Array.isArray(doc) && typeof doc.text === 'string') {
|
|
169
|
+
return doc.text.endsWith('\n') ? doc.text : doc.text + '\n'
|
|
170
|
+
}
|
|
171
|
+
return captured
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Success-path stderr cost summary — the durable cost record (P14). Fields
|
|
176
|
+
* are type-checked, control-stripped, and capped; absent/invalid fields are
|
|
177
|
+
* dropped. The `grok-review:` prefix marks launcher-authorship by
|
|
178
|
+
* CONVENTION, not proof (the child stderr shares the stream).
|
|
179
|
+
*/
|
|
180
|
+
export function costLine(doc) {
|
|
181
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return null
|
|
182
|
+
const parts = []
|
|
183
|
+
if (doc.sessionId !== undefined && doc.sessionId !== null) {
|
|
184
|
+
const s = String(doc.sessionId)
|
|
185
|
+
// eslint-disable-next-line no-control-regex
|
|
186
|
+
.replace(/[\u0000-\u001f\u007f]/g, '')
|
|
187
|
+
.slice(0, 64)
|
|
188
|
+
if (s !== '') parts.push(`session=${s}`)
|
|
189
|
+
}
|
|
190
|
+
if (typeof doc.total_cost_usd === 'number') parts.push(`cost_usd=${doc.total_cost_usd}`)
|
|
191
|
+
if (typeof doc.num_turns === 'number') parts.push(`turns=${doc.num_turns}`)
|
|
192
|
+
return parts.length > 0 ? `grok-review: ${parts.join(' ')}` : null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The launcher body (§3.1 steps 1–11 + §3.1-T). `deps` injects every
|
|
197
|
+
* nondeterministic collaborator; production defaults are the real ones.
|
|
198
|
+
*/
|
|
199
|
+
export async function runLauncher(argv = process.argv.slice(2), deps = {}) {
|
|
200
|
+
const d = {
|
|
201
|
+
spawn: nodeSpawn,
|
|
202
|
+
now: Date.now,
|
|
203
|
+
exitWith: (code) => {
|
|
204
|
+
process.exitCode = code
|
|
205
|
+
},
|
|
206
|
+
onSignal: (handler) => {
|
|
207
|
+
for (const sig of SIGNALS) process.on(sig, handler)
|
|
208
|
+
return () => {
|
|
209
|
+
for (const sig of SIGNALS) process.off(sig, handler)
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
prepareHome,
|
|
213
|
+
acquireLock,
|
|
214
|
+
platform: process.platform,
|
|
215
|
+
cwd: () => process.cwd(),
|
|
216
|
+
tmpdir: os.tmpdir,
|
|
217
|
+
homedir: os.homedir,
|
|
218
|
+
env: process.env,
|
|
219
|
+
io: fsIo,
|
|
220
|
+
...deps,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Step 1: platform gate (arming refuses win32 too — D17).
|
|
224
|
+
if (d.platform === 'win32') die('unsupported platform win32')
|
|
225
|
+
|
|
226
|
+
// Step 2: writable roots.
|
|
227
|
+
const roots = writableRoots(d.cwd(), d.tmpdir())
|
|
228
|
+
|
|
229
|
+
// Step 3: self-assert — the interpreter must sit outside every writable
|
|
230
|
+
// root (defense-in-depth against a mismatched/PATH-swapped node).
|
|
231
|
+
const selfNode = realpathSync(process.execPath)
|
|
232
|
+
if (!outsideRoots(selfNode, roots)) {
|
|
233
|
+
die('node interpreter sits inside a canonicalized writable root')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Step 4: parse own argv with the shared grammar (D9/D13). Real argv has
|
|
237
|
+
// no shell quoting left: map each arg to { text, expansion: false }.
|
|
238
|
+
const launcherPath = realpathSync(fileURLToPath(import.meta.url))
|
|
239
|
+
const words = [
|
|
240
|
+
{ text: selfNode, expansion: false },
|
|
241
|
+
{ text: launcherPath, expansion: false },
|
|
242
|
+
...argv.map((a) => ({ text: a, expansion: false })),
|
|
243
|
+
]
|
|
244
|
+
const parsed = parseArgv(words, { node: selfNode, launcher: launcherPath })
|
|
245
|
+
if (!parsed.ok) die(`invalid invocation (${parsed.reason})\n${USAGE}`, 2)
|
|
246
|
+
const { last, prompt } = parsed.value
|
|
247
|
+
|
|
248
|
+
// The §3.1-T state machine's mutable shell.
|
|
249
|
+
let H = null
|
|
250
|
+
let releaseLock = () => {}
|
|
251
|
+
let child = null
|
|
252
|
+
let closed = false
|
|
253
|
+
let closeInfo = null
|
|
254
|
+
let spawnError = null
|
|
255
|
+
let terminateCalled = false
|
|
256
|
+
let terminateReason = null
|
|
257
|
+
let capBreached = false
|
|
258
|
+
let finalizeDone = false
|
|
259
|
+
let orphanWritten = false
|
|
260
|
+
let graceTimer = null
|
|
261
|
+
let budgetTimer = null
|
|
262
|
+
let resolveClose = null
|
|
263
|
+
const detachRef = { fn: () => {} }
|
|
264
|
+
|
|
265
|
+
const promptShadowPath = () => path.join(H, '.prompt-current.txt')
|
|
266
|
+
const orphanMarkerPath = () => path.join(H, '.orphaned')
|
|
267
|
+
|
|
268
|
+
const writeOrphanMarker = () => {
|
|
269
|
+
if (orphanWritten) return
|
|
270
|
+
orphanWritten = true
|
|
271
|
+
try {
|
|
272
|
+
writeFileSync(orphanMarkerPath(), `reap-budget-expired ${new Date().toISOString()}\n`, { mode: 0o600 })
|
|
273
|
+
} catch {
|
|
274
|
+
/* best-effort — the marker gates the NEXT launch, not this one */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const killGroup = (sig) => {
|
|
279
|
+
if (child && child.pid) {
|
|
280
|
+
try {
|
|
281
|
+
process.kill(-child.pid, sig)
|
|
282
|
+
} catch {
|
|
283
|
+
/* ESRCH — group already gone */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const clearReapTimers = () => {
|
|
289
|
+
if (graceTimer) clearTimeout(graceTimer)
|
|
290
|
+
if (budgetTimer) clearTimeout(budgetTimer)
|
|
291
|
+
graceTimer = budgetTimer = null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** T9: reap budget expiry — poison marker, forced kill, stop waiting. */
|
|
295
|
+
const onReapBudgetExpiry = () => {
|
|
296
|
+
writeOrphanMarker()
|
|
297
|
+
killGroup('SIGKILL')
|
|
298
|
+
if (resolveClose) resolveClose({ code: null, signal: null, forced: true })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Memoized terminate (T4/T6/T8): TERM → 2 s SIGKILL grace, 5 s total
|
|
303
|
+
* reap budget → `.orphaned` + proceed.
|
|
304
|
+
*/
|
|
305
|
+
const terminate = (reason) => {
|
|
306
|
+
if (terminateCalled) return
|
|
307
|
+
terminateCalled = true
|
|
308
|
+
terminateReason = reason
|
|
309
|
+
if (child === null || closed) return
|
|
310
|
+
killGroup('SIGTERM')
|
|
311
|
+
graceTimer = setTimeout(() => killGroup('SIGKILL'), REAP_GRACE_MS)
|
|
312
|
+
budgetTimer = setTimeout(onReapBudgetExpiry, REAP_BUDGET_MS)
|
|
313
|
+
graceTimer.unref() // hygiene: alone, timers must not keep the process alive
|
|
314
|
+
budgetTimer.unref()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Memoized finalize: unlink shadow prompt → release → detach → exit. */
|
|
318
|
+
const finalize = (code) => {
|
|
319
|
+
if (finalizeDone) return
|
|
320
|
+
finalizeDone = true
|
|
321
|
+
clearReapTimers()
|
|
322
|
+
try {
|
|
323
|
+
if (H !== null) unlinkSync(promptShadowPath())
|
|
324
|
+
} catch {
|
|
325
|
+
/* best-effort — the prompt bytes never persist */
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
releaseLock()
|
|
329
|
+
} catch {
|
|
330
|
+
/* best-effort */
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
detachRef.fn()
|
|
334
|
+
} catch {
|
|
335
|
+
/* best-effort */
|
|
336
|
+
}
|
|
337
|
+
d.exitWith(code)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const onSignal = () => {
|
|
341
|
+
if (finalizeDone) return
|
|
342
|
+
if (child === null || closed) {
|
|
343
|
+
// T5: signal in INIT (no child) — uniform 130, deliberate.
|
|
344
|
+
finalize(130)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
if (terminateCalled) {
|
|
348
|
+
// T7: second signal while TERMINATING — immediate SIGKILL, no budget extension.
|
|
349
|
+
killGroup('SIGKILL')
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
// T6: signal in RUNNING.
|
|
353
|
+
terminate('signal')
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const awaitClose = () =>
|
|
357
|
+
new Promise((resolve) => {
|
|
358
|
+
const finish = (info) => {
|
|
359
|
+
resolveClose = null
|
|
360
|
+
closed = true
|
|
361
|
+
clearReapTimers()
|
|
362
|
+
resolve(info)
|
|
363
|
+
}
|
|
364
|
+
resolveClose = finish
|
|
365
|
+
child.on('error', (e) => {
|
|
366
|
+
spawnError = e
|
|
367
|
+
finish({ code: null, signal: null })
|
|
368
|
+
})
|
|
369
|
+
child.on('close', (code, signal) => {
|
|
370
|
+
closeInfo = { code, signal }
|
|
371
|
+
finish({ code, signal })
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
// Step 5: shadow state root. Signal handlers registered FIRST (before
|
|
377
|
+
// acquireLock) — no signal window can strand the lock: a pre-lock
|
|
378
|
+
// signal hits the guarded finalize with nothing to reap/release.
|
|
379
|
+
detachRef.fn = d.onSignal(onSignal)
|
|
380
|
+
// A registration-time signal finalized us already (T5 window): do NOT
|
|
381
|
+
// proceed to touch the home or spawn anything.
|
|
382
|
+
if (finalizeDone) return 130
|
|
383
|
+
;({ H } = d.prepareHome({ cwd: d.cwd(), tmpdir: d.tmpdir() }))
|
|
384
|
+
// Orphan marker check (codex-R5 B3): STRICTLY precedes the step-6
|
|
385
|
+
// sweep, and `.orphaned` is never in the sweep allowlist — the poison
|
|
386
|
+
// marker survives until a human removes it.
|
|
387
|
+
if (existsSync(orphanMarkerPath())) {
|
|
388
|
+
die(
|
|
389
|
+
'previous run left a live child after the reap budget (H/.orphaned) — inspect processes and remove the marker to re-enable this lane',
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
releaseLock = d.acquireLock(H)
|
|
393
|
+
|
|
394
|
+
// Step 6: sweep BEFORE credentials, inside the lock (order closes the
|
|
395
|
+
// planted-destination DoS). Sweep failure of any entry is fail-closed.
|
|
396
|
+
sweepHome(H)
|
|
397
|
+
syncCredentials(H, { home: d.homedir(), env: d.env })
|
|
398
|
+
|
|
399
|
+
// Step 7: resolve the grok CLI.
|
|
400
|
+
const grok = resolveGrok(roots, d.env)
|
|
401
|
+
|
|
402
|
+
// Step 8: prompt materialization (write-through; only bridge-owned
|
|
403
|
+
// paths reach Grok).
|
|
404
|
+
let promptArg
|
|
405
|
+
if (prompt.kind === 'inline') {
|
|
406
|
+
promptArg = ['-p', prompt.text]
|
|
407
|
+
} else {
|
|
408
|
+
const text = readPromptFile(prompt.path, d.io)
|
|
409
|
+
const shadow = writePromptShadow(H, text)
|
|
410
|
+
promptArg = ['--prompt-file', shadow]
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Step 9: spawn — group leader (detached) so termination reaches grok +
|
|
414
|
+
// group-resident descendants. No --cwd flag: spawn-cwd inheritance is
|
|
415
|
+
// the resume surface. Env = buildChildEnv(process.env, H):
|
|
416
|
+
// { ...env, GROK_HOME: H } minus GROK_SESSION_ID, GROK_AGENT,
|
|
417
|
+
// GROK_SANDBOX (D10) — process.env itself is never mutated.
|
|
418
|
+
const args = ['--permission-mode', 'bypassPermissions', '--output-format', 'json']
|
|
419
|
+
if (last) args.push('-c')
|
|
420
|
+
args.push(...promptArg)
|
|
421
|
+
// Pre-spawn signal window (T5/T6 boundary): a signal that finalized us
|
|
422
|
+
// mid-INIT must not still produce a run.
|
|
423
|
+
if (finalizeDone) return 130
|
|
424
|
+
const env = buildChildEnv(d.env, H)
|
|
425
|
+
child = d.spawn(grok, args, {
|
|
426
|
+
cwd: realpathSync(d.cwd()),
|
|
427
|
+
env,
|
|
428
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
429
|
+
detached: true,
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
// Step 10: capture with the 16 MiB + 1 byte cap (discard after breach).
|
|
433
|
+
let captured = Buffer.alloc(0)
|
|
434
|
+
child.stdout?.on('data', (chunk) => {
|
|
435
|
+
if (capBreached) return // post-breach chunks are discarded, never appended
|
|
436
|
+
if (captured.length + chunk.length > STDOUT_CAP) {
|
|
437
|
+
capBreached = true
|
|
438
|
+
captured = Buffer.concat([captured, chunk.subarray(0, STDOUT_CAP - captured.length + 1)])
|
|
439
|
+
terminate('stdout-cap')
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
captured = Buffer.concat([captured, chunk])
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
await awaitClose()
|
|
446
|
+
clearReapTimers()
|
|
447
|
+
|
|
448
|
+
// Step 10/11: output contract + exit-code mapping (§3.1-T rows T2–T4, T8, T9).
|
|
449
|
+
let exitCode
|
|
450
|
+
if (spawnError) {
|
|
451
|
+
// T8: spawn error — memoized terminate's group-kill best-effort.
|
|
452
|
+
terminate('spawn-error')
|
|
453
|
+
console.error(`grok-review: spawn failed: ${spawnError.message}`)
|
|
454
|
+
exitCode = 1
|
|
455
|
+
} else if (capBreached) {
|
|
456
|
+
console.error('grok-review: grok stdout exceeded the 16 MiB capture cap')
|
|
457
|
+
exitCode = 1
|
|
458
|
+
} else if (closeInfo !== null && closeInfo.code !== null && !terminateCalled) {
|
|
459
|
+
// T2: normal close — formatter + cost line; exit code is the child's.
|
|
460
|
+
const out = formatOutput(captured)
|
|
461
|
+
if (Buffer.isBuffer(out)) process.stdout.write(out)
|
|
462
|
+
else process.stdout.write(out, 'utf8')
|
|
463
|
+
let doc
|
|
464
|
+
try {
|
|
465
|
+
doc = JSON.parse(captured.toString('utf8'))
|
|
466
|
+
} catch {
|
|
467
|
+
doc = undefined
|
|
468
|
+
}
|
|
469
|
+
const line = costLine(doc)
|
|
470
|
+
if (line !== null) console.error(line)
|
|
471
|
+
exitCode = closeInfo.code
|
|
472
|
+
} else if (closeInfo !== null && closeInfo.code === null && !terminateCalled) {
|
|
473
|
+
// T3: genuine signal close without a launcher-initiated terminate —
|
|
474
|
+
// the formatter is bypassed entirely, nothing of the partial capture prints.
|
|
475
|
+
console.error(`grok-review: grok terminated by signal ${closeInfo.signal ?? 'UNKNOWN'}`)
|
|
476
|
+
exitCode = 1
|
|
477
|
+
} else {
|
|
478
|
+
// T4/T6/T9: the initiating row's code (cap → 1, signal → 130).
|
|
479
|
+
exitCode = terminateReason === 'signal' ? 130 : 1
|
|
480
|
+
}
|
|
481
|
+
finalize(exitCode)
|
|
482
|
+
return exitCode
|
|
483
|
+
} catch (e) {
|
|
484
|
+
// T1: sync failure in INIT — the finally below still runs finalize's
|
|
485
|
+
// cleanup half (prompt-file safety even when spawn never happens).
|
|
486
|
+
if (!finalizeDone) {
|
|
487
|
+
console.error(e?.message ?? e)
|
|
488
|
+
finalize(e?.exitCode ?? 1)
|
|
489
|
+
}
|
|
490
|
+
} finally {
|
|
491
|
+
// The whole-region try/finally: the memoized finalize runs on EVERY
|
|
492
|
+
// path — this call is a cleanup no-op whenever the body already
|
|
493
|
+
// finalized (T1 included).
|
|
494
|
+
finalize(undefined)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
499
|
+
runLauncher(process.argv.slice(2)).then(
|
|
500
|
+
(code) => {
|
|
501
|
+
if (typeof code === 'number') process.exitCode = code
|
|
502
|
+
},
|
|
503
|
+
(e) => {
|
|
504
|
+
console.error(e?.message ?? e)
|
|
505
|
+
process.exitCode = e?.exitCode ?? 1
|
|
506
|
+
},
|
|
507
|
+
)
|
|
508
|
+
}
|