@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.
- package/.claude-plugin/plugin.json +5 -0
- package/LICENSE +201 -0
- package/README.i18n.yaml +6 -0
- package/README.md +91 -0
- package/README.zh.md +76 -0
- package/commands/rescue.md +30 -0
- package/hooks/codex-rescue-allow.mjs +76 -0
- package/hooks/codex-rescue-context.mjs +38 -0
- package/hooks/hooks.json +27 -0
- package/package.json +22 -0
- package/scripts/codex-rescue-run.mjs +182 -0
- package/scripts/lib/argv.mjs +368 -0
- package/scripts/lib/canonical.mjs +99 -0
- package/scripts/lib/home.mjs +179 -0
- package/scripts/lib/lexer.mjs +145 -0
|
@@ -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
|
+
}
|