@agfpd/iapeer 0.2.31 → 0.2.33
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/package.json +1 -1
- package/src/cli/index.ts +60 -0
- package/src/launch/canary.ts +1 -1
- package/src/launch/codexHooksTrust.test.ts +248 -0
- package/src/launch/codexHooksTrust.ts +372 -0
- package/src/lifecycle/index.ts +12 -3
- package/src/lifecycle/lifecycle.test.ts +34 -0
- package/src/transport/index.ts +23 -0
- package/src/transport/routesend-wake.test.ts +139 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -284,6 +284,10 @@ export interface RemoveOutcome {
|
|
|
284
284
|
* was dropped from the host codex config; a failure detail otherwise. Absent
|
|
285
285
|
* for non-codex peers and when no entry existed. */
|
|
286
286
|
codexTrust?: string
|
|
287
|
+
/** Codex hooks-trust cleanup outcome: `removed <n>` when pre-seeded
|
|
288
|
+
* `[hooks.state."<cwd>/…"]` entries were dropped (trust-hooks verb is the
|
|
289
|
+
* writer); a failure detail otherwise. Absent when nothing matched. */
|
|
290
|
+
codexHooksTrust?: string
|
|
287
291
|
/** Identity-keyed lifecycle artifacts purged with the record (state/lifecycle/
|
|
288
292
|
* `<identity>.*` per runtime). Without this purge a NEWBORN peer reusing the
|
|
289
293
|
* personality inherits the dead namesake's parking (live defect, boris 10.06:
|
|
@@ -365,6 +369,7 @@ export async function removePeerCli(
|
|
|
365
369
|
// unprovision (the provider sees the peer's last consistent state first),
|
|
366
370
|
// best-effort like everything else on this path.
|
|
367
371
|
let trustCleaned: string | undefined
|
|
372
|
+
let hooksTrustCleaned: string | undefined
|
|
368
373
|
if (peer.runtimes.includes('codex')) {
|
|
369
374
|
try {
|
|
370
375
|
const { removeCodexCwdTrust } = await import('../launch/nativeMemory.ts')
|
|
@@ -373,6 +378,16 @@ export async function removePeerCli(
|
|
|
373
378
|
} catch (e) {
|
|
374
379
|
trustCleaned = `failed (${e instanceof Error ? e.message : String(e)})`
|
|
375
380
|
}
|
|
381
|
+
// Same class, hooks edition: pre-seeded `[hooks.state."<cwd>/…"]` entries
|
|
382
|
+
// (trust-hooks verb) must not outlive the peer either.
|
|
383
|
+
try {
|
|
384
|
+
const { removeCodexHooksTrustUnder } = await import('../launch/codexHooksTrust.ts')
|
|
385
|
+
const h = removeCodexHooksTrustUnder(peer.cwd, env)
|
|
386
|
+
hooksTrustCleaned =
|
|
387
|
+
h.state === 'written' ? `removed ${h.removed.length}` : h.state === 'already' ? undefined : `${h.state}${h.detail ? ` (${h.detail})` : ''}`
|
|
388
|
+
} catch (e) {
|
|
389
|
+
hooksTrustCleaned = `failed (${e instanceof Error ? e.message : String(e)})`
|
|
390
|
+
}
|
|
376
391
|
}
|
|
377
392
|
// Purge identity-keyed lifecycle state WITH the record (per runtime): stale
|
|
378
393
|
// .stopped/.idle-reaped/... must never outlive the peer and ambush a future
|
|
@@ -387,6 +402,7 @@ export async function removePeerCli(
|
|
|
387
402
|
purgedState,
|
|
388
403
|
...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
|
|
389
404
|
...(trustCleaned ? { codexTrust: trustCleaned } : {}),
|
|
405
|
+
...(hooksTrustCleaned ? { codexHooksTrust: hooksTrustCleaned } : {}),
|
|
390
406
|
}
|
|
391
407
|
}
|
|
392
408
|
|
|
@@ -542,6 +558,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
542
558
|
self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
|
|
543
559
|
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
544
560
|
memory-plugin <on|off> (--peer <p> | --all) install/remove the slot-declared provider plugin (claude per-peer, codex host-global)
|
|
561
|
+
trust-hooks <hooks.json> [--check] pre-seed codex hooks trust for a file-form hooks.json (no modal); --check = drift report
|
|
545
562
|
`
|
|
546
563
|
|
|
547
564
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
@@ -700,6 +717,45 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
700
717
|
}
|
|
701
718
|
return r.ok ? 0 : 1
|
|
702
719
|
}
|
|
720
|
+
case 'trust-hooks': {
|
|
721
|
+
// Deterministic pre-seed of the codex hooks trust state for a FILE-form
|
|
722
|
+
// hooks.json (user- or project-local layer) — the headless replacement for
|
|
723
|
+
// the "Hooks need review" modal: codex trusts per hook-command HASH, and in
|
|
724
|
+
// `codex exec` an untrusted hook is SILENTLY skipped (no modal, no error).
|
|
725
|
+
// Verified on codex-cli 0.138.0 (golden hashes in the module tests).
|
|
726
|
+
// Consumers: the memory provider's provision command (writes its per-peer
|
|
727
|
+
// <cwd>/.codex/hooks.json, then shells this verb), operators. `--check` is
|
|
728
|
+
// the read-only drift detector for verify pipelines — per-hook
|
|
729
|
+
// trusted/missing/drift, exit 1 on anything but full trust.
|
|
730
|
+
const target = positionals[0]
|
|
731
|
+
if (!target) return usage(errOut)
|
|
732
|
+
const { preSeedCodexHooksTrust, checkCodexHooksTrust } = await import('../launch/codexHooksTrust.ts')
|
|
733
|
+
if (flags.check === true) {
|
|
734
|
+
let checks
|
|
735
|
+
try {
|
|
736
|
+
checks = checkCodexHooksTrust(target, env)
|
|
737
|
+
} catch (e) {
|
|
738
|
+
errOut(`trust-hooks --check: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
739
|
+
return 1
|
|
740
|
+
}
|
|
741
|
+
for (const c of checks.checks) {
|
|
742
|
+
out(`${c.status}\t${c.event}\t${c.key}${c.status === 'drift' ? ` (state: ${c.found ?? '?'} ≠ expected: ${c.hash})` : ''}\n`)
|
|
743
|
+
}
|
|
744
|
+
if (checks.checks.length === 0) out('no command hooks found — nothing to check\n')
|
|
745
|
+
return checks.checks.every(c => c.status === 'trusted') ? 0 : 1
|
|
746
|
+
}
|
|
747
|
+
const r = preSeedCodexHooksTrust(target, env)
|
|
748
|
+
if (r.state === 'failed') {
|
|
749
|
+
errOut(`trust-hooks: ${r.detail}\n`)
|
|
750
|
+
return 1
|
|
751
|
+
}
|
|
752
|
+
out(
|
|
753
|
+
r.state === 'already'
|
|
754
|
+
? `already trusted: ${r.entries.length} hook(s) from ${r.source}${r.detail ? ` — ${r.detail}` : ''}\n`
|
|
755
|
+
: `trusted ${r.entries.length} hook(s) from ${r.source} → ${r.path}\n`,
|
|
756
|
+
)
|
|
757
|
+
return 0
|
|
758
|
+
}
|
|
703
759
|
case 'status': {
|
|
704
760
|
// Host snapshot (контракт «Слот памяти» §status): version + daemon health +
|
|
705
761
|
// the memory-slot line. Exit 1 iff the daemon is unhealthy (usable as a
|
|
@@ -850,6 +906,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
850
906
|
if (o.codexTrust) {
|
|
851
907
|
out(`codex trust entry: ${o.codexTrust}\n`)
|
|
852
908
|
}
|
|
909
|
+
// Same for pre-seeded hooks trust state (trust-hooks verb's writes).
|
|
910
|
+
if (o.codexHooksTrust) {
|
|
911
|
+
out(`codex hooks trust: ${o.codexHooksTrust}\n`)
|
|
912
|
+
}
|
|
853
913
|
// Stale identity-keyed markers must die with the record (boris 10.06: a
|
|
854
914
|
// namesake newborn inherited a dead peer's .stopped → refused to wake).
|
|
855
915
|
if (o.purgedState?.length) {
|
package/src/launch/canary.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function canaryScript(o: CanaryScriptOptions): string {
|
|
|
117
117
|
`{`,
|
|
118
118
|
` echo "server-death identity=${o.identity} ts=$ts server_pid=$SPID wait_rc=$rc cause=$cause"`,
|
|
119
119
|
` echo '--- vm_stat'; vm_stat`,
|
|
120
|
-
` echo '--- swapusage'; sysctl -n vm.swapusage`,
|
|
120
|
+
` echo '--- swapusage'; /usr/sbin/sysctl -n vm.swapusage`,
|
|
121
121
|
` echo '--- ps-top-rss'; ps axo pid,ppid,rss,etime,command | sort -rn -k3 | head -25`,
|
|
122
122
|
` echo '--- diagnosticreports-user'; ls -t "$HOME/Library/Logs/DiagnosticReports" 2>/dev/null | head`,
|
|
123
123
|
` echo '--- diagnosticreports-system'; ls -t /Library/Logs/DiagnosticReports 2>/dev/null | head`,
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// codex hooks trust pre-seed — the deterministic replacement for the "Hooks
|
|
2
|
+
// need review" modal. The HARD anchor here: the GOLDEN hashes — three values
|
|
3
|
+
// codex-cli 0.138.0 itself granted live ("Trust all and continue", smoke
|
|
4
|
+
// 11.06, isolated CODEX_HOME). If the re-implemented algorithm drifts from
|
|
5
|
+
// these, we would seed state codex ignores → hooks silently skipped fleet-wide.
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
9
|
+
import { tmpdir } from 'os'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { realpathSync } from 'fs'
|
|
12
|
+
import {
|
|
13
|
+
checkCodexHooksTrust,
|
|
14
|
+
commandHookHash,
|
|
15
|
+
computeHooksTrustEntries,
|
|
16
|
+
preSeedCodexHooksTrust,
|
|
17
|
+
removeCodexHooksTrustUnder,
|
|
18
|
+
} from './codexHooksTrust.ts'
|
|
19
|
+
import { codexGlobalConfigPath } from './nativeMemory.ts'
|
|
20
|
+
|
|
21
|
+
const dirs: string[] = []
|
|
22
|
+
function mkTmp(): string {
|
|
23
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-hookstrust-'))
|
|
24
|
+
dirs.push(d)
|
|
25
|
+
return d
|
|
26
|
+
}
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** Env pointing the codex global config into an isolated CODEX_HOME. */
|
|
32
|
+
function isolatedEnv(): { env: NodeJS.ProcessEnv; configPath: string } {
|
|
33
|
+
const home = mkTmp()
|
|
34
|
+
const env = { CODEX_HOME: home } as NodeJS.ProcessEnv
|
|
35
|
+
return { env, configPath: codexGlobalConfigPath(env) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeHooksJson(dir: string, content: unknown): string {
|
|
39
|
+
const path = join(dir, 'hooks.json')
|
|
40
|
+
writeFileSync(path, JSON.stringify(content, null, 2))
|
|
41
|
+
return path
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── golden hashes (granted LIVE by codex-cli 0.138.0) ──────────────────────
|
|
45
|
+
|
|
46
|
+
describe('commandHookHash — golden values from live-granted trust', () => {
|
|
47
|
+
test('session_start, no matcher, default timeout (V1 smoke hook)', () => {
|
|
48
|
+
expect(
|
|
49
|
+
commandHookHash('session_start', undefined, {
|
|
50
|
+
command: '/tmp/iapeer-codex-hooks-smoke/bin/stamp.sh user-hooks-json-sessionstart',
|
|
51
|
+
}),
|
|
52
|
+
).toBe('sha256:c21b34240dd15d7184bf391f13e820399cb4b8e97c22347689316f8d02207391')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('session_start, no matcher, default timeout (V2 smoke hook)', () => {
|
|
56
|
+
expect(
|
|
57
|
+
commandHookHash('session_start', undefined, {
|
|
58
|
+
command: '/tmp/iapeer-codex-hooks-smoke/bin/stamp.sh user-hooks-json-sessionstart-V2',
|
|
59
|
+
}),
|
|
60
|
+
).toBe('sha256:5727103dc6b7409ae2a811561e28de70b625a2bdd28f8715a73e6542836f34fe')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('post_tool_use, no matcher, default timeout (project-local smoke hook)', () => {
|
|
64
|
+
expect(
|
|
65
|
+
commandHookHash('post_tool_use', undefined, {
|
|
66
|
+
command: '/tmp/iapeer-codex-hooks-smoke/bin/stamp.sh proj-hooks-json-posttooluse',
|
|
67
|
+
}),
|
|
68
|
+
).toBe('sha256:86f96ab754e05851c6f2f9b162bd99c70359099cdb6c93c114d0e6df42971030')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('normalization knobs shift the hash: timeout, matcher, statusMessage', () => {
|
|
72
|
+
const base = commandHookHash('post_tool_use', undefined, { command: 'x' })
|
|
73
|
+
expect(commandHookHash('post_tool_use', undefined, { command: 'x', timeout: 30 })).not.toBe(base)
|
|
74
|
+
expect(commandHookHash('post_tool_use', 'Write|Edit', { command: 'x' })).not.toBe(base)
|
|
75
|
+
expect(commandHookHash('post_tool_use', undefined, { command: 'x', statusMessage: 's' })).not.toBe(base)
|
|
76
|
+
// explicit default timeout hashes the SAME as omitted (both normalize to 600)
|
|
77
|
+
expect(commandHookHash('post_tool_use', undefined, { command: 'x', timeout: 600 })).toBe(base)
|
|
78
|
+
// timeout 0 clamps to 1 (upstream .max(1)), distinct from the default
|
|
79
|
+
expect(commandHookHash('post_tool_use', undefined, { command: 'x', timeout: 0 })).toBe(
|
|
80
|
+
commandHookHash('post_tool_use', undefined, { command: 'x', timeout: 1 }),
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// ─── hooks.json → entries ────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe('computeHooksTrustEntries', () => {
|
|
88
|
+
test('keys carry source:event_snake:group:handler; matcher passes through', () => {
|
|
89
|
+
const entries = computeHooksTrustEntries(
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
hooks: {
|
|
92
|
+
PostToolUse: [
|
|
93
|
+
{ matcher: 'Write', hooks: [{ type: 'command', command: 'a' }] },
|
|
94
|
+
{ hooks: [{ type: 'command', command: 'b' }, { type: 'command', command: 'c' }] },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
'/some/hooks.json',
|
|
99
|
+
)
|
|
100
|
+
expect(entries.map(e => e.key)).toEqual([
|
|
101
|
+
'/some/hooks.json:post_tool_use:0:0',
|
|
102
|
+
'/some/hooks.json:post_tool_use:1:0',
|
|
103
|
+
'/some/hooks.json:post_tool_use:1:1',
|
|
104
|
+
])
|
|
105
|
+
expect(entries[0]!.hash).toBe(commandHookHash('post_tool_use', 'Write', { command: 'a' }))
|
|
106
|
+
expect(entries[1]!.hash).toBe(commandHookHash('post_tool_use', undefined, { command: 'b' }))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('skipped handlers (async / prompt / empty) consume their index, get no entry', () => {
|
|
110
|
+
const entries = computeHooksTrustEntries(
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
hooks: {
|
|
113
|
+
SessionStart: [
|
|
114
|
+
{
|
|
115
|
+
hooks: [
|
|
116
|
+
{ type: 'command', command: 'first', async: true }, // h=0 skipped
|
|
117
|
+
{ type: 'prompt' }, // h=1 skipped
|
|
118
|
+
{ type: 'command', command: ' ' }, // h=2 skipped (empty)
|
|
119
|
+
{ type: 'command', command: 'real' }, // h=3 KEPT
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
'/p/hooks.json',
|
|
126
|
+
)
|
|
127
|
+
expect(entries).toHaveLength(1)
|
|
128
|
+
expect(entries[0]!.key).toBe('/p/hooks.json:session_start:0:3')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('UserPromptSubmit/Stop force the matcher OFF (upstream matcher_pattern_for_event)', () => {
|
|
132
|
+
const entries = computeHooksTrustEntries(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
hooks: {
|
|
135
|
+
Stop: [{ matcher: 'ignored', hooks: [{ type: 'command', command: 'x' }] }],
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
'/p/hooks.json',
|
|
139
|
+
)
|
|
140
|
+
expect(entries[0]!.hash).toBe(commandHookHash('stop', undefined, { command: 'x' }))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('unknown event / malformed shapes are loud errors', () => {
|
|
144
|
+
expect(() => computeHooksTrustEntries(JSON.stringify({ hooks: { NotAnEvent: [] } }), '/p')).toThrow(
|
|
145
|
+
/unknown hook event/,
|
|
146
|
+
)
|
|
147
|
+
expect(() => computeHooksTrustEntries('not json', '/p')).toThrow(/not valid JSON/)
|
|
148
|
+
expect(() => computeHooksTrustEntries(JSON.stringify({}), '/p')).toThrow(/"hooks" object/)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// ─── pre-seed (state surgery) ────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe('preSeedCodexHooksTrust', () => {
|
|
155
|
+
test('absent config → sections appended; second run → already', () => {
|
|
156
|
+
const { env, configPath } = isolatedEnv()
|
|
157
|
+
const proj = mkTmp()
|
|
158
|
+
const hooksPath = writeHooksJson(proj, {
|
|
159
|
+
hooks: { SessionStart: [{ hooks: [{ type: 'command', command: '/x/stamp.sh' }] }] },
|
|
160
|
+
})
|
|
161
|
+
const first = preSeedCodexHooksTrust(hooksPath, env)
|
|
162
|
+
expect(first.state).toBe('written')
|
|
163
|
+
expect(first.entries).toHaveLength(1)
|
|
164
|
+
const real = realpathSync(hooksPath)
|
|
165
|
+
const text = readFileSync(configPath, 'utf8')
|
|
166
|
+
expect(text).toContain(`[hooks.state."${real}:session_start:0:0"]`)
|
|
167
|
+
expect(text).toContain(`trusted_hash = "${first.entries[0]!.hash}"`)
|
|
168
|
+
expect(preSeedCodexHooksTrust(hooksPath, env).state).toBe('already')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('NO-CLOBBER: foreign sections survive; changed hook updates IN PLACE', () => {
|
|
172
|
+
const { env, configPath } = isolatedEnv()
|
|
173
|
+
const proj = mkTmp()
|
|
174
|
+
const hooksPath = writeHooksJson(proj, {
|
|
175
|
+
hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'one' }] }] },
|
|
176
|
+
})
|
|
177
|
+
mkdirSync(join(env.CODEX_HOME!), { recursive: true })
|
|
178
|
+
const foreign = '[projects."/keep/me"]\ntrust_level = "trusted"\n\n[features]\nmemories = false\n'
|
|
179
|
+
writeFileSync(configPath, foreign)
|
|
180
|
+
preSeedCodexHooksTrust(hooksPath, env)
|
|
181
|
+
// change the command → hash changes → same section updated, not duplicated
|
|
182
|
+
writeHooksJson(proj, { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'two' }] }] } })
|
|
183
|
+
const second = preSeedCodexHooksTrust(hooksPath, env)
|
|
184
|
+
expect(second.state).toBe('written')
|
|
185
|
+
const text = readFileSync(configPath, 'utf8')
|
|
186
|
+
expect(text).toContain('[projects."/keep/me"]')
|
|
187
|
+
expect(text).toContain('memories = false')
|
|
188
|
+
const real = realpathSync(hooksPath)
|
|
189
|
+
const headers = text.split('\n').filter(l => l.includes(`${real}:session_start:0:0`))
|
|
190
|
+
expect(headers).toHaveLength(1) // updated in place — no duplicate section
|
|
191
|
+
expect(text).toContain(`trusted_hash = "${second.entries[0]!.hash}"`)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('missing hooks.json → failed (loud), config untouched', () => {
|
|
195
|
+
const { env, configPath } = isolatedEnv()
|
|
196
|
+
const o = preSeedCodexHooksTrust('/nonexistent/hooks.json', env)
|
|
197
|
+
expect(o.state).toBe('failed')
|
|
198
|
+
expect(existsSync(configPath)).toBe(false)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// ─── check (read-only drift detector) ────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
describe('checkCodexHooksTrust', () => {
|
|
205
|
+
test('missing → trusted after seed → drift after edit without re-seed', () => {
|
|
206
|
+
const { env } = isolatedEnv()
|
|
207
|
+
const proj = mkTmp()
|
|
208
|
+
const hooksPath = writeHooksJson(proj, {
|
|
209
|
+
hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: 'stamp' }] }] },
|
|
210
|
+
})
|
|
211
|
+
expect(checkCodexHooksTrust(hooksPath, env).checks[0]!.status).toBe('missing')
|
|
212
|
+
preSeedCodexHooksTrust(hooksPath, env)
|
|
213
|
+
expect(checkCodexHooksTrust(hooksPath, env).checks[0]!.status).toBe('trusted')
|
|
214
|
+
writeHooksJson(proj, { hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: 'edited' }] }] } })
|
|
215
|
+
const drifted = checkCodexHooksTrust(hooksPath, env).checks[0]!
|
|
216
|
+
expect(drifted.status).toBe('drift')
|
|
217
|
+
expect(drifted.found).toBeDefined()
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// ─── remove (reap-side cleanup) ──────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe('removeCodexHooksTrustUnder', () => {
|
|
224
|
+
test('drops sections under the cwd, keeps everything else', () => {
|
|
225
|
+
const { env, configPath } = isolatedEnv()
|
|
226
|
+
const peerCwd = mkTmp()
|
|
227
|
+
const hooksPath = writeHooksJson(peerCwd, {
|
|
228
|
+
hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'x' }] }] },
|
|
229
|
+
})
|
|
230
|
+
preSeedCodexHooksTrust(hooksPath, env)
|
|
231
|
+
// a foreign hooks.state entry from ANOTHER peer must survive
|
|
232
|
+
const foreign = '[hooks.state."/other/peer/hooks.json:session_start:0:0"]\ntrusted_hash = "sha256:keep"\n'
|
|
233
|
+
writeFileSync(configPath, readFileSync(configPath, 'utf8') + foreign)
|
|
234
|
+
const o = removeCodexHooksTrustUnder(peerCwd, env)
|
|
235
|
+
expect(o.state).toBe('written')
|
|
236
|
+
expect(o.removed).toHaveLength(1)
|
|
237
|
+
const text = readFileSync(configPath, 'utf8')
|
|
238
|
+
expect(text).not.toContain(realpathSync(hooksPath))
|
|
239
|
+
expect(text).toContain('/other/peer/hooks.json')
|
|
240
|
+
// idempotent
|
|
241
|
+
expect(removeCodexHooksTrustUnder(peerCwd, env).state).toBe('already')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('no config / no matches → already', () => {
|
|
245
|
+
const { env } = isolatedEnv()
|
|
246
|
+
expect(removeCodexHooksTrustUnder('/nope', env).state).toBe('already')
|
|
247
|
+
})
|
|
248
|
+
})
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// codexHooksTrust — deterministic pre-seed of the codex hooks trust state.
|
|
2
|
+
//
|
|
3
|
+
// codex gates EVERY hook (plugin-form AND file-form alike) behind a one-time
|
|
4
|
+
// interactive "Hooks need review" modal; trust is recorded per hook COMMAND in
|
|
5
|
+
// the codex GLOBAL config as
|
|
6
|
+
//
|
|
7
|
+
// [hooks.state."<source>:<event_snake>:<group>:<handler>"]
|
|
8
|
+
// trusted_hash = "sha256:<hex>"
|
|
9
|
+
//
|
|
10
|
+
// where <source> is the absolute (resolved) path of the hooks.json the hook
|
|
11
|
+
// came from. In headless `codex exec` the modal never shows — an untrusted
|
|
12
|
+
// hook is SILENTLY skipped. Writing the state entry BEFORE the first session
|
|
13
|
+
// makes the hook trusted deterministically: no modal, no TUI auto-responder.
|
|
14
|
+
//
|
|
15
|
+
// The hash algorithm is reverse-engineered from openai/codex
|
|
16
|
+
// (`hooks/src/engine/discovery.rs::command_hook_hash` +
|
|
17
|
+
// `config/src/fingerprint.rs::version_for_toml`) and verified against two
|
|
18
|
+
// live-granted hashes (smoke 11.06, isolated CODEX_HOME — the golden values
|
|
19
|
+
// live in the test file):
|
|
20
|
+
//
|
|
21
|
+
// trusted_hash = "sha256:" + sha256(compactJson(sortKeysDeep(identity)))
|
|
22
|
+
// identity = { event_name: "<snake>", matcher?: "<verbatim>",
|
|
23
|
+
// hooks: [{ type: "command", command, timeout, async: false,
|
|
24
|
+
// statusMessage? }] }
|
|
25
|
+
//
|
|
26
|
+
// Normalization (mirrors upstream discovery before hashing):
|
|
27
|
+
// - timeout = given.max(1), default 600 (commandWindows is dropped: the
|
|
28
|
+
// None fields vanish in the TOML round-trip upstream hashes through)
|
|
29
|
+
// - matcher = group's matcher verbatim, EXCEPT UserPromptSubmit/Stop where
|
|
30
|
+
// codex forces it to None (matcher_pattern_for_event)
|
|
31
|
+
// - async hooks, non-"command" handlers and empty commands are SKIPPED by
|
|
32
|
+
// codex before hashing — they get no state entry, but they DO consume
|
|
33
|
+
// their positional index.
|
|
34
|
+
//
|
|
35
|
+
// FRAGILITY PIN: verified on codex-cli 0.138.0. Upstream carries a TODO to
|
|
36
|
+
// replace the positional key suffix with a durable hook id — when that lands,
|
|
37
|
+
// `--check` (checkCodexHooksTrust) is the detector: it compares the state
|
|
38
|
+
// against a re-computation, so a format drift reads as missing/drift instead
|
|
39
|
+
// of silently lying. A future backend may instead ask codex itself
|
|
40
|
+
// (app-server `hooks/list` returns `current_hash`) without changing callers.
|
|
41
|
+
//
|
|
42
|
+
// Single ecosystem writer: ALL iapeer writes to the host ~/.codex/config.toml
|
|
43
|
+
// live in this repo (preTrustCodexCwd / removeCodexCwdTrust / here) — codex
|
|
44
|
+
// itself also writes the file, so every mutation is a section-scoped
|
|
45
|
+
// read-modify-write + atomic rename, never a rewrite.
|
|
46
|
+
|
|
47
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs'
|
|
48
|
+
import { createHash } from 'crypto'
|
|
49
|
+
import { dirname } from 'path'
|
|
50
|
+
import { writeFileAtomic } from '../storage/index.ts'
|
|
51
|
+
import { codexGlobalConfigPath } from './nativeMemory.ts'
|
|
52
|
+
|
|
53
|
+
/** The codex-cli version the hash algorithm + key format were verified on. */
|
|
54
|
+
export const TRUST_HOOKS_TESTED_CODEX_VERSION = '0.138.0'
|
|
55
|
+
|
|
56
|
+
/** hooks.json event names → the snake_case labels codex keys state with
|
|
57
|
+
* (upstream `hook_event_key_label`). */
|
|
58
|
+
const EVENT_LABELS: Record<string, string> = {
|
|
59
|
+
PreToolUse: 'pre_tool_use',
|
|
60
|
+
PermissionRequest: 'permission_request',
|
|
61
|
+
PostToolUse: 'post_tool_use',
|
|
62
|
+
PreCompact: 'pre_compact',
|
|
63
|
+
PostCompact: 'post_compact',
|
|
64
|
+
SessionStart: 'session_start',
|
|
65
|
+
UserPromptSubmit: 'user_prompt_submit',
|
|
66
|
+
SubagentStart: 'subagent_start',
|
|
67
|
+
SubagentStop: 'subagent_stop',
|
|
68
|
+
Stop: 'stop',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Events where codex force-drops the matcher (upstream matcher_pattern_for_event). */
|
|
72
|
+
const MATCHERLESS_EVENTS = new Set(['UserPromptSubmit', 'Stop'])
|
|
73
|
+
|
|
74
|
+
export interface HookTrustEntry {
|
|
75
|
+
/** Full state key: `<source path>:<event_snake>:<group>:<handler>`. */
|
|
76
|
+
key: string
|
|
77
|
+
/** `sha256:<hex>` of the normalized hook identity. */
|
|
78
|
+
hash: string
|
|
79
|
+
event: string
|
|
80
|
+
command: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type HookTrustCheckStatus = 'trusted' | 'missing' | 'drift'
|
|
84
|
+
|
|
85
|
+
export interface HookTrustCheck extends HookTrustEntry {
|
|
86
|
+
status: HookTrustCheckStatus
|
|
87
|
+
/** The hash currently in the state (when present and different). */
|
|
88
|
+
found?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface HooksTrustOutcome {
|
|
92
|
+
path: string // the codex global config written/read
|
|
93
|
+
source: string // the resolved hooks.json path used as the key source
|
|
94
|
+
state: 'written' | 'already' | 'failed'
|
|
95
|
+
entries: HookTrustEntry[]
|
|
96
|
+
detail?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── hashing ─────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function sortKeysDeep(value: unknown): unknown {
|
|
102
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep)
|
|
103
|
+
if (value && typeof value === 'object') {
|
|
104
|
+
const src = value as Record<string, unknown>
|
|
105
|
+
const sorted: Record<string, unknown> = {}
|
|
106
|
+
for (const k of Object.keys(src).sort()) sorted[k] = sortKeysDeep(src[k])
|
|
107
|
+
return sorted
|
|
108
|
+
}
|
|
109
|
+
return value
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Hash ONE normalized command hook the way codex does (see module header). */
|
|
113
|
+
export function commandHookHash(
|
|
114
|
+
eventLabel: string,
|
|
115
|
+
matcher: string | undefined,
|
|
116
|
+
handler: { command: string; timeout?: number; statusMessage?: string },
|
|
117
|
+
): string {
|
|
118
|
+
const normalized: Record<string, unknown> = {
|
|
119
|
+
type: 'command',
|
|
120
|
+
command: handler.command,
|
|
121
|
+
timeout: Math.max(1, Math.trunc(handler.timeout ?? 600)),
|
|
122
|
+
async: false,
|
|
123
|
+
...(handler.statusMessage !== undefined ? { statusMessage: handler.statusMessage } : {}),
|
|
124
|
+
}
|
|
125
|
+
const identity: Record<string, unknown> = {
|
|
126
|
+
event_name: eventLabel,
|
|
127
|
+
...(matcher !== undefined ? { matcher } : {}),
|
|
128
|
+
hooks: [normalized],
|
|
129
|
+
}
|
|
130
|
+
const json = JSON.stringify(sortKeysDeep(identity))
|
|
131
|
+
return `sha256:${createHash('sha256').update(json, 'utf8').digest('hex')}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── hooks.json → trust entries ──────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
interface RawHandler {
|
|
137
|
+
type?: unknown
|
|
138
|
+
command?: unknown
|
|
139
|
+
timeout?: unknown
|
|
140
|
+
statusMessage?: unknown
|
|
141
|
+
async?: unknown
|
|
142
|
+
}
|
|
143
|
+
interface RawGroup {
|
|
144
|
+
matcher?: unknown
|
|
145
|
+
hooks?: unknown
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parse a hooks.json (Claude-compatible shape codex reads) and compute the
|
|
150
|
+
* trust entries codex would record for it. `keySource` is the path codex will
|
|
151
|
+
* see the file at — pass the RESOLVED path (codex keys by realpath).
|
|
152
|
+
*/
|
|
153
|
+
export function computeHooksTrustEntries(hooksJsonText: string, keySource: string): HookTrustEntry[] {
|
|
154
|
+
let raw: unknown
|
|
155
|
+
try {
|
|
156
|
+
raw = JSON.parse(hooksJsonText)
|
|
157
|
+
} catch (e) {
|
|
158
|
+
throw new Error(`hooks.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`)
|
|
159
|
+
}
|
|
160
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('hooks.json root must be an object')
|
|
161
|
+
const events = (raw as Record<string, unknown>).hooks
|
|
162
|
+
if (!events || typeof events !== 'object' || Array.isArray(events)) {
|
|
163
|
+
throw new Error('hooks.json must carry a "hooks" object ({"hooks": {"<Event>": [...]}})')
|
|
164
|
+
}
|
|
165
|
+
const entries: HookTrustEntry[] = []
|
|
166
|
+
for (const [eventName, groups] of Object.entries(events as Record<string, unknown>)) {
|
|
167
|
+
const label = EVENT_LABELS[eventName]
|
|
168
|
+
if (!label) throw new Error(`unknown hook event "${eventName}" (known: ${Object.keys(EVENT_LABELS).join(', ')})`)
|
|
169
|
+
if (!Array.isArray(groups)) throw new Error(`event "${eventName}" must hold an array of matcher groups`)
|
|
170
|
+
for (let g = 0; g < groups.length; g++) {
|
|
171
|
+
const group = groups[g] as RawGroup
|
|
172
|
+
if (!group || typeof group !== 'object') continue
|
|
173
|
+
const matcher =
|
|
174
|
+
!MATCHERLESS_EVENTS.has(eventName) && typeof group.matcher === 'string' ? group.matcher : undefined
|
|
175
|
+
const handlers = Array.isArray(group.hooks) ? group.hooks : []
|
|
176
|
+
for (let h = 0; h < handlers.length; h++) {
|
|
177
|
+
const handler = handlers[h] as RawHandler
|
|
178
|
+
// Mirror upstream skips — each skip still CONSUMES its index (h):
|
|
179
|
+
if (!handler || typeof handler !== 'object') continue
|
|
180
|
+
if (handler.type !== 'command') continue // prompt/agent: unsupported upstream
|
|
181
|
+
if (handler.async === true) continue // async: skipped before hashing
|
|
182
|
+
if (typeof handler.command !== 'string' || handler.command.trim() === '') continue
|
|
183
|
+
entries.push({
|
|
184
|
+
key: `${keySource}:${label}:${g}:${h}`,
|
|
185
|
+
hash: commandHookHash(label, matcher, {
|
|
186
|
+
command: handler.command,
|
|
187
|
+
timeout: typeof handler.timeout === 'number' ? handler.timeout : undefined,
|
|
188
|
+
statusMessage: typeof handler.statusMessage === 'string' ? handler.statusMessage : undefined,
|
|
189
|
+
}),
|
|
190
|
+
event: eventName,
|
|
191
|
+
command: handler.command,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return entries
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── global-config section surgery ───────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Locate the body bounds of `[hooks.state."<key>"]`: header line index and
|
|
202
|
+
* [start, end) of the body (up to the next `[` header / EOF). null = absent. */
|
|
203
|
+
function stateSectionBounds(lines: string[], key: string): { header: number; start: number; end: number } | null {
|
|
204
|
+
const wanted = `[hooks.state."${key}"]`
|
|
205
|
+
const header = lines.findIndex(l => l.trim() === wanted)
|
|
206
|
+
if (header < 0) return null
|
|
207
|
+
let end = lines.length
|
|
208
|
+
for (let i = header + 1; i < lines.length; i++) {
|
|
209
|
+
if (/^\s*\[/.test(lines[i]!)) {
|
|
210
|
+
end = i
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { header, start: header + 1, end }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readTrustedHash(lines: string[], bounds: { start: number; end: number }): string | undefined {
|
|
218
|
+
for (let i = bounds.start; i < bounds.end; i++) {
|
|
219
|
+
const m = lines[i]!.match(/^\s*trusted_hash\s*=\s*"([^"]+)"\s*$/)
|
|
220
|
+
if (m) return m[1]
|
|
221
|
+
}
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function resolveHooksSource(hooksJsonPath: string): string {
|
|
226
|
+
// codex keys trust state on the RESOLVED path (same lesson as cwd trust:
|
|
227
|
+
// /tmp → /private/tmp made a literal entry miss). The file must exist —
|
|
228
|
+
// we parse it anyway.
|
|
229
|
+
return realpathSync(hooksJsonPath)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** TOML basic strings treat `\` and `"` specially — paths carrying them would
|
|
233
|
+
* need escaping codex-side too; refuse instead of guessing. */
|
|
234
|
+
function assertTomlSafeKey(key: string): void {
|
|
235
|
+
if (key.includes('"') || key.includes('\\')) {
|
|
236
|
+
throw new Error(`hooks.json path is not TOML-key-safe (contains " or \\): ${key}`)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Pre-seed the codex hooks trust state for every command hook in the given
|
|
242
|
+
* hooks.json — the deterministic, headless replacement for the "Hooks need
|
|
243
|
+
* review → Trust all" modal. Idempotent: unchanged entries are left alone;
|
|
244
|
+
* changed hashes are updated in place; missing sections are appended.
|
|
245
|
+
*/
|
|
246
|
+
export function preSeedCodexHooksTrust(
|
|
247
|
+
hooksJsonPath: string,
|
|
248
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
249
|
+
): HooksTrustOutcome {
|
|
250
|
+
const path = codexGlobalConfigPath(env)
|
|
251
|
+
try {
|
|
252
|
+
const source = resolveHooksSource(hooksJsonPath)
|
|
253
|
+
assertTomlSafeKey(source)
|
|
254
|
+
const entries = computeHooksTrustEntries(readFileSync(source, 'utf8'), source)
|
|
255
|
+
if (entries.length === 0) {
|
|
256
|
+
return { path, source, state: 'already', entries, detail: 'no command hooks to trust' }
|
|
257
|
+
}
|
|
258
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
259
|
+
const lines = text.length ? text.split('\n') : []
|
|
260
|
+
let changed = false
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
const bounds = stateSectionBounds(lines, entry.key)
|
|
263
|
+
if (bounds) {
|
|
264
|
+
if (readTrustedHash(lines, bounds) === entry.hash) continue
|
|
265
|
+
// replace (or insert) the trusted_hash line inside the existing section
|
|
266
|
+
let replaced = false
|
|
267
|
+
for (let i = bounds.start; i < bounds.end; i++) {
|
|
268
|
+
if (/^\s*trusted_hash\s*=/.test(lines[i]!)) {
|
|
269
|
+
lines[i] = `trusted_hash = "${entry.hash}"`
|
|
270
|
+
replaced = true
|
|
271
|
+
break
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!replaced) lines.splice(bounds.start, 0, `trusted_hash = "${entry.hash}"`)
|
|
275
|
+
changed = true
|
|
276
|
+
} else {
|
|
277
|
+
if (lines.length && lines[lines.length - 1]!.trim() !== '') lines.push('')
|
|
278
|
+
lines.push(`[hooks.state."${entry.key}"]`, `trusted_hash = "${entry.hash}"`)
|
|
279
|
+
changed = true
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (!changed) return { path, source, state: 'already', entries }
|
|
283
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
284
|
+
const outText = lines.join('\n')
|
|
285
|
+
writeFileAtomic(path, outText.endsWith('\n') ? outText : `${outText}\n`)
|
|
286
|
+
return { path, source, state: 'written', entries }
|
|
287
|
+
} catch (e) {
|
|
288
|
+
return {
|
|
289
|
+
path,
|
|
290
|
+
source: hooksJsonPath,
|
|
291
|
+
state: 'failed',
|
|
292
|
+
entries: [],
|
|
293
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Read-only drift check: for every command hook in hooks.json, compare the
|
|
300
|
+
* expected hash against the recorded state. `trusted` = match, `missing` = no
|
|
301
|
+
* state entry, `drift` = entry exists with a DIFFERENT hash (hooks.json edited
|
|
302
|
+
* after seeding, or the upstream algorithm/key format moved). Consumers (the
|
|
303
|
+
* memory provider's verify line) shell this instead of re-implementing the
|
|
304
|
+
* hash — the algorithm lives in ONE place.
|
|
305
|
+
*/
|
|
306
|
+
export function checkCodexHooksTrust(
|
|
307
|
+
hooksJsonPath: string,
|
|
308
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
309
|
+
): { path: string; source: string; checks: HookTrustCheck[] } {
|
|
310
|
+
const path = codexGlobalConfigPath(env)
|
|
311
|
+
const source = resolveHooksSource(hooksJsonPath)
|
|
312
|
+
const entries = computeHooksTrustEntries(readFileSync(source, 'utf8'), source)
|
|
313
|
+
const lines = existsSync(path) ? readFileSync(path, 'utf8').split('\n') : []
|
|
314
|
+
const checks: HookTrustCheck[] = entries.map(entry => {
|
|
315
|
+
const bounds = stateSectionBounds(lines, entry.key)
|
|
316
|
+
if (!bounds) return { ...entry, status: 'missing' }
|
|
317
|
+
const found = readTrustedHash(lines, bounds)
|
|
318
|
+
if (found === entry.hash) return { ...entry, status: 'trusted' }
|
|
319
|
+
return { ...entry, status: 'drift', found }
|
|
320
|
+
})
|
|
321
|
+
return { path, source, checks }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Reap-side counterpart for `iapeer remove`: drop every `[hooks.state."…"]`
|
|
326
|
+
* section whose key SOURCE path lies under the given cwd (resolved or literal
|
|
327
|
+
* form — a deleted cwd can no longer be resolved). Same class as
|
|
328
|
+
* removeCodexCwdTrust: a removed peer must not leave trust state behind.
|
|
329
|
+
*/
|
|
330
|
+
export function removeCodexHooksTrustUnder(
|
|
331
|
+
cwd: string,
|
|
332
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
333
|
+
): { path: string; state: 'written' | 'already' | 'failed'; removed: string[]; detail?: string } {
|
|
334
|
+
const path = codexGlobalConfigPath(env)
|
|
335
|
+
try {
|
|
336
|
+
if (!existsSync(path)) return { path, state: 'already', removed: [] }
|
|
337
|
+
let real = cwd
|
|
338
|
+
try {
|
|
339
|
+
real = realpathSync(cwd)
|
|
340
|
+
} catch {
|
|
341
|
+
/* cwd already deleted → only the literal form can match */
|
|
342
|
+
}
|
|
343
|
+
const prefixes = [...new Set([real, cwd])].map(p => (p.endsWith('/') ? p : `${p}/`))
|
|
344
|
+
const lines = readFileSync(path, 'utf8').split('\n')
|
|
345
|
+
const kept: string[] = []
|
|
346
|
+
const removed: string[] = []
|
|
347
|
+
let inDoomed = false
|
|
348
|
+
for (const line of lines) {
|
|
349
|
+
const header = line.match(/^\s*\[hooks\.state\."(.+)"\]\s*$/)
|
|
350
|
+
if (header) {
|
|
351
|
+
const key = header[1]!
|
|
352
|
+
// key = `<source path>:<event>:<group>:<handler>` — strip the 3-part
|
|
353
|
+
// positional suffix to recover the source path.
|
|
354
|
+
const m = key.match(/^(.*):[a-z_]+:\d+:\d+$/)
|
|
355
|
+
const sourcePath = m ? m[1]! : key
|
|
356
|
+
inDoomed = prefixes.some(p => sourcePath.startsWith(p))
|
|
357
|
+
if (inDoomed) {
|
|
358
|
+
removed.push(key)
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
} else if (inDoomed && /^\s*\[/.test(line)) {
|
|
362
|
+
inDoomed = false // next section starts — stop dropping
|
|
363
|
+
}
|
|
364
|
+
if (!inDoomed) kept.push(line)
|
|
365
|
+
}
|
|
366
|
+
if (removed.length === 0) return { path, state: 'already', removed }
|
|
367
|
+
writeFileAtomic(path, kept.join('\n').replace(/\n{3,}/g, '\n\n'))
|
|
368
|
+
return { path, state: 'written', removed }
|
|
369
|
+
} catch (e) {
|
|
370
|
+
return { path, state: 'failed', removed: [], detail: e instanceof Error ? e.message : String(e) }
|
|
371
|
+
}
|
|
372
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -654,7 +654,7 @@ export function classifyGoneSession(sock: string): { death: 'server-dead' | 'ses
|
|
|
654
654
|
// proves the server is alive and merely lost this session.
|
|
655
655
|
return tmux(sock, 'list-sessions').ok
|
|
656
656
|
? { death: 'session-gone', reason: 'session gone, tmux server alive (exit cause should be in exits.log)' }
|
|
657
|
-
: { death: 'server-dead', reason: 'tmux server dead — stale socket (SIGKILL
|
|
657
|
+
: { death: 'server-dead', reason: 'tmux server dead — stale socket (SIGKILL class — OOM only if a matching JetsamEvent report exists; check exits.log ev=server-exit canary record)' }
|
|
658
658
|
}
|
|
659
659
|
|
|
660
660
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -790,6 +790,13 @@ export interface WakeResult {
|
|
|
790
790
|
* deliberate halt, distinct from offline/wake-failure). The sender is told the
|
|
791
791
|
* peer is stopped, not that delivery failed transiently. */
|
|
792
792
|
stopped?: boolean
|
|
793
|
+
/** Was `task` actually delivered by this wake? true on the launch path (the
|
|
794
|
+
* task rode as the boot first-message); FALSE on the idempotent live-session
|
|
795
|
+
* fast path — a CONCURRENT sender's boot won the lock and only ITS envelope
|
|
796
|
+
* was delivered; this caller's task was NOT. The caller (routeSend) must then
|
|
797
|
+
* deliver the envelope itself via the live path, or the second sender's
|
|
798
|
+
* message is silently lost behind a false ok (contract forbids silent loss). */
|
|
799
|
+
taskDelivered?: boolean
|
|
793
800
|
}
|
|
794
801
|
|
|
795
802
|
export interface WakeDeps {
|
|
@@ -890,7 +897,9 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
890
897
|
// wake already brought it up) — no second spawn.
|
|
891
898
|
if (sessionAlive(sock, identity)) {
|
|
892
899
|
writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
|
|
893
|
-
|
|
900
|
+
// taskDelivered:false — the winning concurrent wake delivered ITS OWN task as
|
|
901
|
+
// the boot first-message; THIS caller's task was not delivered by anything.
|
|
902
|
+
return { status: 'READY', woke: false, runtime, process_address: identity, taskDelivered: false }
|
|
894
903
|
}
|
|
895
904
|
if (!existsSync(cwd)) {
|
|
896
905
|
return { status: 'FAILED', woke: false, runtime, reason: `peer cwd does not exist: ${cwd}` }
|
|
@@ -997,7 +1006,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
997
1006
|
// ring — a successful wake means this is NOT a tight crash loop. Best-effort.
|
|
998
1007
|
writeTopic(cfg, identity, args.topic?.trim() ?? '')
|
|
999
1008
|
trimDeaths(cfg, identity, cfg.crashLoopWindowSecs, Date.now())
|
|
1000
|
-
return { status: 'READY', woke: true, runtime, process_address: identity }
|
|
1009
|
+
return { status: 'READY', woke: true, runtime, process_address: identity, taskDelivered: true }
|
|
1001
1010
|
})
|
|
1002
1011
|
}
|
|
1003
1012
|
|
|
@@ -284,6 +284,40 @@ describe('wakeOrSpawn H4 refusal', () => {
|
|
|
284
284
|
})
|
|
285
285
|
})
|
|
286
286
|
|
|
287
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
288
|
+
// wakeOrSpawn live-session fast path — marks the caller's task as NOT delivered
|
|
289
|
+
// (the concurrent-sender envelope-loss seam: routeSend redelivers on this flag)
|
|
290
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
describe('wakeOrSpawn live-session fast path', () => {
|
|
293
|
+
test('READY with taskDelivered:false — the winning wake delivered only ITS envelope', async () => {
|
|
294
|
+
const root = mkdtempSync(join(tmpdir(), 'iapeer-fpl-root-'))
|
|
295
|
+
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-fpl-la-')) // empty → not launchd-managed
|
|
296
|
+
const sockDir = join(root, 'socks')
|
|
297
|
+
const sock = join(sockDir, 'tmux-iap-claude-fpl.sock')
|
|
298
|
+
try {
|
|
299
|
+
mkdirSync(sockDir, { recursive: true })
|
|
300
|
+
await upsertPeer(
|
|
301
|
+
{ personality: 'fpl', runtime: 'claude', cwd: root, intelligence: 'artificial' },
|
|
302
|
+
{ rootDir: root },
|
|
303
|
+
)
|
|
304
|
+
// a live session already up — what a WINNING concurrent wake leaves behind
|
|
305
|
+
const up = spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', 'claude-fpl', 'cat'], { encoding: 'utf8' })
|
|
306
|
+
expect(up.status).toBe(0)
|
|
307
|
+
const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir }
|
|
308
|
+
const r = await wakeOrSpawn({ personality: 'fpl', runtime: 'claude', task: 'second sender envelope' }, { env })
|
|
309
|
+
expect(r.status).toBe('READY')
|
|
310
|
+
expect(r.woke).toBe(false)
|
|
311
|
+
// the contract under test: the fast path NEVER delivers the caller's task
|
|
312
|
+
expect(r.taskDelivered).toBe(false)
|
|
313
|
+
} finally {
|
|
314
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { encoding: 'utf8' })
|
|
315
|
+
rmSync(root, { recursive: true, force: true })
|
|
316
|
+
rmSync(laDir, { recursive: true, force: true })
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
287
321
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
288
322
|
// C1 — durable stopped flag (stop/start; daemon refuses to wake a stopped peer)
|
|
289
323
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/transport/index.ts
CHANGED
|
@@ -408,6 +408,12 @@ export interface WakeOutcome {
|
|
|
408
408
|
* refused, not failed. routeSend surfaces an explicit "stopped" error to the
|
|
409
409
|
* sender (contract Демон §stopped: stopped → no wake, no queue, clear error). */
|
|
410
410
|
stopped?: boolean
|
|
411
|
+
/** Did the wake itself deliver `task` (as the boot first-message)? An impl
|
|
412
|
+
* returns FALSE from its idempotent live-session fast path (a concurrent wake
|
|
413
|
+
* won and delivered only ITS envelope) — routeSend then delivers this caller's
|
|
414
|
+
* envelope via the live path. undefined → assumed delivered (legacy impls keep
|
|
415
|
+
* their prior behavior; the in-repo wakeOrSpawn always sets it explicitly). */
|
|
416
|
+
taskDelivered?: boolean
|
|
411
417
|
}
|
|
412
418
|
export type WakeFn = (req: WakeRequest) => Promise<WakeOutcome>
|
|
413
419
|
|
|
@@ -551,6 +557,23 @@ export async function routeSend(
|
|
|
551
557
|
return err(`woke "${personality}" but the session is not live (verify-before-act): ${live.error.message}`)
|
|
552
558
|
}
|
|
553
559
|
if (live.value.address === caller.address) return err('cannot send to self')
|
|
560
|
+
// taskDelivered:false — the wake took the idempotent live-session fast path (a
|
|
561
|
+
// CONCURRENT sender's boot won the wake.lock and delivered only ITS envelope as
|
|
562
|
+
// the boot first-message). OUR envelope was not delivered by anything yet —
|
|
563
|
+
// deliver it NOW via the live path (submit-confirm + liveness guards apply).
|
|
564
|
+
// Without this, the second concurrent sender's message is silently lost behind
|
|
565
|
+
// a false ok:true — the class the contract forbids. A delivery failure here is
|
|
566
|
+
// surfaced loudly (false-FAIL over false-OK).
|
|
567
|
+
if (woke.taskDelivered === false) {
|
|
568
|
+
const delivered = deliverViaTmux(live.value, envelope, peer.cwd)
|
|
569
|
+
if (!delivered.ok) return delivered
|
|
570
|
+
return ok({
|
|
571
|
+
ok: true,
|
|
572
|
+
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|
|
573
|
+
woke: false, // honest: this sender's envelope went the LIVE path, not a boot
|
|
574
|
+
ts: new Date().toISOString(),
|
|
575
|
+
})
|
|
576
|
+
}
|
|
554
577
|
// The envelope was delivered as the boot first-message during wake.
|
|
555
578
|
return ok({
|
|
556
579
|
ok: true,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// routeSend wake-path delivery guarantee — the concurrent-sender fast-path hole.
|
|
2
|
+
//
|
|
3
|
+
// The race (found by code-read 09.06, vault «Находка — wake-on-miss fast-path …»):
|
|
4
|
+
// sender A's wake boots the session (A's envelope = boot first-message); sender B,
|
|
5
|
+
// blocked on wake.lock, enters AFTER the boot and takes the idempotent live-session
|
|
6
|
+
// fast path — which delivers NOTHING. Pre-fix, routeSend then reported {ok:true,
|
|
7
|
+
// woke:true} for B while B's envelope was silently lost (the class the delivery
|
|
8
|
+
// contract forbids). Post-fix, wakeOrSpawn marks the fast path taskDelivered:false
|
|
9
|
+
// and routeSend delivers B's envelope itself via the LIVE path.
|
|
10
|
+
//
|
|
11
|
+
// The fake WakeFn here reproduces the loser-of-the-lock outcome exactly: it brings
|
|
12
|
+
// the session up itself (= what the winning concurrent wake did) and returns READY
|
|
13
|
+
// without having delivered the caller's task.
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
16
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
17
|
+
import { tmpdir } from 'os'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import { spawnSync } from 'child_process'
|
|
20
|
+
import { routeSend, type WakeFn } from './index.ts'
|
|
21
|
+
import type { ResolvedCaller } from '../identity/index.ts'
|
|
22
|
+
import type { PeerRecord } from '../registry/index.ts'
|
|
23
|
+
|
|
24
|
+
let root: string
|
|
25
|
+
let sockDir: string
|
|
26
|
+
const prevRoot = process.env.IAPEER_ROOT
|
|
27
|
+
|
|
28
|
+
// Target peer: NON-TUI runtime (telegram) → the C-j delivery path lands into a
|
|
29
|
+
// plain `cat` pane with no TUI prompt machinery; capture-pane then shows the
|
|
30
|
+
// envelope text verbatim.
|
|
31
|
+
// NB: keep names SHORT — the tmux socket lives under mkdtemp and the unix
|
|
32
|
+
// socket path limit on macOS is 104 bytes.
|
|
33
|
+
const TARGET = 'fpw'
|
|
34
|
+
const SESSION = `telegram-${TARGET}`
|
|
35
|
+
const sockPath = () => join(sockDir, `tmux-iap-telegram-${TARGET}.sock`)
|
|
36
|
+
|
|
37
|
+
const callerRecord = {
|
|
38
|
+
personality: 'boris',
|
|
39
|
+
runtime: 'claude',
|
|
40
|
+
runtimes: ['claude'],
|
|
41
|
+
description: '',
|
|
42
|
+
intelligence: 'artificial',
|
|
43
|
+
cwd: '/tmp/boris',
|
|
44
|
+
} as unknown as PeerRecord
|
|
45
|
+
const caller: ResolvedCaller = {
|
|
46
|
+
personality: 'boris',
|
|
47
|
+
runtime: 'claude',
|
|
48
|
+
address: 'claude-boris',
|
|
49
|
+
description: '',
|
|
50
|
+
intelligence: 'artificial',
|
|
51
|
+
cwd: '/tmp/boris',
|
|
52
|
+
record: callerRecord,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function startTargetSession(): void {
|
|
56
|
+
const r = spawnSync('tmux', ['-S', sockPath(), 'new-session', '-d', '-x', '200', '-y', '50', '-s', SESSION, 'cat'], {
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
})
|
|
59
|
+
if (r.status !== 0) throw new Error(`tmux new-session failed: ${r.stderr}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function killTargetSession(): void {
|
|
63
|
+
spawnSync('tmux', ['-S', sockPath(), 'kill-server'], { encoding: 'utf8' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function capturePane(): string {
|
|
67
|
+
const r = spawnSync('tmux', ['-S', sockPath(), 'capture-pane', '-p', '-t', SESSION], { encoding: 'utf8' })
|
|
68
|
+
return r.stdout ?? ''
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeAll(() => {
|
|
72
|
+
root = mkdtempSync(join(tmpdir(), 'fpw-'))
|
|
73
|
+
sockDir = join(root, 'socks')
|
|
74
|
+
mkdirSync(sockDir, { recursive: true })
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(root, 'peers-profiles.json'),
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
version: 2,
|
|
79
|
+
peers: [
|
|
80
|
+
{ personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/boris' },
|
|
81
|
+
{ personality: TARGET, runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'artificial', cwd: root },
|
|
82
|
+
],
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
process.env.IAPEER_ROOT = root
|
|
86
|
+
})
|
|
87
|
+
afterAll(() => {
|
|
88
|
+
killTargetSession()
|
|
89
|
+
if (prevRoot === undefined) delete process.env.IAPEER_ROOT
|
|
90
|
+
else process.env.IAPEER_ROOT = prevRoot
|
|
91
|
+
rmSync(root, { recursive: true, force: true })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
|
|
95
|
+
test('taskDelivered:false → routeSend delivers the envelope itself via the live path', async () => {
|
|
96
|
+
const marker = 'SECOND-SENDER-ENVELOPE-7741'
|
|
97
|
+
// The loser-of-the-lock outcome: the session is up (the WINNING wake booted it),
|
|
98
|
+
// READY came back, but THIS caller's task was never delivered.
|
|
99
|
+
const wake: WakeFn = async () => {
|
|
100
|
+
startTargetSession()
|
|
101
|
+
return { status: 'READY', woke: false, runtime: 'telegram', taskDelivered: false }
|
|
102
|
+
}
|
|
103
|
+
const r = await routeSend(caller, { personality: TARGET, message: marker }, { wake })
|
|
104
|
+
expect(r.ok).toBe(true)
|
|
105
|
+
if (!r.ok) return
|
|
106
|
+
// honest woke flag: the envelope went the LIVE path, not a boot first-message
|
|
107
|
+
expect(r.value.woke).toBe(false)
|
|
108
|
+
expect(r.value.delivered_to).toEqual({ personality: TARGET, runtime: 'telegram' })
|
|
109
|
+
// the envelope actually LANDED in the session (pre-fix: silently lost)
|
|
110
|
+
expect(capturePane()).toContain(marker)
|
|
111
|
+
killTargetSession()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('legacy WakeFn without taskDelivered → prior behavior (no redelivery, woke:true)', async () => {
|
|
115
|
+
const marker = 'LEGACY-IMPL-ENVELOPE-9962'
|
|
116
|
+
const wake: WakeFn = async () => {
|
|
117
|
+
startTargetSession()
|
|
118
|
+
return { status: 'READY', woke: true, runtime: 'telegram' } // field absent → assumed delivered
|
|
119
|
+
}
|
|
120
|
+
const r = await routeSend(caller, { personality: TARGET, message: marker }, { wake })
|
|
121
|
+
expect(r.ok).toBe(true)
|
|
122
|
+
if (!r.ok) return
|
|
123
|
+
expect(r.value.woke).toBe(true)
|
|
124
|
+
// assumed delivered as the boot first-message → routeSend must NOT double-deliver
|
|
125
|
+
expect(capturePane()).not.toContain(marker)
|
|
126
|
+
killTargetSession()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('taskDelivered:false but the session died right after wake → loud failure, not false ok', async () => {
|
|
130
|
+
const wake: WakeFn = async () => {
|
|
131
|
+
// READY without a live session: verify-before-act must catch it
|
|
132
|
+
return { status: 'READY', woke: false, runtime: 'telegram', taskDelivered: false }
|
|
133
|
+
}
|
|
134
|
+
const r = await routeSend(caller, { personality: TARGET, message: 'never lands' }, { wake })
|
|
135
|
+
expect(r.ok).toBe(false)
|
|
136
|
+
if (r.ok) return
|
|
137
|
+
expect(r.error.message).toMatch(/verify-before-act|not live/)
|
|
138
|
+
})
|
|
139
|
+
})
|