@agfpd/iapeer 0.2.69 → 0.2.71
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
|
}
|
|
@@ -174,4 +174,40 @@ d('supervisor daemon (Bun-native pty, tick runtime)', () => {
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
})
|
|
177
|
+
|
|
178
|
+
// FINDING-2 — restore the SERVE geometry on last detach. The daemon resizes the child pty to a
|
|
179
|
+
// client's size on attach and (before this) never restored it, so an unattached session stuck at a
|
|
180
|
+
// departed client's size → the fixed-geometry warm-deliver/ready-gate viewport model mismatched the
|
|
181
|
+
// pty. Drive it with a child that prints its live pty winsize ("ROWS COLS") to the pane-log: attach
|
|
182
|
+
// small → pty shrinks; detach → pty restores to the serve geometry.
|
|
183
|
+
test('FINDING-2: last detach restores the serve geometry (no sticking at the client size)', async () => {
|
|
184
|
+
const gSession = 'ptsgeo'
|
|
185
|
+
const paneLog = join(runDir, 'geo-pane.log')
|
|
186
|
+
const lastSize = (): string => {
|
|
187
|
+
const t = existsSync(paneLog) ? readFileSync(paneLog, 'utf8') : ''
|
|
188
|
+
const m = [...t.matchAll(/(\d+) (\d+)/g)]
|
|
189
|
+
return m.length ? `${m[m.length - 1][1]}x${m[m.length - 1][2]}` : ''
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const r = startSupervisorDaemon({
|
|
193
|
+
session: gSession,
|
|
194
|
+
runtime: 'tick',
|
|
195
|
+
runDir,
|
|
196
|
+
// serve geometry 200x50 (distinct from defaults); child reports its live pty winsize as "rows cols"
|
|
197
|
+
serve: { argv: ['sh', '-c', 'while :; do stty size 2>/dev/null; sleep 0.3; done'], env: { PATH: process.env.PATH ?? '' }, cwd: runDir, paneLogPath: paneLog, cols: 200, rows: 50 },
|
|
198
|
+
})
|
|
199
|
+
expect(r.state).toBe('started')
|
|
200
|
+
expect(await waitFor(() => lastSize() === '50 200'.replace(' ', 'x'), 6000)).toBe(true) // at serve geometry
|
|
201
|
+
const c = await attach(runDir, gSession, 90, 30) // attach at a DIFFERENT size → pty resizes to it
|
|
202
|
+
expect(await waitFor(() => lastSize() === '30x90', 6000)).toBe(true) // pty shrank to the client size
|
|
203
|
+
c.close() // last client gone
|
|
204
|
+
expect(await waitFor(() => lastSize() === '50x200', 6000)).toBe(true) // RESTORED to serve geometry
|
|
205
|
+
} finally {
|
|
206
|
+
try {
|
|
207
|
+
killSession(runDir, gSession)
|
|
208
|
+
} catch {
|
|
209
|
+
/* */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
})
|
|
177
213
|
})
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -90,6 +90,13 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
90
90
|
const { session, runtime, runDir } = opts
|
|
91
91
|
let cols = opts.cols ?? 80
|
|
92
92
|
let rows = opts.rows ?? 30
|
|
93
|
+
// The SERVE geometry — the size the session is hosted at (HOST_COLS×HOST_ROWS for a pty-host peer),
|
|
94
|
+
// immutable. `cols`/`rows` above track the CURRENT (client-driven) size. A client attaches at its own
|
|
95
|
+
// terminal size and the pty resizes to match; on LAST detach we restore the serve geometry (below) so
|
|
96
|
+
// the unattached session never sticks at a departed client's size — otherwise the warm-deliver /
|
|
97
|
+
// ready-gate viewport model (built at the fixed serve geometry) mismatches the actually-resized pty.
|
|
98
|
+
const serveCols = cols
|
|
99
|
+
const serveRows = rows
|
|
93
100
|
const sock = sockPath(runDir, session)
|
|
94
101
|
const pid = pidPath(runDir, session)
|
|
95
102
|
|
|
@@ -217,28 +224,40 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
217
224
|
sendClient(s, frame(FRAME_DATA, Buffer.from(snap, 'utf8')))
|
|
218
225
|
}
|
|
219
226
|
|
|
227
|
+
// Resize the model + child pty to (c, r). Shared by client FRAME_RESIZE and the last-detach restore.
|
|
228
|
+
const applyGeometry = (c: number, r: number): void => {
|
|
229
|
+
cols = c
|
|
230
|
+
rows = r
|
|
231
|
+
try {
|
|
232
|
+
xterm.resize(c, r)
|
|
233
|
+
child.terminal.resize(c, r)
|
|
234
|
+
} catch {
|
|
235
|
+
/* transient */
|
|
236
|
+
}
|
|
237
|
+
// Best-effort SIGWINCH nudge for a SIGWINCH-reactive child (forces a repaint at the new size); NOT
|
|
238
|
+
// load-bearing for an attached client (the complete snapshot repaint is), but for the unattached
|
|
239
|
+
// restore it is what makes the child redraw into the pane-log at the serve geometry.
|
|
240
|
+
try {
|
|
241
|
+
child.terminal.resize(c, Math.max(1, r - 1))
|
|
242
|
+
child.terminal.resize(c, r)
|
|
243
|
+
} catch {
|
|
244
|
+
/* */
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Restore the serve geometry when the last client detaches, so the unattached session never sticks at
|
|
248
|
+
// a departed client's size (FINDING-2 — would mismatch the fixed-geometry warm-deliver/ready-gate
|
|
249
|
+
// viewport model). No-op when already at serve geometry or a client is still attached.
|
|
250
|
+
const restoreServeGeometryIfIdle = (): void => {
|
|
251
|
+
if (clients.size === 0 && (cols !== serveCols || rows !== serveRows)) applyGeometry(serveCols, serveRows)
|
|
252
|
+
}
|
|
253
|
+
|
|
220
254
|
const onClientFrame = (s: { write(b: Buffer): number }, type: number, payload: Buffer): void => {
|
|
221
255
|
if (type === FRAME_DATA) {
|
|
222
256
|
child.terminal.write(payload) // keystrokes → child
|
|
223
257
|
} else if (type === FRAME_RESIZE) {
|
|
224
258
|
const sz = parseSize(payload)
|
|
225
|
-
cols
|
|
226
|
-
rows = sz.rows
|
|
227
|
-
try {
|
|
228
|
-
xterm.resize(cols, rows)
|
|
229
|
-
child.terminal.resize(cols, rows)
|
|
230
|
-
} catch {
|
|
231
|
-
/* transient */
|
|
232
|
-
}
|
|
259
|
+
applyGeometry(sz.cols, sz.rows)
|
|
233
260
|
repaint(s) // instant paint from the snapshot (delivered COMPLETE via backpressure)
|
|
234
|
-
// Best-effort SIGWINCH nudge for a SIGWINCH-reactive child; NOT load-bearing (the complete
|
|
235
|
-
// snapshot above is the real freshness fix).
|
|
236
|
-
try {
|
|
237
|
-
child.terminal.resize(cols, Math.max(1, rows - 1))
|
|
238
|
-
child.terminal.resize(cols, rows)
|
|
239
|
-
} catch {
|
|
240
|
-
/* */
|
|
241
|
-
}
|
|
242
261
|
}
|
|
243
262
|
}
|
|
244
263
|
|
|
@@ -266,11 +285,13 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
266
285
|
clients.delete(s)
|
|
267
286
|
writers.delete(s)
|
|
268
287
|
syncAttached()
|
|
288
|
+
restoreServeGeometryIfIdle() // last client gone → un-stick the pty from its size
|
|
269
289
|
},
|
|
270
290
|
error(s: object) {
|
|
271
291
|
clients.delete(s)
|
|
272
292
|
writers.delete(s)
|
|
273
293
|
syncAttached()
|
|
294
|
+
restoreServeGeometryIfIdle()
|
|
274
295
|
},
|
|
275
296
|
},
|
|
276
297
|
})
|