@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,179 @@
1
+ /**
2
+ * §3.1 steps 1–4 for the codex-rescue-bridge launcher: canonical per-cwd
3
+ * state home under a uid-suffixed canonical-tmpdir subroot, fail-loud
4
+ * ownership/mode validation, single-flight lock with heartbeat + signal
5
+ * release, and nofollow atomic credential sync.
6
+ */
7
+ import {
8
+ closeSync,
9
+ constants as fsConstants,
10
+ fstatSync,
11
+ lstatSync,
12
+ mkdirSync,
13
+ openSync,
14
+ readFileSync,
15
+ renameSync,
16
+ rmSync,
17
+ realpathSync,
18
+ utimesSync,
19
+ writeFileSync,
20
+ writeSync,
21
+ fsyncSync,
22
+ } from 'node:fs'
23
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
24
+ import os from 'node:os'
25
+ import path from 'node:path'
26
+
27
+ const LOCK_STALE_MS = 6 * 60 * 60 * 1000
28
+
29
+ /**
30
+ * R = <canonical tmpdir>/codex-rescue-home-<uid>, H = R/sha256(realpath(cwd))
31
+ * (§3.1 step 1: canonicalize FIRST — macOS /var and /tmp are symlinks and
32
+ * every path-boundary comparison is against canonical realpaths).
33
+ */
34
+ export function homePaths({ cwd = process.cwd(), tmpdir = os.tmpdir() } = {}) {
35
+ const tmp = realpathSync(tmpdir)
36
+ const R = path.join(tmp, `codex-rescue-home-${process.getuid()}`)
37
+ const H = path.join(
38
+ R,
39
+ createHash('sha256').update(realpathSync(cwd)).digest('hex').slice(0, 16),
40
+ )
41
+ return { tmp, R, H }
42
+ }
43
+
44
+ function assertOwnPrivateDir(dir, label) {
45
+ const st = lstatSync(dir) // throws if missing → fail loud
46
+ if (st.isSymbolicLink()) throw new Error(`${label} ${dir} is a symlink`)
47
+ if (!st.isDirectory()) throw new Error(`${label} ${dir} is not a directory`)
48
+ if (st.uid !== process.getuid()) throw new Error(`${label} ${dir} is not owned by the current uid`)
49
+ if ((st.mode & 0o777) !== 0o700) throw new Error(`${label} ${dir} mode is not 0700`)
50
+ }
51
+
52
+ /**
53
+ * Parent chain: only directories-that-are-not-symlinks are checked — a
54
+ * root-owned /tmp is normal and must not fail (§3.1 step 2).
55
+ */
56
+ function assertPlainAncestor(dir) {
57
+ const st = lstatSync(dir)
58
+ if (st.isSymbolicLink()) throw new Error(`parent ${dir} is a symlink`)
59
+ if (!st.isDirectory()) throw new Error(`parent ${dir} is not a directory`)
60
+ }
61
+
62
+ /** mkdir -m 0700 R and H, then lstat-validate both (§3.1 step 2). */
63
+ export function prepareHome(opts = {}) {
64
+ const { tmp, R, H } = homePaths(opts)
65
+ let parent = path.dirname(R)
66
+ while (parent !== path.dirname(parent)) {
67
+ assertPlainAncestor(parent)
68
+ parent = path.dirname(parent)
69
+ }
70
+ mkdirSync(R, { mode: 0o700, recursive: true })
71
+ assertOwnPrivateDir(R, 'state subroot')
72
+ mkdirSync(H, { mode: 0o700, recursive: true })
73
+ assertOwnPrivateDir(H, 'workspace home')
74
+ return { tmp, R, H }
75
+ }
76
+
77
+ /**
78
+ * Acquire the single-flight lock `mkdir H/.lock` (§3.1 step 3). Carries an
79
+ * owner nonce, is heartbeat-touched while held, stale (>6h mtime) is
80
+ * reclaimed. ponytail/best-effort note (per §4): this is an
81
+ * accidental-concurrency guard for cooperative callers, NOT an adversarial
82
+ * cost cap — a same-UID process can always delete it or run codex directly.
83
+ * Returns the release function; call it in `finally` (the launcher also
84
+ * wires signal handlers through it).
85
+ */
86
+ export function acquireLock(H) {
87
+ const lockDir = path.join(H, '.lock')
88
+ try {
89
+ mkdirSync(lockDir, { mode: 0o700 })
90
+ } catch {
91
+ const st = lstatSync(lockDir)
92
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
93
+ rmSync(lockDir, { recursive: true, force: true })
94
+ mkdirSync(lockDir, { mode: 0o700 })
95
+ } else {
96
+ throw new Error(`another launch holds ${lockDir} (not stale)`)
97
+ }
98
+ }
99
+ writeFileSync(path.join(lockDir, 'owner'), `${randomUUID()}\n`, { mode: 0o600 })
100
+ const heartbeat = setInterval(() => {
101
+ try {
102
+ utimesSync(lockDir, new Date(), new Date())
103
+ } catch {
104
+ /* lock vanished underneath us — release will notice too */
105
+ }
106
+ }, 60_000)
107
+ const sweep = () => {
108
+ try {
109
+ rmSync(lockDir, { recursive: true, force: true })
110
+ } catch {
111
+ /* best-effort */
112
+ }
113
+ }
114
+ const onSignal = () => {
115
+ release()
116
+ process.exit(1)
117
+ }
118
+ const onExit = () => sweep()
119
+ const release = () => {
120
+ clearInterval(heartbeat)
121
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(sig, onSignal)
122
+ process.off('exit', onExit)
123
+ sweep()
124
+ }
125
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, onSignal)
126
+ process.on('exit', onExit)
127
+ return release
128
+ }
129
+
130
+ /**
131
+ * Credential sync inside the lock (§3.1 step 4): read auth.json/config.toml
132
+ * with O_NOFOLLOW after source lstat (regular file), write to H/.tmp-<random>
133
+ * mode 0600, fsync, atomic rename, overwrite always. A missing/unreadable
134
+ * source DELETES the H copy and warns on stderr (a revoked token must not
135
+ * live on in the shadow home); any other sync failure throws (abort launch).
136
+ *
137
+ * Source home is os.homedir() — POSIX honors $HOME, which is the test seam
138
+ * (specs point HOME at a fake home; the real ~/.codex is never touched).
139
+ */
140
+ export function syncCredentials(H, { home = os.homedir(), warn = (m) => console.error(m) } = {}) {
141
+ const srcDir = path.join(home, '.codex')
142
+ for (const name of ['auth.json', 'config.toml']) {
143
+ const src = path.join(srcDir, name)
144
+ const dst = path.join(H, name)
145
+ let st
146
+ try {
147
+ st = lstatSync(src)
148
+ } catch {
149
+ rmSync(dst, { force: true })
150
+ warn(`codex-rescue: source ${src} missing/unreadable — removed shadow copy`)
151
+ continue
152
+ }
153
+ if (st.isSymbolicLink() || !st.isFile()) {
154
+ throw new Error(`source ${src} is not a regular file`)
155
+ }
156
+ // O_NOFOLLOW at open: even if src were swapped to a symlink between the
157
+ // lstat above and here, the open itself refuses to follow it.
158
+ const rfd = openSync(src, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW)
159
+ try {
160
+ if (!fstatSync(rfd).isFile()) throw new Error(`source ${src} is not a regular file`)
161
+ const data = readFileSync(rfd)
162
+ const tmpDst = path.join(H, `.tmp-${randomBytes(8).toString('hex')}`)
163
+ const wfd = openSync(
164
+ tmpDst,
165
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
166
+ 0o600,
167
+ )
168
+ try {
169
+ writeSync(wfd, data)
170
+ fsyncSync(wfd)
171
+ } finally {
172
+ closeSync(wfd)
173
+ }
174
+ renameSync(tmpDst, dst) // atomic, overwrite always
175
+ } finally {
176
+ closeSync(rfd)
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * §3.2-a mini POSIX-shell lexer for the codex-rescue-bridge matcher.
3
+ *
4
+ * Grammar (hostile-input constraints, all strict):
5
+ *
6
+ * command := word (blank+ word)* EOF-without-operator
7
+ * word := segment* ; segments concatenate into one word
8
+ * segment := unquoted | '\'' single-quoted '\'' | '"' double-quoted '"'
9
+ * | '\' escape | '$' expansion | '`' | '~' | glob
10
+ *
11
+ * Rejections (fail closed, machine-readable `reason`):
12
+ * - any UNQUOTED operator: `;` `&&` `||` `|` `>` `<` newline -> 'unquoted-operator'
13
+ * - env-assignment prefix on the first word (`FOO=1 cmd`) -> 'env-assignment-prefix'
14
+ * - command substitution in UNQUOTED context ($(), backticks) -> 'command-substitution-unquoted'
15
+ * - command substitution in DOUBLE-QUOTED context -> 'command-substitution-double-quoted'
16
+ * (bash evaluates those inside double quotes; SINGLE-QUOTED bytes are pure
17
+ * data and pass — this is what lets hostile install paths be expressed)
18
+ * - unclosed quote -> 'unclosed-quote'
19
+ *
20
+ * Expansion-bearing words (tilde at word start, glob `*?[`, `$VAR`/`${...}`,
21
+ * `$` of any kind) are flagged with `expansion: true` so the matcher can
22
+ * require argv[0]/argv[1] to be expansion-free literal bytes.
23
+ *
24
+ * One word may concatenate quoted and unquoted segments ('a'\''b' is the
25
+ * POSIX spelling of a literal `a'b`) — byte equality of the concatenated
26
+ * text is what the matcher pins, never the quoting.
27
+ */
28
+
29
+ export function lexCommand(raw) {
30
+ const fail = (reason) => ({ ok: false, layer: 'lexer', reason })
31
+ const argv = []
32
+ let word = null // { text, expansion } | null
33
+ const flush = () => {
34
+ if (word !== null) {
35
+ argv.push(word)
36
+ word = null
37
+ }
38
+ }
39
+ let i = 0
40
+ while (i < raw.length) {
41
+ const c = raw[i]
42
+ if (c === ' ' || c === '\t') {
43
+ flush()
44
+ i++
45
+ continue
46
+ }
47
+ if (c === '\n' || c === '\r') {
48
+ flush()
49
+ if (argv.length > 0) return fail('unquoted-operator')
50
+ i++
51
+ continue
52
+ }
53
+ if (';|&<>'.includes(c)) {
54
+ return fail('unquoted-operator')
55
+ }
56
+ if (c === '#' && word === null) {
57
+ // comment: skip to end of line
58
+ while (i < raw.length && raw[i] !== '\n') i++
59
+ continue
60
+ }
61
+ if (word === null) word = { text: '', expansion: false }
62
+ while (i < raw.length) {
63
+ const ch = raw[i]
64
+ if (ch === '\'') {
65
+ // single-quoted segment: pure data, no expansions ever
66
+ i++
67
+ const start = i
68
+ while (i < raw.length && raw[i] !== '\'') i++
69
+ if (i >= raw.length) return fail('unclosed-quote')
70
+ word.text += raw.slice(start, i)
71
+ i++ // closing quote
72
+ continue
73
+ }
74
+ if (ch === '"') {
75
+ // double-quoted segment: bash still evaluates $() and backticks here
76
+ i++
77
+ while (i < raw.length && raw[i] !== '"') {
78
+ const d = raw[i]
79
+ if (d === '\\') {
80
+ if (i + 1 >= raw.length) return fail('unclosed-quote')
81
+ const n = raw[i + 1]
82
+ if (n === '$' || n === '`' || n === '"' || n === '\\') {
83
+ word.text += n
84
+ i += 2
85
+ } else {
86
+ word.text += d
87
+ i++
88
+ }
89
+ continue
90
+ }
91
+ if (d === '`') return fail('command-substitution-double-quoted')
92
+ if (d === '$') {
93
+ if (raw[i + 1] === '(') return fail('command-substitution-double-quoted')
94
+ word.expansion = true
95
+ word.text += d
96
+ i++
97
+ continue
98
+ }
99
+ // quoted newline is data here; the argv grammar rejects it in prompts
100
+ word.text += d
101
+ i++
102
+ }
103
+ if (i >= raw.length) return fail('unclosed-quote')
104
+ i++ // closing quote
105
+ continue
106
+ }
107
+ if (ch === '$') {
108
+ if (raw[i + 1] === '(') return fail('command-substitution-unquoted')
109
+ word.expansion = true
110
+ word.text += ch
111
+ i++
112
+ continue
113
+ }
114
+ if (ch === '`') return fail('command-substitution-unquoted')
115
+ if (ch === '\\' && i + 1 < raw.length) {
116
+ // escape: the next byte is a literal (a backslash-newline is a
117
+ // continuation, not an operator)
118
+ word.text += raw[i + 1]
119
+ i += 2
120
+ continue
121
+ }
122
+ if (ch === '~' && word.text === '') {
123
+ // tilde expansion only at the start of an unquoted word
124
+ word.expansion = true
125
+ word.text += ch
126
+ i++
127
+ continue
128
+ }
129
+ if ('*?['.includes(ch)) {
130
+ word.expansion = true
131
+ word.text += ch
132
+ i++
133
+ continue
134
+ }
135
+ if (' \t\n\r;|&<>'.includes(ch)) break // back to outer loop, which rules on it
136
+ word.text += ch
137
+ i++
138
+ }
139
+ }
140
+ flush()
141
+ if (argv.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0].text)) {
142
+ return fail('env-assignment-prefix')
143
+ }
144
+ return { ok: true, layer: 'lexer', argv }
145
+ }