@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.
@@ -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
+ }