@agfpd/iapeer 0.2.68 → 0.2.70
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
CHANGED
|
@@ -107,6 +107,46 @@ describe('buildLaunchInvocation — differential vs the verbatim pre-extraction
|
|
|
107
107
|
})
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
+
// Silent-loss guard — the claude-hosting canary caught a warm-deliver false-fail: a launch INITIATED
|
|
111
|
+
// from inside a claude session leaked CLAUDE_CODE_SESSION_ID + CLAUDE_CODE_CHILD_SESSION into the
|
|
112
|
+
// child, which then wrote no own transcript → newestActivityMtime null → landed-confirm blind while
|
|
113
|
+
// the message landed. buildLaunchInvocation must strip the claude-code-internal namespace so a
|
|
114
|
+
// launched claude is always a FRESH top-level session, regardless of initiator env.
|
|
115
|
+
describe('buildLaunchInvocation — strips the claude-code-internal session namespace', () => {
|
|
116
|
+
const DIRTY: LaunchAdapterConfig & { env: NodeJS.ProcessEnv } = {
|
|
117
|
+
claudeBin: '/bin/claude',
|
|
118
|
+
codexBin: '/bin/codex',
|
|
119
|
+
env: {
|
|
120
|
+
PATH: '/test/path',
|
|
121
|
+
BASE_VAR: 'base',
|
|
122
|
+
CLAUDE_CODE_SESSION_ID: 'spawner-session-uuid', // the corruptor (pins the child to the spawner's session)
|
|
123
|
+
CLAUDE_CODE_CHILD_SESSION: '1', // the corruptor (marks a child sub-session → suppresses own transcript)
|
|
124
|
+
CLAUDECODE: '1',
|
|
125
|
+
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
|
126
|
+
CLAUDE_CODE_EXECPATH: '/v/2.1.177',
|
|
127
|
+
CLAUDE_EFFORT: 'xhigh', // NOT in the claude-code namespace → must survive (scoped strip, not a wipe)
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
test('claude launch: the whole CLAUDE_CODE_* namespace + CLAUDECODE is removed', () => {
|
|
131
|
+
const { env } = buildLaunchInvocation(spec({ runtime: 'claude', identity: 'claude-p' }), claudeAdapter, DIRTY)
|
|
132
|
+
expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined()
|
|
133
|
+
expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined()
|
|
134
|
+
expect(env.CLAUDECODE).toBeUndefined()
|
|
135
|
+
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
|
|
136
|
+
expect(env.CLAUDE_CODE_EXECPATH).toBeUndefined()
|
|
137
|
+
// scoped: non-namespace vars (incl. a CLAUDE_-prefixed-but-not-CLAUDE_CODE_ one) survive
|
|
138
|
+
expect(env.BASE_VAR).toBe('base')
|
|
139
|
+
expect(env.CLAUDE_EFFORT).toBe('xhigh')
|
|
140
|
+
expect(env.PEER_IDENTITY).toBe('claude-p')
|
|
141
|
+
})
|
|
142
|
+
test('codex launch: same strip applies (harmless — codex ignores CLAUDE_*) and the bearer stub stays', () => {
|
|
143
|
+
const { env } = buildLaunchInvocation(spec({ runtime: 'codex', identity: 'codex-p' }), codexAdapter, DIRTY)
|
|
144
|
+
expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined()
|
|
145
|
+
expect(env.CLAUDECODE).toBeUndefined()
|
|
146
|
+
expect(env[CODEX_BEARER_ENV_VAR]).toBe(CODEX_DUMMY_BEARER)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
110
150
|
// Independent contract anchors — expectations come from the CONTRACT, not from the function output.
|
|
111
151
|
describe('buildLaunchInvocation — identity ABI + system-prompt swap (contract values)', () => {
|
|
112
152
|
test('codex carries the non-secret IAPEER_BEARER stub; claude does NOT (identity in .mcp.json)', () => {
|
package/src/launch/invocation.ts
CHANGED
|
@@ -52,5 +52,20 @@ export function buildLaunchInvocation(
|
|
|
52
52
|
PEER_IDENTITY: spec.identity,
|
|
53
53
|
PATH: env.PATH ?? `${homedir()}/.bun/bin:${homedir()}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
|
|
54
54
|
}
|
|
55
|
+
// Strip the claude-code-internal session namespace from the inherited env. When a launch is
|
|
56
|
+
// INITIATED from inside a running claude session (an operator CLI, or a claude peer shelling out),
|
|
57
|
+
// process.env carries that session's CLAUDE_CODE_SESSION_ID + CLAUDE_CODE_CHILD_SESSION. Inherited
|
|
58
|
+
// by a newly-launched claude, they make it believe it is a CHILD sub-session of the initiator → it
|
|
59
|
+
// writes NO independent ~/.claude/projects transcript → newestActivityMtime(cwd) is permanently
|
|
60
|
+
// null → the warm-deliver landed-confirm false-fails WHILE the message actually lands (a silent-
|
|
61
|
+
// loss, the class the contract forbids). Stripping the whole namespace makes a launched claude
|
|
62
|
+
// always start as a FRESH top-level session (it re-establishes its own vars), independent of who
|
|
63
|
+
// initiated the launch — the env-composition analogue of the supervisor's TMUX strip. Caught live
|
|
64
|
+
// by the claude-hosting canary. Harmless for codex (no CLAUDE_* usage). NOTE: a launch driven by
|
|
65
|
+
// the always-on daemon already has a clean env (no claude session) — this hardens the CLI / peer-
|
|
66
|
+
// initiated paths and removes the silent-loss class regardless of initiator.
|
|
67
|
+
for (const k of Object.keys(childEnv)) {
|
|
68
|
+
if (k.startsWith('CLAUDE_CODE_') || k === 'CLAUDECODE') delete childEnv[k]
|
|
69
|
+
}
|
|
55
70
|
return { argv, env: childEnv }
|
|
56
71
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { killSession, sessionAlive, startSupervisorDaemon } from './index.ts'
|
|
6
|
+
|
|
7
|
+
// SURFACE-5 regression — the interactive attach-and-stay loop. The other supervisor tests attach a
|
|
8
|
+
// RAW socket; none drives the real `runSupervisorClient` under a TTY, so the canary's first live
|
|
9
|
+
// `iapeer attach` exposed a bug invisible to them: runSupervisorClient resolved its Promise the
|
|
10
|
+
// instant Bun.connect returned (setup done), the CLI caller ran `await runSupervisorClient(); return
|
|
11
|
+
// 0` → runCli's `.then(process.exit)`, and the just-attached client was torn down within
|
|
12
|
+
// milliseconds (handshake-then-exit). The fix makes the client BLOCK (never-resolve) after setup so
|
|
13
|
+
// process.exit from a terminal path owns termination — exactly the detached `daemon` case's guard.
|
|
14
|
+
//
|
|
15
|
+
// This is the headless verifier of that fix AND the missing surface-5 automation: allocate a REAL
|
|
16
|
+
// Bun-native pty, run the actual client CLI in it, assert it STAYS attached + renders live frames +
|
|
17
|
+
// Ctrl-] detaches cleanly while the session keeps running. Arthur's live attach is the final human
|
|
18
|
+
// seal; this test makes every iteration of the fix verifiable WITHOUT pulling him in.
|
|
19
|
+
// Gated on Bun-native PTY (≥1.3.5) so a runner without it skips rather than reds.
|
|
20
|
+
const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
|
|
21
|
+
const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
|
|
22
|
+
const d = bunPty ? describe : describe.skip
|
|
23
|
+
|
|
24
|
+
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
25
|
+
async function waitFor(pred: () => boolean, timeoutMs = 4000, step = 50): Promise<boolean> {
|
|
26
|
+
const end = Date.now() + timeoutMs
|
|
27
|
+
while (Date.now() < end) {
|
|
28
|
+
if (pred()) return true
|
|
29
|
+
await sleep(step)
|
|
30
|
+
}
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Spawn the real attach client under a real pty (its stdin/stdout is a TTY → raw-mode path runs).
|
|
35
|
+
interface PtyClient {
|
|
36
|
+
out(): string
|
|
37
|
+
exited(): boolean
|
|
38
|
+
sendKey(byte: number): void
|
|
39
|
+
}
|
|
40
|
+
function spawnAttachClient(runDir: string, session: string): PtyClient {
|
|
41
|
+
let buf = ''
|
|
42
|
+
let done = false
|
|
43
|
+
const proc = Bun.spawn(['bun', 'src/supervisor/index.ts', 'attach', session, '--run-dir', runDir], {
|
|
44
|
+
cwd: process.cwd(),
|
|
45
|
+
env: { ...process.env },
|
|
46
|
+
// a real Bun-native pty pair: the client sees a TTY on stdin (isTTY → setRawMode runs).
|
|
47
|
+
terminal: {
|
|
48
|
+
cols: 80,
|
|
49
|
+
rows: 24,
|
|
50
|
+
name: 'xterm-256color',
|
|
51
|
+
data(_t: unknown, data: Uint8Array | string) {
|
|
52
|
+
buf += Buffer.from(data as Uint8Array).toString('latin1')
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun pty spawn opts not yet in @types
|
|
56
|
+
} as any)
|
|
57
|
+
proc.exited.then(() => {
|
|
58
|
+
done = true
|
|
59
|
+
})
|
|
60
|
+
return {
|
|
61
|
+
out: () => buf,
|
|
62
|
+
exited: () => done,
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- proc.terminal is the pty master writer
|
|
64
|
+
sendKey: (byte: number) => void (proc as any).terminal.write(Buffer.from([byte])),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
d('runSupervisorClient interactive attach (surface-5, Bun-native pty)', () => {
|
|
69
|
+
let runDir: string
|
|
70
|
+
const session = 'ptsattach'
|
|
71
|
+
|
|
72
|
+
beforeAll(() => {
|
|
73
|
+
runDir = mkdtempSync(join(tmpdir(), 'iapeer-attach-'))
|
|
74
|
+
startSupervisorDaemon({ session, runtime: 'tick', runDir })
|
|
75
|
+
})
|
|
76
|
+
afterAll(() => {
|
|
77
|
+
try {
|
|
78
|
+
killSession(runDir, session)
|
|
79
|
+
} catch {
|
|
80
|
+
/* */
|
|
81
|
+
}
|
|
82
|
+
rmSync(runDir, { recursive: true, force: true })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('the client STAYS attached after the handshake and keeps rendering live frames', async () => {
|
|
86
|
+
expect(sessionAlive(runDir, session)).toBe(true)
|
|
87
|
+
const c = spawnAttachClient(runDir, session)
|
|
88
|
+
|
|
89
|
+
// handshake: the attach line lands (connect + attach + terminal-setup succeeded).
|
|
90
|
+
expect(await waitFor(() => /\[supervisor\] attached to/.test(c.out()))).toBe(true)
|
|
91
|
+
|
|
92
|
+
// THE BUG was a handshake-then-exit within ~ms. Assert the client is STILL attached well past
|
|
93
|
+
// that window AND that live child output (STREAMTICK from the tick runtime) is reaching it —
|
|
94
|
+
// proving the attach-and-stay loop holds, not just that the process happens to linger.
|
|
95
|
+
await sleep(2000)
|
|
96
|
+
expect(c.exited()).toBe(false) // RED on the pre-fix early-resolve (exited ~ms after handshake)
|
|
97
|
+
expect(/STREAMTICK_\d+/.test(c.out())).toBe(true) // live frames flow through the held attach
|
|
98
|
+
|
|
99
|
+
// Ctrl-] (0x1d) detaches: the client exits cleanly, prints the DETACH message (not the
|
|
100
|
+
// misleading "session ended"), and the session keeps running.
|
|
101
|
+
c.sendKey(0x1d)
|
|
102
|
+
expect(await waitFor(() => c.exited())).toBe(true)
|
|
103
|
+
expect(/detached/.test(c.out())).toBe(true)
|
|
104
|
+
expect(/session ended/.test(c.out())).toBe(false) // detach must not claim the session ended
|
|
105
|
+
expect(sessionAlive(runDir, session)).toBe(true) // detach kept the daemon alive
|
|
106
|
+
})
|
|
107
|
+
})
|
package/src/supervisor/client.ts
CHANGED
|
@@ -29,6 +29,10 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
|
|
|
29
29
|
process.exit(1)
|
|
30
30
|
}
|
|
31
31
|
let restored = false
|
|
32
|
+
// Set the instant a deliberate Ctrl-] detach begins. `sock.end()` can re-enter the socket close()
|
|
33
|
+
// handler synchronously, so close() must NOT claim "session ended" on an operator-initiated detach
|
|
34
|
+
// (the session keeps running) — it stays silent and lets the detach path own the message + exit.
|
|
35
|
+
let detaching = false
|
|
32
36
|
// writeSync(1, …) works inside a signal handler (no event-loop dependency) — so a
|
|
33
37
|
// SIGHUP/window-close client still resets the terminal before it dies (port-dep #1).
|
|
34
38
|
const restore = (): void => {
|
|
@@ -70,6 +74,7 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
|
|
|
70
74
|
},
|
|
71
75
|
close() {
|
|
72
76
|
restore()
|
|
77
|
+
if (detaching) process.exit(0) // operator detached; the detach path already wrote its message
|
|
73
78
|
process.stdout.write('\r\n[supervisor] session ended.\r\n')
|
|
74
79
|
process.exit(0)
|
|
75
80
|
},
|
|
@@ -82,13 +87,16 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
|
|
|
82
87
|
|
|
83
88
|
process.stdin.on('data', b => {
|
|
84
89
|
if (b.includes(DETACH)) {
|
|
90
|
+
detaching = true
|
|
85
91
|
restore()
|
|
92
|
+
// Write the detach message BEFORE sock.end() — sock.end() can re-enter close() synchronously,
|
|
93
|
+
// and close() now exits silently while `detaching`, so the message must already be out.
|
|
94
|
+
process.stdout.write(`\r\n[supervisor] detached — session still running. reattach: supervisor attach ${session}\r\n`)
|
|
86
95
|
try {
|
|
87
96
|
sock.end()
|
|
88
97
|
} catch {
|
|
89
98
|
/* */
|
|
90
99
|
}
|
|
91
|
-
process.stdout.write(`\r\n[supervisor] detached — session still running. reattach: supervisor attach ${session}\r\n`)
|
|
92
100
|
process.exit(0)
|
|
93
101
|
}
|
|
94
102
|
sock.write(frame(FRAME_DATA, b))
|
|
@@ -115,4 +123,11 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
|
|
|
115
123
|
}
|
|
116
124
|
process.on('exit', () => restore()) // last-resort sync net
|
|
117
125
|
process.stdout.write(`\x1b[2m[supervisor] attached to "${session}". detach: Ctrl-] (session keeps running)\x1b[0m\r\n`)
|
|
126
|
+
// STAY ATTACHED. The client lives on its handles (raw stdin + the socket); EVERY terminal path
|
|
127
|
+
// (detach Ctrl-], session-end close(), SIGHUP/SIGTERM, socket error) calls process.exit ITSELF.
|
|
128
|
+
// This promise INTENTIONALLY never resolves: returning here would resolve runSupervisorClient the
|
|
129
|
+
// instant the handshake completed, and the CLI caller (`await runSupervisorClient(); return 0` →
|
|
130
|
+
// runCli's `.then(process.exit)`) would tear the just-attached client down in milliseconds — the
|
|
131
|
+
// surface-5 interactive-attach bug. The detached `daemon` case guards itself the same way.
|
|
132
|
+
await new Promise<never>(() => {})
|
|
118
133
|
}
|