@dsh-cc/plugin-cc-codex-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,182 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * §3.1 launcher for the codex-rescue-bridge.
4
+ *
5
+ * codex-rescue-run.mjs [--last] -- <single-line prompt>
6
+ * codex-rescue-run.mjs [--last] --prompt-file <path>
7
+ *
8
+ * Redirects CODEX_HOME to a per-cwd shadow home under the canonical tmpdir
9
+ * (§3.1 steps 1–4, scripts/lib/home.mjs), resolves and validates the Codex
10
+ * CLI, spawns `codex exec --sandbox danger-full-access ... -` with the prompt
11
+ * on stdin, and prints last-message.txt at the end.
12
+ */
13
+ import { spawn } from 'node:child_process'
14
+ import {
15
+ closeSync,
16
+ constants as fsConstants,
17
+ existsSync,
18
+ fstatSync,
19
+ openSync,
20
+ readFileSync,
21
+ realpathSync,
22
+ } from 'node:fs'
23
+ import { fileURLToPath } from 'node:url'
24
+ import os from 'node:os'
25
+ import path from 'node:path'
26
+ import { parseArgv } from './lib/argv.mjs'
27
+ import { acquireLock, prepareHome, syncCredentials } from './lib/home.mjs'
28
+
29
+ const USAGE =
30
+ 'usage: codex-rescue-run.mjs [--last] -- <single-line prompt>\n' +
31
+ ' codex-rescue-run.mjs [--last] --prompt-file <path>'
32
+
33
+ const die = (msg, code = 1) => {
34
+ const e = new Error(`codex-rescue: ${msg}`)
35
+ e.exitCode = code
36
+ throw e
37
+ }
38
+
39
+ /** Canonicalized outer writable roots: {cwd, tmpdir, /tmp} (§3.1 step 6). */
40
+ function writableRoots() {
41
+ const roots = []
42
+ for (const p of [process.cwd(), os.tmpdir(), '/tmp']) {
43
+ try {
44
+ roots.push(realpathSync(p))
45
+ } catch {
46
+ /* absent root — nothing to confine against */
47
+ }
48
+ }
49
+ return roots
50
+ }
51
+
52
+ const outsideRoots = (p, roots) => !roots.some((r) => p === r || p.startsWith(r + path.sep))
53
+
54
+ function resolveCodex(roots) {
55
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
56
+ if (!dir) continue
57
+ let real
58
+ try {
59
+ real = realpathSync(path.join(dir, 'codex'))
60
+ } catch {
61
+ continue
62
+ }
63
+ // The executed binary must sit outside every canonicalized writable
64
+ // root: nothing the model can write may become the CLI (§3.1 step 6).
65
+ if (!outsideRoots(real, roots)) continue
66
+ return real
67
+ }
68
+ die('codex CLI not found on PATH outside the writable roots')
69
+ }
70
+
71
+ const PROMPT_CAP = 256 * 1024
72
+
73
+ /**
74
+ * --prompt-file (§3.1 step 5): O_NOFOLLOW open, fstat regular-file, read
75
+ * from THAT same fd with the byte cap. The check→open ancestor-swap race is
76
+ * the §4 accepted residual — this channel is hygiene-only (prompt text the
77
+ * model could already read and inline itself), data egress into a networked
78
+ * model run, not an egress gate.
79
+ */
80
+ function readPromptFile(p) {
81
+ let fd
82
+ try {
83
+ fd = openSync(p, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW)
84
+ } catch (e) {
85
+ die(`prompt-file unreadable: ${e.message}`)
86
+ }
87
+ try {
88
+ if (!fstatSync(fd).isFile()) die('prompt-file is not a regular file')
89
+ if (fstatSync(fd).size > PROMPT_CAP) die(`prompt-file exceeds the ${PROMPT_CAP}-byte cap`)
90
+ return readFileSync(fd).toString('utf8')
91
+ } finally {
92
+ closeSync(fd)
93
+ }
94
+ }
95
+
96
+ /** Spawn codex with the prompt on a piped stdin, written then closed. */
97
+ function runCodex(codex, args, env, promptText) {
98
+ return new Promise((resolve) => {
99
+ const child = spawn(codex, args, { env, stdio: ['pipe', 'inherit', 'inherit'] })
100
+ // Write the full prompt then CLOSE stdin — the EOF is the prompt
101
+ // terminator (§5 write/close EOF path). Child stdout/stderr inherit;
102
+ // CODEX_HOME lives only in this child env object, process.env is never
103
+ // mutated.
104
+ child.stdin.end(promptText)
105
+ child.on('error', (e) => {
106
+ console.error(`codex-rescue: spawn failed: ${e.message}`)
107
+ resolve(1) // never throw from an event callback — the finally must run
108
+ })
109
+ child.on('close', (code) => resolve(code ?? 1))
110
+ })
111
+ }
112
+
113
+ async function main() {
114
+ if (process.platform === 'win32') {
115
+ // The design's probes (§2) targeted POSIX; fail loud instead of guessing.
116
+ die('unsupported platform win32')
117
+ }
118
+
119
+ const roots = writableRoots()
120
+
121
+ // Self-assert at startup (§3.1 step 6, critic F1 belt): the interpreter
122
+ // must sit outside every writable root — one line of defense-in-depth
123
+ // against a mismatched/PATH-swapped node.
124
+ const selfNode = realpathSync(process.execPath)
125
+ if (!outsideRoots(selfNode, roots)) {
126
+ die('node interpreter sits inside a canonicalized writable root')
127
+ }
128
+
129
+ // Parse own argv with the shared grammar. Real argv has no shell quoting
130
+ // left: map each arg to { text, expansion: false }.
131
+ const launcherPath = realpathSync(fileURLToPath(import.meta.url))
132
+ const words = [
133
+ { text: selfNode, expansion: false },
134
+ { text: launcherPath, expansion: false },
135
+ ...process.argv.slice(2).map((a) => ({ text: a, expansion: false })),
136
+ ]
137
+ const parsed = parseArgv(words, { node: selfNode, launcher: launcherPath })
138
+ if (!parsed.ok) die(`invalid invocation (${parsed.reason})\n${USAGE}`, 2)
139
+ const { last, prompt } = parsed.value
140
+
141
+ const { H } = prepareHome({ cwd: process.cwd(), tmpdir: os.tmpdir() })
142
+ const releaseLock = acquireLock(H)
143
+ try {
144
+ syncCredentials(H)
145
+ const codex = resolveCodex(roots)
146
+ const promptText =
147
+ prompt.kind === 'inline' ? prompt.text : readPromptFile(prompt.path)
148
+
149
+ const args = [
150
+ 'exec',
151
+ '--sandbox',
152
+ 'danger-full-access',
153
+ '--cd',
154
+ realpathSync(process.cwd()),
155
+ '-o',
156
+ path.join(H, 'last-message.txt'),
157
+ ]
158
+ if (last) args.push('resume', '--last')
159
+ // The literal `-` selects stdin on BOTH shapes (fresh exec and
160
+ // `resume --last`, per the 0.153.4 help text): prompt content is always
161
+ // stdin data, so leading-dash prompts are never re-parsed as flags.
162
+ args.push('-')
163
+
164
+ const code = await runCodex(codex, args, { ...process.env, CODEX_HOME: H }, promptText)
165
+
166
+ const outFile = path.join(H, 'last-message.txt')
167
+ if (existsSync(outFile)) process.stdout.write(readFileSync(outFile))
168
+ return code
169
+ } finally {
170
+ releaseLock()
171
+ }
172
+ }
173
+
174
+ main().then(
175
+ (code) => {
176
+ process.exitCode = code
177
+ },
178
+ (e) => {
179
+ console.error(e?.message ?? e)
180
+ process.exitCode = e?.exitCode ?? 1
181
+ },
182
+ )
@@ -0,0 +1,368 @@
1
+ /**
2
+ * §3.2-d pinned argv grammar for the codex-rescue-bridge (§5: one shared
3
+ * parser source for matcher and launcher; the exported FIXTURES table is
4
+ * the single fixture suite PR-2's hook tests reuse).
5
+ *
6
+ * invocation := NODE LAUNCHER [--last] prompt
7
+ * prompt := '--prompt-file' PATH | '--' SINGLE_LINE_NONEMPTY_PROMPT
8
+ *
9
+ * Rules:
10
+ * - NODE and LAUNCHER are expansion-free literal tokens byte-equal to the
11
+ * registration-time canonical paths (a symlink literal, a tilde/glob/$VAR
12
+ * expansion, or a PATH-resolved name never byte-equals a canonical
13
+ * absolute literal — indirection dies here).
14
+ * - `--last` appears at most once, only in the slot shown.
15
+ * - the positional prompt is a single literal token, never empty, no
16
+ * newline, and MAY start with `-` (it reaches Codex via stdin, so it is
17
+ * data, never a flag).
18
+ * - PATH for --prompt-file is non-empty; containment is checked at match
19
+ * time by the PR-2 hook, not here.
20
+ * - unknown shapes fail closed with a machine-readable `reason`.
21
+ */
22
+ import { lexCommand } from './lexer.mjs'
23
+
24
+ /** Example canonical anchor pair used by the shared fixture table. */
25
+ export const CANONICAL_NODE = '/usr/local/bin/node'
26
+ export const CANONICAL_LAUNCHER = '/opt/dsh-cc/cc-codex-bridge/scripts/codex-rescue-run.mjs'
27
+
28
+ const fail = (reason) => ({ ok: false, layer: 'argv', reason })
29
+ const ok = (value) => ({ ok: true, layer: 'argv', value })
30
+
31
+ /**
32
+ * Parse a lexer-resolved argv (words with { text, expansion }) against the
33
+ * pinned grammar. `node`/`launcher` are the byte-pinned canonical anchors.
34
+ */
35
+ export function parseArgv(argv, { node, launcher }) {
36
+ if (argv.length < 2) return fail('missing-anchor')
37
+ if (argv[0].expansion) return fail('expansion-in-argv0')
38
+ if (argv[1].expansion) return fail('expansion-in-argv1')
39
+ if (argv[0].text !== node) return fail('argv0-byte-mismatch')
40
+ if (argv[1].text !== launcher) return fail('argv1-byte-mismatch')
41
+ let i = 2
42
+ let last = false
43
+ if (argv[i] !== undefined && argv[i].text === '--last') {
44
+ last = true
45
+ i++
46
+ }
47
+ const rest = argv.slice(i)
48
+ if (rest.length === 0) return fail('missing-prompt')
49
+ if (rest[0].text === '--prompt-file') {
50
+ if (rest.length < 2 || rest[1].text === '') return fail('missing-prompt-file-path')
51
+ if (rest.length > 2) return fail('trailing-arguments')
52
+ return ok({ last, prompt: { kind: 'prompt-file', path: rest[1].text } })
53
+ }
54
+ if (rest[0].text !== '--') return fail('unexpected-token')
55
+ if (rest.length < 2) return fail('missing-prompt')
56
+ if (rest.length > 2) return fail('trailing-arguments')
57
+ const prompt = rest[1].text
58
+ if (prompt === '') return fail('empty-prompt')
59
+ if (prompt.includes('\n')) return fail('prompt-newline')
60
+ return ok({ last, prompt: { kind: 'inline', text: prompt } })
61
+ }
62
+
63
+ /**
64
+ * Full matcher pipeline: lex the raw bash command to exactly one simple
65
+ * command (§3.2-a), then pin its argv to the grammar (§3.2-d).
66
+ */
67
+ export function matchInvocation(raw, { node = CANONICAL_NODE, launcher = CANONICAL_LAUNCHER } = {}) {
68
+ const lex = lexCommand(raw)
69
+ if (!lex.ok) return lex
70
+ return parseArgv(lex.argv, { node, launcher })
71
+ }
72
+
73
+ /**
74
+ * Shared fixture table (§5 one-shared-parser-source rule). Every row runs
75
+ * through matchInvocation; positive rows pin the normalized parse, negative
76
+ * rows carry `reason` = the violated rule. `layer` documents which layer
77
+ * rejects (or accepts) the row; lexer-layer rows fail regardless of anchors.
78
+ */
79
+ export const FIXTURES = [
80
+ // ---- positive: pinned normalized shapes ----
81
+ {
82
+ name: 'inline prompt',
83
+ layer: 'argv',
84
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "review the failing spec"`,
85
+ ok: true,
86
+ value: { last: false, prompt: { kind: 'inline', text: 'review the failing spec' } },
87
+ },
88
+ {
89
+ name: 'inline prompt with --last in its only slot',
90
+ layer: 'argv',
91
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last -- "continue the rescue"`,
92
+ ok: true,
93
+ value: { last: true, prompt: { kind: 'inline', text: 'continue the rescue' } },
94
+ },
95
+ {
96
+ name: 'prompt-file form',
97
+ layer: 'argv',
98
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file /tmp/prompts/p.txt`,
99
+ ok: true,
100
+ value: { last: false, prompt: { kind: 'prompt-file', path: '/tmp/prompts/p.txt' } },
101
+ },
102
+ {
103
+ name: 'prompt-file form with --last',
104
+ layer: 'argv',
105
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last --prompt-file /tmp/prompts/p.txt`,
106
+ ok: true,
107
+ value: { last: true, prompt: { kind: 'prompt-file', path: '/tmp/prompts/p.txt' } },
108
+ },
109
+ {
110
+ name: 'positional prompt may itself be --last (data after --)',
111
+ layer: 'argv',
112
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- --last`,
113
+ ok: true,
114
+ value: { last: false, prompt: { kind: 'inline', text: '--last' } },
115
+ },
116
+ {
117
+ name: 'positional prompt may start with - (stdin handoff makes it data)',
118
+ layer: 'argv',
119
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- -restart-from-scratch`,
120
+ ok: true,
121
+ value: { last: false, prompt: { kind: 'inline', text: '-restart-from-scratch' } },
122
+ },
123
+ {
124
+ name: 'quoted multi-word prompt is one token',
125
+ layer: 'argv',
126
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- 'multi word prompt'`,
127
+ ok: true,
128
+ value: { last: false, prompt: { kind: 'inline', text: 'multi word prompt' } },
129
+ },
130
+ {
131
+ name: 'command substitution inside single quotes is pure data',
132
+ layer: 'lexer',
133
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- '$(rm -rf /)'`,
134
+ ok: true,
135
+ value: { last: false, prompt: { kind: 'inline', text: '$(rm -rf /)' } },
136
+ },
137
+ {
138
+ name: 'hostile install path survives as concatenated quoted word',
139
+ layer: 'argv',
140
+ // The POSIX single-quote escape `'\''` needs `\\''` in a JS template
141
+ // literal: a lone `\` is swallowed (collapses to `''`, an empty quoted
142
+ // segment), which would lex the path to "quotes", not "quote's".
143
+ input: `${CANONICAL_NODE} '/opt/dsh space/quote'\\''s/cc-codex-bridge/scripts/codex-rescue-run.mjs' -- p`,
144
+ launcher: '/opt/dsh space/quote\'s/cc-codex-bridge/scripts/codex-rescue-run.mjs',
145
+ ok: true,
146
+ value: { last: false, prompt: { kind: 'inline', text: 'p' } },
147
+ },
148
+ // ---- negative: lexer layer (§3.2-a) ----
149
+ {
150
+ name: 'unquoted semicolon (compound command)',
151
+ layer: 'lexer',
152
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p; rm -rf /`,
153
+ ok: false,
154
+ reason: 'unquoted-operator',
155
+ },
156
+ {
157
+ name: 'unquoted && (compound command)',
158
+ layer: 'lexer',
159
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p && echo owned`,
160
+ ok: false,
161
+ reason: 'unquoted-operator',
162
+ },
163
+ {
164
+ name: 'unquoted || (compound command)',
165
+ layer: 'lexer',
166
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p || echo fallback`,
167
+ ok: false,
168
+ reason: 'unquoted-operator',
169
+ },
170
+ {
171
+ name: 'unquoted pipe',
172
+ layer: 'lexer',
173
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p | tee /tmp/leak`,
174
+ ok: false,
175
+ reason: 'unquoted-operator',
176
+ },
177
+ {
178
+ name: 'unquoted redirect out',
179
+ layer: 'lexer',
180
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p > /tmp/out`,
181
+ ok: false,
182
+ reason: 'unquoted-operator',
183
+ },
184
+ {
185
+ name: 'unquoted redirect in',
186
+ layer: 'lexer',
187
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p < /etc/passwd`,
188
+ ok: false,
189
+ reason: 'unquoted-operator',
190
+ },
191
+ {
192
+ name: 'unquoted newline (second command on next line)',
193
+ layer: 'lexer',
194
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p\ncurl evil.example`,
195
+ ok: false,
196
+ reason: 'unquoted-operator',
197
+ },
198
+ {
199
+ name: 'env-assignment prefix',
200
+ layer: 'lexer',
201
+ input: `BASH_ENV=/tmp/x ${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p`,
202
+ ok: false,
203
+ reason: 'env-assignment-prefix',
204
+ },
205
+ {
206
+ name: 'unquoted $() command substitution',
207
+ layer: 'lexer',
208
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- $(echo p)`,
209
+ ok: false,
210
+ reason: 'command-substitution-unquoted',
211
+ },
212
+ {
213
+ name: 'unquoted backtick command substitution',
214
+ layer: 'lexer',
215
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- \`echo p\``,
216
+ ok: false,
217
+ reason: 'command-substitution-unquoted',
218
+ },
219
+ {
220
+ name: '$() inside double quotes (bash evaluates it)',
221
+ layer: 'lexer',
222
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "$(echo p)"`,
223
+ ok: false,
224
+ reason: 'command-substitution-double-quoted',
225
+ },
226
+ {
227
+ name: 'backticks inside double quotes',
228
+ layer: 'lexer',
229
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "\`echo p\`"`,
230
+ ok: false,
231
+ reason: 'command-substitution-double-quoted',
232
+ },
233
+ {
234
+ name: 'unclosed quote',
235
+ layer: 'lexer',
236
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- 'p`,
237
+ ok: false,
238
+ reason: 'unclosed-quote',
239
+ },
240
+ // ---- negative: expansion-bearing anchor tokens (§3.2-b) ----
241
+ {
242
+ name: 'tilde expansion in argv[0]',
243
+ layer: 'argv',
244
+ input: `~/bin/node ${CANONICAL_LAUNCHER} -- p`,
245
+ ok: false,
246
+ reason: 'expansion-in-argv0',
247
+ },
248
+ {
249
+ name: 'glob expansion in launcher path',
250
+ layer: 'argv',
251
+ input: `${CANONICAL_NODE} /opt/dsh-cc/*/scripts/codex-rescue-run.mjs -- p`,
252
+ ok: false,
253
+ reason: 'expansion-in-argv1',
254
+ },
255
+ {
256
+ name: '$VAR expansion in argv[0]',
257
+ layer: 'argv',
258
+ input: `$NODE_BIN ${CANONICAL_LAUNCHER} -- p`,
259
+ ok: false,
260
+ reason: 'expansion-in-argv0',
261
+ },
262
+ // ---- negative: argv layer (§3.2-b/d) ----
263
+ {
264
+ name: 'forged launcher path (absolute but not the anchor)',
265
+ layer: 'argv',
266
+ input: `${CANONICAL_NODE} /tmp/evil/scripts/codex-rescue-run.mjs -- p`,
267
+ ok: false,
268
+ reason: 'argv1-byte-mismatch',
269
+ },
270
+ {
271
+ name: 'PATH-trampoline interpreter bare name',
272
+ layer: 'argv',
273
+ input: `node ${CANONICAL_LAUNCHER} -- p`,
274
+ ok: false,
275
+ reason: 'argv0-byte-mismatch',
276
+ },
277
+ {
278
+ name: 'symlink-literal anchor (bytes differ from the canonical launcher)',
279
+ layer: 'argv',
280
+ input: `${CANONICAL_NODE} /tmp/link-to-codex-rescue-run.mjs -- p`,
281
+ ok: false,
282
+ reason: 'argv1-byte-mismatch',
283
+ },
284
+ {
285
+ name: 'duplicate --last',
286
+ layer: 'argv',
287
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last --last -- p`,
288
+ ok: false,
289
+ reason: 'unexpected-token',
290
+ },
291
+ {
292
+ name: 'misplaced --last after the prompt',
293
+ layer: 'argv',
294
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p --last`,
295
+ ok: false,
296
+ reason: 'trailing-arguments',
297
+ },
298
+ {
299
+ name: 'misplaced --last alone (no prompt)',
300
+ layer: 'argv',
301
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last`,
302
+ ok: false,
303
+ reason: 'missing-prompt',
304
+ },
305
+ {
306
+ name: 'no prompt at all',
307
+ layer: 'argv',
308
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER}`,
309
+ ok: false,
310
+ reason: 'missing-prompt',
311
+ },
312
+ {
313
+ name: 'empty positional prompt',
314
+ layer: 'argv',
315
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- ""`,
316
+ ok: false,
317
+ reason: 'empty-prompt',
318
+ },
319
+ {
320
+ name: '-- with no prompt token',
321
+ layer: 'argv',
322
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --`,
323
+ ok: false,
324
+ reason: 'missing-prompt',
325
+ },
326
+ {
327
+ name: 'newline-bearing positional prompt',
328
+ layer: 'argv',
329
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "line one\nline two"`,
330
+ ok: false,
331
+ reason: 'prompt-newline',
332
+ },
333
+ {
334
+ name: 'unknown flag before the prompt',
335
+ layer: 'argv',
336
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --json -- p`,
337
+ ok: false,
338
+ reason: 'unexpected-token',
339
+ },
340
+ {
341
+ name: '--prompt-file without a path',
342
+ layer: 'argv',
343
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file`,
344
+ ok: false,
345
+ reason: 'missing-prompt-file-path',
346
+ },
347
+ {
348
+ name: '--prompt-file with empty path',
349
+ layer: 'argv',
350
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file ""`,
351
+ ok: false,
352
+ reason: 'missing-prompt-file-path',
353
+ },
354
+ {
355
+ name: '--prompt-file with trailing arguments',
356
+ layer: 'argv',
357
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file /tmp/p.txt -- q`,
358
+ ok: false,
359
+ reason: 'trailing-arguments',
360
+ },
361
+ {
362
+ name: 'positional prompt with trailing extra argument',
363
+ layer: 'argv',
364
+ input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p extra`,
365
+ ok: false,
366
+ reason: 'trailing-arguments',
367
+ },
368
+ ]
@@ -0,0 +1,99 @@
1
+ /**
2
+ * canonical.mjs — the single arming/derivation source for the
3
+ * codex-rescue-bridge hooks (plan §9 S3, §5 anchor-lint "one source").
4
+ *
5
+ * Every hook (PreToolUse allow, SessionStart context) derives the canonical
6
+ * anchor pair and the refusal/arming verdict through THIS module only; the
7
+ * specs pin that no hook re-implements the derivation.
8
+ *
9
+ * Canonical anchors (§3.1): `realpath(process.execPath)` of the calling hook
10
+ * process plus the realpath of the sibling `scripts/codex-rescue-run.mjs`
11
+ * resolved from the hook's own `import.meta.url`. Refusal set (§3.2-e): the
12
+ * canonicalized session cwd plus the canonical tmp roots, unrealpathable
13
+ * entries skipped.
14
+ */
15
+ import { realpathSync } from 'node:fs'
16
+ import { tmpdir } from 'node:os'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ export const safeReal = (p) => {
20
+ try {
21
+ return realpathSync(p)
22
+ } catch {
23
+ return null
24
+ }
25
+ }
26
+
27
+ /** Containment against canonicalized roots (path-boundary aware). */
28
+ export const inside = (p, roots) => p !== null && roots.some((root) => p === root || p.startsWith(root + '/'))
29
+
30
+ /** POSIX single-quote escape: bytes inside the quotes are pure data. */
31
+ export const posixSingleQuote = (s) => `'` + String(s).replaceAll(`'`, `'\\''`) + `'`
32
+
33
+ /**
34
+ * Derive the canonical anchor pair from the CALLING hook's own location
35
+ * (pass that hook's `import.meta.url`). Returns null when either anchor is
36
+ * unrealpathable.
37
+ */
38
+ export function deriveAnchors(hookUrl) {
39
+ const node = safeReal(process.execPath)
40
+ const launcher = safeReal(fileURLToPath(new URL('../scripts/codex-rescue-run.mjs', hookUrl)))
41
+ if (node === null || launcher === null) return null
42
+ return { node, launcher }
43
+ }
44
+
45
+ /** Refusal set (§3.2-e): session cwd plus canonical tmp roots. */
46
+ export function refusalRoots(cwdRaw) {
47
+ return [safeReal(cwdRaw), safeReal(tmpdir()), safeReal('/tmp')].filter((p) => p !== null)
48
+ }
49
+
50
+ /**
51
+ * Arming predicate with machine-readable reasons. `hookUrl` is the calling
52
+ * hook's `import.meta.url`; `env` defaults to `process.env` (the hooks pass
53
+ * nothing and inherit it).
54
+ */
55
+ export function arming(cwdRaw, { hookUrl, env = process.env } = {}) {
56
+ const anchors = deriveAnchors(hookUrl)
57
+ if (anchors === null) return { armed: false, reason: 'anchor-unrealpathable' }
58
+ const cwd = safeReal(typeof cwdRaw === 'string' && cwdRaw !== '' ? cwdRaw : process.cwd())
59
+ if (cwd === null) return { armed: false, reason: 'anchor-unrealpathable' }
60
+ const roots = refusalRoots(cwd)
61
+ if (inside(anchors.node, roots) || inside(anchors.launcher, roots)) {
62
+ return { armed: false, reason: 'anchor-under-writable-root' }
63
+ }
64
+ if (env.BASH_ENV) return { armed: false, reason: 'bash-env-set' }
65
+ if (env.ENV) return { armed: false, reason: 'env-set' }
66
+ return { armed: true, node: anchors.node, launcher: anchors.launcher, cwd }
67
+ }
68
+
69
+ /**
70
+ * The ARMED additionalContext block: the exact canonical invocation to type,
71
+ * both anchor paths POSIX single-quote-escaped so hostile install paths
72
+ * survive as pure data.
73
+ */
74
+ export function armedText({ node, launcher }) {
75
+ const q = posixSingleQuote
76
+ return [
77
+ 'cc-codex-bridge: the Codex rescue lane is ARMED. Use EXACTLY this canonical invocation for a rescue:',
78
+ `${q(node)} ${q(launcher)} -- 'the rescue prompt, single line'`,
79
+ 'For a multi-line prompt, write the text to a file inside this workspace (or the canonical tmpdir), then run:',
80
+ `${q(node)} ${q(launcher)} --prompt-file 'the prompt file path'`,
81
+ 'Add --last ONLY when the user explicitly asks to continue the previous rescue (it resumes the most recent rescue thread).',
82
+ ].join('\n')
83
+ }
84
+
85
+ /** The REFUSED additionalContext block: machine reason in plain words. */
86
+ export function refusedText(reason) {
87
+ const words =
88
+ reason === 'anchor-under-writable-root'
89
+ ? "the plugin's launcher sits under this session's workspace, expected in dsh-cc repo dev sessions"
90
+ : reason === 'bash-env-set'
91
+ ? 'the ambient BASH_ENV variable is non-empty (shell-function takeover defense)'
92
+ : reason === 'env-set'
93
+ ? 'the ambient ENV variable is non-empty (shell-function takeover defense)'
94
+ : 'the plugin\'s canonical anchors could not be resolved in this environment'
95
+ return [
96
+ `cc-codex-bridge: the Codex rescue lane is NOT armed (reason: ${reason} — ${words}).`,
97
+ 'Codex rescue today goes through the normal, approval-requiring path; do NOT guess or construct the bridge\'s canonical bash invocation manually.',
98
+ ].join('\n')
99
+ }