@agfpd/iapeer 0.2.8 → 0.2.10

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
- import { mkdtempSync, rmSync, writeFileSync } from 'fs'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
3
3
  import { tmpdir } from 'os'
4
4
  import { join } from 'path'
5
5
  import {
@@ -101,3 +101,40 @@ describe('callTool (no wake passed → Ф1 offline behaviour, never spawns)', ()
101
101
  expect((await callTool(caller, 'bogus', {})).isError).toBe(true)
102
102
  })
103
103
  })
104
+
105
+ describe('per-delivery outcome log (Ф-#8a — delivery.log)', () => {
106
+ test('with deliveryLogDir wired: ONE logfmt outcome line per attempt, metadata only', async () => {
107
+ const logDir = join(root, 'logs', 'iapeer')
108
+ const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
109
+ await callTool(caller, 'send_to_peer', { personality: 'offlinepeer', message: 'hi' }, undefined, logDir)
110
+ await callTool(
111
+ caller,
112
+ 'send_to_peer',
113
+ { personality: 'nobody', message: 'hello there', topic: 'probe topic' },
114
+ undefined,
115
+ logDir,
116
+ )
117
+ const lines = readFileSync(join(logDir, 'delivery.log'), 'utf8').trim().split('\n')
118
+ expect(lines.length).toBe(2)
119
+ // attempt 1: known-but-offline peer (no wake wired → Ф1 explicit offline)
120
+ expect(lines[0]).toContain('ev=delivery')
121
+ expect(lines[0]).toContain('caller=claude-boris')
122
+ expect(lines[0]).toContain('to=offlinepeer')
123
+ expect(lines[0]).toContain('ok=false')
124
+ expect(lines[0]).toMatch(/err=".*offline.*"/)
125
+ expect(lines[0]).toContain('len=2') // body length, NEVER the body itself
126
+ expect(lines[0]).not.toContain('hi"') // the message text must not leak
127
+ // attempt 2: unknown peer — validation failures are recorded too
128
+ expect(lines[1]).toContain('to=nobody')
129
+ expect(lines[1]).toContain('ok=false')
130
+ expect(lines[1]).toContain('topic="probe topic"')
131
+ expect(lines[1]).toContain('len=11')
132
+ })
133
+
134
+ test('without deliveryLogDir (default): nothing is written — library/test daemons stay hermetic', async () => {
135
+ const probeDir = join(root, 'logs', 'unwired')
136
+ const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
137
+ await callTool(caller, 'send_to_peer', { personality: 'offlinepeer', message: 'hi' })
138
+ expect(existsSync(join(probeDir, 'delivery.log'))).toBe(false)
139
+ })
140
+ })
@@ -0,0 +1,64 @@
1
+ // Per-delivery outcome log — the daemon's DURABLE trace of EVERY send_to_peer it
2
+ // routes (Ф-#8a transport hardening). This closes the observability gap the
3
+ // 09.06 long-message investigation hit: a sender held an `ok:true` while the
4
+ // recipient never saw the message, and there was NO daemon-side record of what
5
+ // routeSend actually decided (hit/miss, woke, error) to reconstruct the path.
6
+ // With this log, the NEXT suspected loss is answerable from disk: one logfmt
7
+ // line per delivery attempt — who sent, to whom, how it resolved, how long it
8
+ // took — delivery.log, sibling to lifecycle.log / exits.log (where the
9
+ // investigator already looks).
10
+ //
11
+ // What is logged: routing METADATA only (caller, target, outcome, sizes, topic)
12
+ // — never the message body (peer traffic stays out of the foundation's logs;
13
+ // length is enough to correlate with the sender's account).
14
+ //
15
+ // Same class as lifecycle.log ("встроенная ротация", Фаза — Ротация логов
16
+ // iapeer): written by OUR code → rotates itself in the writer, via the shared
17
+ // storage/rotatelog primitive. The dir is passed IN by the composition point
18
+ // (daemon/main.ts routes cfg.eventLogDir), NEVER re-resolved from env here — a
19
+ // falsy dir → no-op, so library/test callers of startDaemon stay hermetic by
20
+ // default (same opt-in pattern as the discovery file).
21
+
22
+ import { join } from 'path'
23
+ import {
24
+ DEFAULT_LOG_KEEP,
25
+ DEFAULT_LOG_MAX_BYTES,
26
+ appendRotatedEvent,
27
+ } from '../storage/rotatelog.ts'
28
+
29
+ /** The per-delivery outcome log inside `logDir` (sibling to lifecycle.log). */
30
+ export function deliveryLogPath(logDir: string): string {
31
+ return join(logDir, 'delivery.log')
32
+ }
33
+
34
+ function envPosInt(raw: string | undefined, dflt: number): number {
35
+ const n = parseInt(raw ?? '', 10)
36
+ return Number.isFinite(n) && n > 0 ? n : dflt
37
+ }
38
+
39
+ export interface AppendDeliveryOptions {
40
+ /** Reads the rotation knobs IAPEER_DELIVERY_LOG_MAX_BYTES / _KEEP. */
41
+ env?: NodeJS.ProcessEnv
42
+ /** Stamp the line with this epoch-ms. Default Date.now(). */
43
+ nowMs?: number
44
+ }
45
+
46
+ /**
47
+ * Append one delivery outcome line into `logDir`/delivery.log. A falsy `logDir`
48
+ * is a no-op (the default for library/test daemons — production main passes the
49
+ * cfg-resolved dir). Fully best-effort — never throws; logging must never fail
50
+ * a delivery.
51
+ */
52
+ export function appendDeliveryEvent(
53
+ logDir: string | undefined,
54
+ fields: Record<string, string | number | undefined>,
55
+ opts: AppendDeliveryOptions = {},
56
+ ): void {
57
+ if (!logDir) return
58
+ const env = opts.env ?? process.env
59
+ appendRotatedEvent(deliveryLogPath(logDir), fields, {
60
+ nowMs: opts.nowMs,
61
+ maxBytes: envPosInt(env.IAPEER_DELIVERY_LOG_MAX_BYTES, DEFAULT_LOG_MAX_BYTES),
62
+ keep: envPosInt(env.IAPEER_DELIVERY_LOG_KEEP, DEFAULT_LOG_KEEP),
63
+ })
64
+ }
@@ -44,6 +44,7 @@ import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage
44
44
  import { publicPeerSummary, readPeersIndex, type PeersIndex } from '../registry/index.ts'
45
45
  import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
46
46
  import { routeSend, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
47
+ import { appendDeliveryEvent } from './deliverylog.ts'
47
48
 
48
49
  export const CALLER_HEADER = 'x-iapeer-identity'
49
50
  const SERVER_INFO = { name: 'iapeer', version: '0.0.0' }
@@ -176,6 +177,7 @@ export async function callTool(
176
177
  name: string,
177
178
  args: Record<string, unknown>,
178
179
  wake?: WakeFn,
180
+ deliveryLogDir?: string,
179
181
  ): Promise<ToolResult> {
180
182
  if (name === 'send_to_peer') {
181
183
  const input: SendToPeerInput = {
@@ -185,7 +187,27 @@ export async function callTool(
185
187
  topic: typeof args.topic === 'string' ? args.topic : undefined,
186
188
  attachments: Array.isArray(args.attachments) ? (args.attachments as string[]) : undefined,
187
189
  }
190
+ const t0 = Date.now()
188
191
  const sent = await routeSend(caller, input, { wake })
192
+ // Ф-#8a: ONE durable outcome line per delivery attempt (delivery.log, sibling
193
+ // to lifecycle.log) — metadata only, never the body. Both branches of the
194
+ // routeSend result are recorded, so a suspected loss is reconstructable from
195
+ // disk (the gap the 09.06 investigation hit). No-op when no dir is wired
196
+ // (library/test daemons); appendDeliveryEvent itself never throws.
197
+ appendDeliveryEvent(deliveryLogDir, {
198
+ ev: 'delivery',
199
+ caller: caller.address,
200
+ to: input.personality,
201
+ rt: input.runtime, // requested runtime override (skipped when absent)
202
+ ok: String(sent.ok),
203
+ via: sent.ok ? `${sent.value.delivered_to.runtime}-${sent.value.delivered_to.personality}` : undefined,
204
+ woke: sent.ok ? String(sent.value.woke) : undefined,
205
+ ms: Date.now() - t0,
206
+ len: input.message.length,
207
+ att: input.attachments?.length || undefined,
208
+ topic: input.topic,
209
+ err: sent.ok ? undefined : sent.error.message,
210
+ })
189
211
  return sent.ok ? jsonResult(sent.value) : errResult(sent.error.message)
190
212
  }
191
213
  return errResult(`unknown tool: ${name}`)
@@ -201,7 +223,7 @@ function headerFromRequestInfo(extra: { requestInfo?: { headers?: Record<string,
201
223
  return Array.isArray(value) ? (value[0] as string) : (value as string | undefined)
202
224
  }
203
225
 
204
- export function createMcpServer(wake?: WakeFn): Server {
226
+ export function createMcpServer(wake?: WakeFn, deliveryLogDir?: string): Server {
205
227
  const server = new Server(SERVER_INFO, { capabilities: { tools: {} } })
206
228
 
207
229
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools(readPeersIndex()) }))
@@ -224,7 +246,7 @@ export function createMcpServer(wake?: WakeFn): Server {
224
246
  process.stderr.write(`[iapeer-daemon] tools/call tool=${req.params.name} caller=${caller.address}\n`)
225
247
  }
226
248
  const args = (req.params.arguments ?? {}) as Record<string, unknown>
227
- return (await callTool(caller, req.params.name, args, wake)) as CallToolResult
249
+ return (await callTool(caller, req.params.name, args, wake, deliveryLogDir)) as CallToolResult
228
250
  })
229
251
 
230
252
  return server
@@ -292,6 +314,14 @@ export interface StartDaemonOptions extends StorageOptions {
292
314
  * broader than same-uid, so a token gates it without changing the on-wire MCP.
293
315
  */
294
316
  bearerToken?: string
317
+ /**
318
+ * Per-delivery outcome log dir (Ф-#8a) — when set, EVERY send_to_peer the daemon
319
+ * routes appends one logfmt outcome line to `<dir>/delivery.log` (rotated,
320
+ * metadata-only — see daemon/deliverylog.ts). OFF by default (library/test
321
+ * callers stay hermetic); the production main wires cfg.eventLogDir here, so
322
+ * delivery.log sits next to lifecycle.log under the SAME cfg-resolved root.
323
+ */
324
+ deliveryLogDir?: string
295
325
  /**
296
326
  * Write the discovery file (router.json) at <root>/state/iapeer/router.json with
297
327
  * the active addresses `{sock, tcp}` — atomically on listen, removed on close.
@@ -319,7 +349,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
319
349
  res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'unauthorized' } }))
320
350
  return
321
351
  }
322
- const server = createMcpServer(opts.wake)
352
+ const server = createMcpServer(opts.wake, opts.deliveryLogDir)
323
353
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
324
354
  res.on('close', () => {
325
355
  transport.close()
@@ -115,6 +115,10 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
115
115
  port: opts.port ?? DEFAULT_DAEMON_PORT,
116
116
  host: opts.host ?? '127.0.0.1',
117
117
  socketPath: opts.socketPath ?? defaultDaemonSocketPath({ env, rootDir: opts.rootDir }),
118
+ // Ф-#8a per-delivery outcome log → delivery.log NEXT TO lifecycle.log, routed
119
+ // through the SAME lifecycle cfg (NOT re-resolved from env) so a sandboxed cfg
120
+ // sandboxes this log too.
121
+ deliveryLogDir: cfg.eventLogDir,
118
122
  discovery: opts.discovery ?? true,
119
123
  env,
120
124
  rootDir: opts.rootDir,
@@ -121,6 +121,23 @@ describe('writePeerProfileAtomic H1 preserve', () => {
121
121
  const parsed = readPeerProfile(cwd)!
122
122
  expect(parsed.intelligence).toBe('artificial')
123
123
  })
124
+
125
+ test('wake_policy "ephemeral" parsed; unknown/absent → omitted (never throws)', () => {
126
+ mkdirSync(join(cwd, '.iapeer'), { recursive: true })
127
+ // honored enum value
128
+ writeFileSync(peerProfilePath(cwd), JSON.stringify({
129
+ personality: 'p', runtime: 'claude', runtimes: ['claude'], wake_policy: 'ephemeral',
130
+ }))
131
+ expect(readPeerProfile(cwd)!.wake_policy).toBe('ephemeral')
132
+ // absent → undefined
133
+ writeFileSync(peerProfilePath(cwd), JSON.stringify({ personality: 'p', runtime: 'claude', runtimes: ['claude'] }))
134
+ expect(readPeerProfile(cwd)!.wake_policy).toBeUndefined()
135
+ // unknown value → omitted (forward-compatible, no throw)
136
+ writeFileSync(peerProfilePath(cwd), JSON.stringify({
137
+ personality: 'p', runtime: 'claude', runtimes: ['claude'], wake_policy: 'persistent-future',
138
+ }))
139
+ expect(readPeerProfile(cwd)!.wake_policy).toBeUndefined()
140
+ })
124
141
  })
125
142
 
126
143
  // ─────────────────────────────────────────────────────────────────────────────
@@ -48,6 +48,14 @@ import {
48
48
  // Types
49
49
  // ─────────────────────────────────────────────────────────────────────────────
50
50
 
51
+ /** Per-peer wake policy. `ephemeral` = stateless worker: every delivery is handled in
52
+ * a FRESH session, the peer dies after its turn, and a delivery to a still-live session
53
+ * is QUEUED (serial) rather than injected — so each task gets a clean context window.
54
+ * Lifecycle-owned (resolveWakeMode forces fresh; superviseTick reaps post-turn; the
55
+ * daemon drains the queue on death). Absent = normal warm-on-demand (resume-eligible).
56
+ * Enum (not bool) to leave room for future policies. */
57
+ export type WakePolicy = 'ephemeral'
58
+
51
59
  export interface PeerProfile {
52
60
  personality: string
53
61
  runtime: Runtime
@@ -59,6 +67,8 @@ export interface PeerProfile {
59
67
  * warm). Carries an opening directive and/or a "I'm up" report. */
60
68
  initial_prompt?: string
61
69
  interfaces?: PeerInterfaces
70
+ /** Per-peer wake policy (lifecycle-owned). Absent = normal warm-on-demand. */
71
+ wake_policy?: WakePolicy
62
72
  }
63
73
 
64
74
  // Write shape: intelligence/description optional so a caller can write a profile
@@ -211,6 +221,9 @@ export function readPeerProfile(cwd: string = process.cwd()): PeerProfile | null
211
221
  ? { initial_prompt: obj.initial_prompt }
212
222
  : {}),
213
223
  ...(interfaces ? { interfaces } : {}),
224
+ // Wake policy — only the known enum value is honored; anything else → omitted
225
+ // (treated as normal warm-on-demand, never throws on an unknown future value).
226
+ ...(obj.wake_policy === 'ephemeral' ? { wake_policy: 'ephemeral' as const } : {}),
214
227
  }
215
228
  }
216
229
 
@@ -0,0 +1,106 @@
1
+ // Ф-#8b: cold-wake boot first-message delivery via load-buffer + bracketed
2
+ // paste-buffer — the SAME byte-path as warm delivery (transport.deliverViaTmux),
3
+ // replacing the old `send-keys -l` retype. Proven hermetically against a REAL
4
+ // tmux (gated like sockdir.test.ts) with a fake tui adapter whose "runtime" is
5
+ // `cat >> <file>`: whatever the boot path injects into the pane lands verbatim in
6
+ // the file, and the ready-gate keys on that file's mtime (the activity proxy).
7
+ //
8
+ // pty note: `cat` reads the pane pty in CANONICAL mode (line-buffered, ~1 KiB
9
+ // line cap on macOS) — real TUIs run raw and have no such cap. The fixture
10
+ // message therefore uses many sub-1-KiB LINES to total multi-KiB; what this test
11
+ // pins is the INJECTION path (one bracketed paste, no option-parsing traps, no
12
+ // key-by-key retype), not the tty discipline.
13
+
14
+ import { afterEach, describe, expect, test } from 'bun:test'
15
+ import { mkdtempSync, readFileSync, rmSync, statSync } from 'fs'
16
+ import { tmpdir } from 'os'
17
+ import { join } from 'path'
18
+ import { spawnSync } from 'child_process'
19
+ import { launch } from './index.ts'
20
+ import type { LaunchConfig, LaunchSpec, RuntimeAdapter } from './types.ts'
21
+
22
+ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
23
+
24
+ const dirs: string[] = []
25
+ function mkTmp(): string {
26
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-bootdeliver-'))
27
+ dirs.push(d)
28
+ return d
29
+ }
30
+ afterEach(() => {
31
+ while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
32
+ })
33
+
34
+ /** Fake tui adapter: the "runtime" appends its pty input to `recvPath`; the
35
+ * activity proxy is that file's mtime (absent → null, exactly like a missing
36
+ * transcript), so the boot baseline is 0 and the ready-gate flips on receipt. */
37
+ function catAdapter(recvPath: string): RuntimeAdapter {
38
+ return {
39
+ runtime: 'claude', // any tui Runtime — the adapter object itself is what launch consumes
40
+ kind: 'tui',
41
+ usesDoctrine: false,
42
+ deliveryMarkers: { promptGlyphs: [] },
43
+ buildArgv: () => ['/bin/sh', '-c', `cat >> ${recvPath}`],
44
+ bootDialogKeys: () => null,
45
+ isInputReady: () => true,
46
+ newestActivityMtime: () => {
47
+ try {
48
+ return statSync(recvPath).mtimeMs
49
+ } catch {
50
+ return null
51
+ }
52
+ },
53
+ permissionDialogActive: () => false,
54
+ permissionDialogKeys: () => [],
55
+ resolveResume: () => ({ ok: true }),
56
+ executeControl: () => null,
57
+ }
58
+ }
59
+
60
+ describe('boot first-message delivery (load-buffer + bracketed paste)', () => {
61
+ test.if(tmuxAvailable)(
62
+ 'a multi-line, dash-leading, multi-KiB first message lands INTACT and launch goes READY',
63
+ async () => {
64
+ const root = mkTmp()
65
+ const recv = join(root, 'received.txt')
66
+ const sock = join(root, 'tmux-iap-claude-bootd.sock')
67
+ // The fixture stresses every historical boot-inject trap at once:
68
+ // • leading '-' — the send-keys option-parsing trap (audit #6);
69
+ // • quotes/$()/; — shell-metachar corruption if anything re-quoted the body;
70
+ // • multi-KiB — a size send-keys -l replayed key-by-key (8 × ~700 B lines).
71
+ const firstMessage = [
72
+ '- dash-leading first line (the send-keys option-parsing trap)',
73
+ `quotes "double" 'single' and $dollar \`backtick\` ; semicolon`,
74
+ ...Array.from({ length: 8 }, (_, i) => `${i}:${'x'.repeat(700)}`),
75
+ ].join('\n')
76
+ const spec: LaunchSpec = {
77
+ personality: 'bootd',
78
+ runtime: 'claude',
79
+ cwd: root,
80
+ identity: 'claude-bootd',
81
+ socketPath: sock,
82
+ intelligence: 'artificial',
83
+ }
84
+ const cfg: LaunchConfig = {
85
+ claudeBin: 'unused',
86
+ codexBin: 'unused',
87
+ sockDir: root,
88
+ bootDeadlineSecs: 10,
89
+ readyGateSecs: 8,
90
+ maxAgeSecs: 120,
91
+ logDir: join(root, 'logs'),
92
+ }
93
+ try {
94
+ const r = await launch(spec, catAdapter(recv), firstMessage, cfg)
95
+ expect(r.status).toBe('READY')
96
+ const got = readFileSync(recv, 'utf8')
97
+ expect(got).toContain('- dash-leading first line (the send-keys option-parsing trap)')
98
+ expect(got).toContain(`quotes "double" 'single' and $dollar \`backtick\` ; semicolon`)
99
+ for (let i = 0; i < 8; i++) expect(got).toContain(`${i}:${'x'.repeat(700)}`)
100
+ } finally {
101
+ spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
102
+ }
103
+ },
104
+ 60000,
105
+ )
106
+ })
@@ -273,7 +273,8 @@ export const launch: LaunchFn = async (
273
273
  }
274
274
 
275
275
  // (6) BOOT phase (tui) — baseline the activity proxy, answer startup dialogs,
276
- // wait for the input surface, then deliver firstMessage (send-keys -l + Enter).
276
+ // wait for the input surface, then deliver firstMessage (load-buffer +
277
+ // bracketed paste-buffer + Enter — the SAME byte-path as warm delivery).
277
278
  // An EMPTY firstMessage is a BARE bring-up (folder-launch with no seed, or an
278
279
  // attach-resume that carries no message): reach the input surface and return
279
280
  // READY — there is no message to deliver and nothing to ready-gate on (the
@@ -297,12 +298,31 @@ export const launch: LaunchFn = async (
297
298
  }
298
299
  if (adapter.isInputReady(pane)) {
299
300
  if (!hasMessage) return ready(identity) // bare bring-up — session up, no message
300
- // `--` terminates tmux option parsing (audit #6): without it a firstMessage that
301
- // starts with '-' (e.g. a task opening with a markdown bullet) is mis-parsed as a
302
- // send-keys flag, losing the boot message and failing the wake with a wrong reason.
303
- tmux(sock, 'send-keys', '-t', identity, '-l', '--', firstMessage)
301
+ // Boot delivery = load-buffer paste-buffer -p Enter (Ф-#8b hardening):
302
+ // the SAME mechanism warm delivery uses (transport.deliverViaTmux), replacing
303
+ // the old `send-keys -l`. send-keys retypes the message as literal keystrokes
304
+ // a multi-KB envelope replays key-by-key through the pty input buffer, and the
305
+ // two paths could diverge (a message that survives warm delivery could be
306
+ // mangled at cold-wake). load-buffer hands the TUI the whole envelope as ONE
307
+ // bracketed paste, byte-identical to the warm path. A load/paste hiccup →
308
+ // retry on the next boot iteration (previously the send-keys result was
309
+ // ignored and a failed inject was declared delivered, failing the wake later
310
+ // with the wrong reason at the ready-gate).
311
+ const bufferName = `iapeer-boot-${process.pid}-${Date.now()}`
312
+ const load = spawnSync(
313
+ 'tmux',
314
+ ['-S', sock, 'load-buffer', '-b', bufferName, '-'],
315
+ { input: firstMessage, encoding: 'utf8' },
316
+ )
317
+ if (load.status !== 0) continue
318
+ const paste = tmux(sock, 'paste-buffer', '-p', '-b', bufferName, '-t', identity)
319
+ if (!paste.ok) {
320
+ tmux(sock, 'delete-buffer', '-b', bufferName)
321
+ continue
322
+ }
304
323
  await sleep(300)
305
324
  tmux(sock, 'send-keys', '-t', identity, 'Enter')
325
+ tmux(sock, 'delete-buffer', '-b', bufferName)
306
326
  delivered = true
307
327
  }
308
328
  }
@@ -312,7 +312,8 @@ export interface LaunchResult {
312
312
  * Bring up ONE session: pre-clean stale tmux server → tmux new-session -d with
313
313
  * adapter.buildArgv → pipe-pane → session self-TTL → boot (answer dialogs via
314
314
  * adapter, wait for adapter.isInputReady, deliver the first message via
315
- * send-keys -l) ready-gate (adapter.newestActivityMtime strictly advances).
315
+ * load-buffer + bracketed paste — the same byte-path as warm delivery)
316
+ * ready-gate (adapter.newestActivityMtime strictly advances).
316
317
  * Runtime-agnostic; all specifics come from the adapter. Returns READY/FAILED.
317
318
  * `firstMessage` (the task / routed envelope) is delivered as the boot message;
318
319
  * a router runtime skips the TUI boot/ready phases.
@@ -19,17 +19,22 @@
19
19
  // • Best-effort throughout: a write/rotate failure is swallowed. Observability
20
20
  // must never take down the daemon or fail a wake/reap.
21
21
  //
22
- // Lifted-out-able: the rotate-append primitive is path-parameterized, so the
23
- // adjacent "log rotation" phase can promote it to storage/ and point other log
24
- // producers at it without touching this module's call sites.
22
+ // The rotate-append primitive was PROMOTED to storage/rotatelog.ts (as this header
23
+ // anticipated) when the daemon's per-delivery log became the second producer
24
+ // (Ф-#8a). This module keeps its public API (appendLifecycleEvent + the logfmt
25
+ // helpers, re-exported) so its call sites and tests are untouched; only the
26
+ // implementation now lives in storage.
25
27
 
26
- import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
27
28
  import { join } from 'path'
29
+ import {
30
+ DEFAULT_LOG_KEEP,
31
+ DEFAULT_LOG_MAX_BYTES,
32
+ appendRotatedEvent,
33
+ } from '../storage/rotatelog.ts'
28
34
 
29
- /** Default cap per lifecycle.log file before it rotates to lifecycle.log.1. */
30
- const DEFAULT_MAX_BYTES = 5 * 1024 * 1024 // 5 MiB
31
- /** Default number of rotated backups kept (lifecycle.log.1 … .KEEP). */
32
- const DEFAULT_KEEP = 5
35
+ // Re-export the logfmt helpers historical home of these (consumers import them
36
+ // from eventlog; the implementation moved to storage/rotatelog.ts).
37
+ export { fmtValue, formatEventLine } from '../storage/rotatelog.ts'
33
38
 
34
39
  /** The durable lifecycle decision log inside `logDir` (cfg.eventLogDir). */
35
40
  export function lifecycleLogPath(logDir: string): string {
@@ -49,56 +54,6 @@ export function superviseLogVerbose(env: NodeJS.ProcessEnv = process.env): boole
49
54
  return v === '1' || v === 'true' || v === 'yes'
50
55
  }
51
56
 
52
- /** logfmt value: bare token, or double-quoted with `"`/`\` escaped, when it
53
- * contains whitespace, `=` or `"`. Empty string → `""`. */
54
- export function fmtValue(v: string | number): string {
55
- const s = String(v)
56
- if (s === '') return '""'
57
- if (/[\s"=]/.test(s)) return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
58
- return s
59
- }
60
-
61
- /** Render one logfmt line (ts first, then fields in insertion order; undefined
62
- * fields are skipped). No trailing newline. Pure — unit-testable. */
63
- export function formatEventLine(nowMs: number, fields: Record<string, string | number | undefined>): string {
64
- const parts = [`ts=${new Date(nowMs).toISOString()}`]
65
- for (const [k, v] of Object.entries(fields)) {
66
- if (v === undefined) continue
67
- parts.push(`${k}=${fmtValue(v)}`)
68
- }
69
- return parts.join(' ')
70
- }
71
-
72
- /** Size-rotate `path` (and its .1 … .keep backups) when the next line would push
73
- * it over `maxBytes`. Drops the oldest, shifts each backup up by one, base→.1.
74
- * Best-effort: any fs hiccup leaves the chain as-is (we then just append). */
75
- function rotateIfNeeded(path: string, lineLen: number, maxBytes: number, keep: number): void {
76
- let size: number
77
- try {
78
- size = statSync(path).size
79
- } catch {
80
- return // no file yet → nothing to rotate
81
- }
82
- if (size + lineLen <= maxBytes) return
83
- try {
84
- rmSync(`${path}.${keep}`, { force: true })
85
- } catch {
86
- /* best-effort */
87
- }
88
- for (let i = keep - 1; i >= 1; i--) {
89
- try {
90
- renameSync(`${path}.${i}`, `${path}.${i + 1}`)
91
- } catch {
92
- /* that backup may not exist yet */
93
- }
94
- }
95
- try {
96
- renameSync(path, `${path}.1`)
97
- } catch {
98
- /* best-effort */
99
- }
100
- }
101
-
102
57
  export interface AppendEventOptions {
103
58
  /** Reads the rotation knobs IAPEER_LIFECYCLE_LOG_MAX_BYTES / _KEEP. */
104
59
  env?: NodeJS.ProcessEnv
@@ -119,15 +74,9 @@ export function appendLifecycleEvent(
119
74
  ): void {
120
75
  if (!logDir) return
121
76
  const env = opts.env ?? process.env
122
- const path = lifecycleLogPath(logDir)
123
- const line = `${formatEventLine(opts.nowMs ?? Date.now(), fields)}\n`
124
- const maxBytes = envPosInt(env.IAPEER_LIFECYCLE_LOG_MAX_BYTES, DEFAULT_MAX_BYTES)
125
- const keep = envPosInt(env.IAPEER_LIFECYCLE_LOG_KEEP, DEFAULT_KEEP)
126
- try {
127
- mkdirSync(logDir, { recursive: true, mode: 0o700 })
128
- rotateIfNeeded(path, line.length, maxBytes, keep)
129
- appendFileSync(path, line, { mode: 0o600 })
130
- } catch {
131
- /* observability is best-effort — a log failure must never break a wake/reap */
132
- }
77
+ appendRotatedEvent(lifecycleLogPath(logDir), fields, {
78
+ nowMs: opts.nowMs,
79
+ maxBytes: envPosInt(env.IAPEER_LIFECYCLE_LOG_MAX_BYTES, DEFAULT_LOG_MAX_BYTES),
80
+ keep: envPosInt(env.IAPEER_LIFECYCLE_LOG_KEEP, DEFAULT_LOG_KEEP),
81
+ })
133
82
  }
@@ -359,6 +359,22 @@ function isHumanConversational(cwd: string): boolean {
359
359
  }
360
360
  }
361
361
 
362
+ /**
363
+ * True iff the peer of `cwd` declares `wake_policy: "ephemeral"` — a stateless worker
364
+ * that ALWAYS wakes fresh on delivery (never resume), dies after its turn, and whose
365
+ * warm-session deliveries are queued (M3). A profile read hiccup → not-ephemeral (safe
366
+ * default: normal warm-on-demand). When BOTH ephemeral and a telegram interface are
367
+ * set, ephemeral WINS in resolveWakeMode (explicit policy beats the inferred human
368
+ * type) — provision warns on that combination; it should not occur for real workers.
369
+ */
370
+ function isEphemeralPeer(cwd: string): boolean {
371
+ try {
372
+ return readPeerProfile(cwd)?.wake_policy === 'ephemeral'
373
+ } catch {
374
+ return false
375
+ }
376
+ }
377
+
362
378
  /**
363
379
  * Decide resume vs fresh on a wake (TARGET redesign). Branch order:
364
380
  * 1. argsResume === false (folder-launch `iapeer <runtime>`) → FRESH.
@@ -390,6 +406,14 @@ export function resolveWakeMode(
390
406
  return { resume: true, resumeRef: r.ref, cause: 'attach' }
391
407
  }
392
408
  // 3. default (a message woke a dead/asleep peer): decide by the death cause.
409
+ // 3-ephemeral (M1): a stateless worker ALWAYS wakes fresh on delivery — never resume,
410
+ // regardless of death cause or topic. Its clean-window-per-task is the whole point.
411
+ // Consume a stray .idle-reaped marker so it does not accumulate (it has no effect for
412
+ // an ephemeral peer, which never resumes, but keep state tidy).
413
+ if (isEphemeralPeer(cwd)) {
414
+ clearIdleReaped(cfg, identity)
415
+ return { resume: false, cause: 'ephemeral-policy' }
416
+ }
393
417
  // 3a. NOT idle-reaped → it died on its own (crash / self-close) → clean FRESH.
394
418
  if (!hasIdleReaped(cfg, identity)) return { resume: false, cause: 'crash-or-self-close' }
395
419
  // 3b. idle-reaped → resume-eligible. Consume the marker now (it has done its job).
@@ -366,8 +366,9 @@ describe('C2 initial_prompt (composeFirstMessage)', () => {
366
366
  // = .idle-reaped marker, plus peer-type/topic; NO agent-dropped fresh mark).
367
367
  // ─────────────────────────────────────────────────────────────────────────────
368
368
 
369
- /** A temp cwd with a peer-profile; interfaces.telegram present → human-conversational. */
370
- function profileCwd(human: boolean): string {
369
+ /** A temp cwd with a peer-profile; interfaces.telegram present → human-conversational;
370
+ * ephemeral wake_policy "ephemeral". */
371
+ function profileCwd(human: boolean, ephemeral = false): string {
371
372
  const cwd = mkdtempSync(join(tmpdir(), 'iapeer-wm-cwd-'))
372
373
  mkdirSync(join(cwd, '.iapeer'), { recursive: true })
373
374
  writeFileSync(
@@ -378,6 +379,7 @@ function profileCwd(human: boolean): string {
378
379
  runtimes: ['claude'],
379
380
  intelligence: human ? 'natural' : 'artificial',
380
381
  ...(human ? { interfaces: { telegram: { user_id: 1 } } } : {}),
382
+ ...(ephemeral ? { wake_policy: 'ephemeral' } : {}),
381
383
  }),
382
384
  )
383
385
  return cwd
@@ -395,8 +397,8 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
395
397
  for (const c of cwds) rmSync(c, { recursive: true, force: true })
396
398
  })
397
399
  const cfg = () => ({ stateDir } as LifecycleConfig)
398
- const cwd = (human = false) => {
399
- const c = profileCwd(human)
400
+ const cwd = (human = false, ephemeral = false) => {
401
+ const c = profileCwd(human, ephemeral)
400
402
  cwds.push(c)
401
403
  return c
402
404
  }
@@ -449,6 +451,26 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
449
451
  expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript, 'unrelated-bug')).toEqual({ resume: false, cause: 'idle-reaped-new-topic' })
450
452
  expect(hasIdleReaped(c, 'claude-p')).toBe(false) // consumed even on the fresh executor branch
451
453
  })
454
+
455
+ // ── M1: wake_policy "ephemeral" → ALWAYS fresh on delivery, overrides resume ──
456
+ test('DEFAULT + ephemeral → FRESH (ephemeral-policy), even with a resumable transcript', () => {
457
+ expect(resolveWakeMode(cfg(), 'claude-p', cwd(false, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
458
+ })
459
+ test('DEFAULT + ephemeral + idle-reaped → FRESH (overrides idle-reaped-resume), marker consumed', () => {
460
+ const c = cfg()
461
+ setIdleReaped(c, 'claude-p')
462
+ expect(resolveWakeMode(c, 'claude-p', cwd(false, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
463
+ expect(hasIdleReaped(c, 'claude-p')).toBe(false) // stray marker consumed
464
+ })
465
+ test('DEFAULT + ephemeral + telegram (human) → FRESH (ephemeral WINS over human type)', () => {
466
+ const c = cfg()
467
+ setIdleReaped(c, 'claude-p')
468
+ expect(resolveWakeMode(c, 'claude-p', cwd(true, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
469
+ })
470
+ test('ephemeral does NOT hijack explicit attach (argsResume=true still resumes)', () => {
471
+ // attach is an operator action; ephemeral only governs the delivery path.
472
+ expect(resolveWakeMode(cfg(), 'claude-p', cwd(false, true), true, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'attach' })
473
+ })
452
474
  })
453
475
 
454
476
  describe('idle-reaped marker round-trip', () => {
@@ -0,0 +1,44 @@
1
+ // rotatelog — the generic rotated-logfmt-append primitive (promoted from
2
+ // lifecycle/eventlog.ts when the daemon's delivery.log became the second
3
+ // producer). The logfmt formatting (fmtValue/formatEventLine) is pinned in
4
+ // lifecycle/eventlog.test.ts (its historical home, re-exported); these tests
5
+ // cover what is NEW at this layer: the path-parameterized append + rotation.
6
+
7
+ import { afterEach, describe, expect, test } from 'bun:test'
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
9
+ import { tmpdir } from 'os'
10
+ import { join } from 'path'
11
+ import { appendRotatedEvent } from './rotatelog.ts'
12
+
13
+ const dirs: string[] = []
14
+ function mkTmp(): string {
15
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-rotatelog-'))
16
+ dirs.push(d)
17
+ return d
18
+ }
19
+ afterEach(() => {
20
+ while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
21
+ })
22
+
23
+ describe('appendRotatedEvent', () => {
24
+ test('creates the parent dir and appends one ts-stamped logfmt line', () => {
25
+ const path = join(mkTmp(), 'deep', 'nested', 'some.log')
26
+ appendRotatedEvent(path, { ev: 'delivery', ok: 'true', note: 'two words' }, { nowMs: 1750000000000 })
27
+ const text = readFileSync(path, 'utf8')
28
+ expect(text).toBe('ts=2025-06-15T15:06:40.000Z ev=delivery ok=true note="two words"\n')
29
+ })
30
+
31
+ test('rotates base → .1 at maxBytes and drops beyond keep', () => {
32
+ const path = join(mkTmp(), 'r.log')
33
+ const line = { ev: 'x', pad: 'a'.repeat(50) } // ~65 bytes/line
34
+ // maxBytes 100 → every second append rotates; keep 1 → no .2 ever exists.
35
+ for (let i = 0; i < 6; i++) appendRotatedEvent(path, line, { maxBytes: 100, keep: 1 })
36
+ expect(existsSync(path)).toBe(true)
37
+ expect(existsSync(`${path}.1`)).toBe(true)
38
+ expect(existsSync(`${path}.2`)).toBe(false)
39
+ })
40
+
41
+ test('never throws on an unwritable path (best-effort observability)', () => {
42
+ expect(() => appendRotatedEvent('/dev/null/impossible/x.log', { ev: 'x' })).not.toThrow()
43
+ })
44
+ })
@@ -0,0 +1,109 @@
1
+ // Rotated logfmt append — the GENERIC durable-log primitive, promoted out of
2
+ // lifecycle/eventlog.ts (its header anticipated exactly this: "the rotate-append
3
+ // primitive is path-parameterized, so the adjacent 'log rotation' phase can
4
+ // promote it to storage/ and point other log producers at it"). Producers today:
5
+ // • lifecycle.log (lifecycle/eventlog.ts — daemon lifecycle decisions)
6
+ // • delivery.log (daemon/deliverylog.ts — per-delivery outcomes, Ф-#8a)
7
+ //
8
+ // Design (carried verbatim from eventlog):
9
+ // • One line per event, logfmt (`key=value`, values quoted iff they contain
10
+ // whitespace/quotes/`=`). Human-greppable AND machine-parseable.
11
+ // • Append-only, app-managed SIZE rotation (base → .1 … .keep). This is the
12
+ // "встроенная ротация" class (Фаза — Ротация логов iapeer): a log OUR code
13
+ // writes rotates itself in the writer; external rotation is only for logs
14
+ // written by processes we don't control.
15
+ // • The target PATH is passed IN by the caller (who routes it through cfg),
16
+ // never re-resolved from env here — so a sandboxed caller cfg sandboxes the
17
+ // log too (no leak to the real ~/.iapeer).
18
+ // • Best-effort throughout: a write/rotate failure is swallowed. Observability
19
+ // must never take down the daemon or fail a wake/reap/delivery.
20
+
21
+ import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
22
+ import { dirname } from 'path'
23
+
24
+ /** Default cap per log file before it rotates to <path>.1. */
25
+ export const DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024 // 5 MiB
26
+ /** Default number of rotated backups kept (<path>.1 … .KEEP). */
27
+ export const DEFAULT_LOG_KEEP = 5
28
+
29
+ /** logfmt value: bare token, or double-quoted with `"`/`\` escaped, when it
30
+ * contains whitespace, `=` or `"`. Empty string → `""`. */
31
+ export function fmtValue(v: string | number): string {
32
+ const s = String(v)
33
+ if (s === '') return '""'
34
+ if (/[\s"=]/.test(s)) return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
35
+ return s
36
+ }
37
+
38
+ /** Render one logfmt line (ts first, then fields in insertion order; undefined
39
+ * fields are skipped). No trailing newline. Pure — unit-testable. */
40
+ export function formatEventLine(nowMs: number, fields: Record<string, string | number | undefined>): string {
41
+ const parts = [`ts=${new Date(nowMs).toISOString()}`]
42
+ for (const [k, v] of Object.entries(fields)) {
43
+ if (v === undefined) continue
44
+ parts.push(`${k}=${fmtValue(v)}`)
45
+ }
46
+ return parts.join(' ')
47
+ }
48
+
49
+ /** Size-rotate `path` (and its .1 … .keep backups) when the next line would push
50
+ * it over `maxBytes`. Drops the oldest, shifts each backup up by one, base→.1.
51
+ * Best-effort: any fs hiccup leaves the chain as-is (we then just append). */
52
+ function rotateIfNeeded(path: string, lineLen: number, maxBytes: number, keep: number): void {
53
+ let size: number
54
+ try {
55
+ size = statSync(path).size
56
+ } catch {
57
+ return // no file yet → nothing to rotate
58
+ }
59
+ if (size + lineLen <= maxBytes) return
60
+ try {
61
+ rmSync(`${path}.${keep}`, { force: true })
62
+ } catch {
63
+ /* best-effort */
64
+ }
65
+ for (let i = keep - 1; i >= 1; i--) {
66
+ try {
67
+ renameSync(`${path}.${i}`, `${path}.${i + 1}`)
68
+ } catch {
69
+ /* that backup may not exist yet */
70
+ }
71
+ }
72
+ try {
73
+ renameSync(path, `${path}.1`)
74
+ } catch {
75
+ /* best-effort */
76
+ }
77
+ }
78
+
79
+ export interface AppendRotatedOptions {
80
+ /** Stamp the line with this epoch-ms (a caller may pass its own tick clock so the
81
+ * log timestamp agrees with its accounting). Default Date.now(). */
82
+ nowMs?: number
83
+ /** Rotation cap per file (default DEFAULT_LOG_MAX_BYTES). */
84
+ maxBytes?: number
85
+ /** Rotated backups kept (default DEFAULT_LOG_KEEP). */
86
+ keep?: number
87
+ }
88
+
89
+ /**
90
+ * Append one logfmt event line to the rotated log at `path` (full file path,
91
+ * routed through the caller's cfg). Creates the parent dir (0700) and the file
92
+ * (0600) as needed. Fully best-effort — never throws.
93
+ */
94
+ export function appendRotatedEvent(
95
+ path: string,
96
+ fields: Record<string, string | number | undefined>,
97
+ opts: AppendRotatedOptions = {},
98
+ ): void {
99
+ const line = `${formatEventLine(opts.nowMs ?? Date.now(), fields)}\n`
100
+ const maxBytes = opts.maxBytes ?? DEFAULT_LOG_MAX_BYTES
101
+ const keep = opts.keep ?? DEFAULT_LOG_KEEP
102
+ try {
103
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
104
+ rotateIfNeeded(path, line.length, maxBytes, keep)
105
+ appendFileSync(path, line, { mode: 0o600 })
106
+ } catch {
107
+ /* observability is best-effort — a log failure must never break the caller */
108
+ }
109
+ }
@@ -6,9 +6,10 @@
6
6
  // Flow:
7
7
  // 1. latest = `npm view @agfpd/iapeer version` (the cloud's truth)
8
8
  // 2. installed == latest && !--force → "already latest" (no needless rebuild/restart)
9
- // 3. `npx @agfpd/iapeer@<latest> install` (fetch + rebuild ~/.local/bin/iapeer
10
- // atomically; the COMPILED binary can't rebuild itself from source, so we shell to
11
- // npx, which runs the freshly-fetched package's own install same path consumers use)
9
+ // 3. fetch the published tarball + build from its SOURCE (defaultRunInstall) — the
10
+ // COMPILED binary can't rebuild itself, so we pull the freshly-published package
11
+ // and run ITS own source installer. DELIBERATELY NOT `npx install` (see
12
+ // defaultRunInstall for why npx is unsafe here).
12
13
  // 4. kickstart com.agfpd.iapeer IF loaded (activate the new binary)
13
14
  //
14
15
  // Scope: the foundation ONLY (the @agfpd/iapeer binary + its daemon). It never
@@ -20,7 +21,10 @@
20
21
  // unit-testable with no network and no launchctl; the defaults are the real impls.
21
22
 
22
23
  import { spawnSync } from 'child_process'
24
+ import { mkdtempSync, readdirSync, rmSync } from 'fs'
23
25
  import { connect } from 'net'
26
+ import { tmpdir } from 'os'
27
+ import { join } from 'path'
24
28
  import { IapError } from '../core/errors.ts'
25
29
  import { IAPEER_VERSION } from '../core/version.ts'
26
30
  import { kickstartDaemon, type DaemonRestartResult } from '../launch/launchd.ts'
@@ -144,14 +148,51 @@ function defaultResolveVersion(spec: string, env: NodeJS.ProcessEnv): string | n
144
148
  return SEMVER_RE.test(v) ? v : null
145
149
  }
146
150
 
147
- /** Default installer: `npx -y @agfpd/iapeer@<version> install` (pull from cloud + rebuild). */
151
+ /**
152
+ * Default installer — fetch the published tarball and build from its SOURCE,
153
+ * DELIBERATELY bypassing `npx`. Pull from the cloud + rebuild ~/.local/bin/iapeer.
154
+ *
155
+ * Why not `npx -y @agfpd/iapeer@<v> install`: the package's bin is named `iapeer`,
156
+ * and once `~/.local/bin/iapeer` is on PATH (true on every host AFTER the first
157
+ * install) npx resolves that bin NAME to the COMPILED binary already on PATH and
158
+ * runs ITS `install` — which cannot rebuild itself from source (`bun build --compile`
159
+ * gets a `/$bunfs/root` entrypoint → FileNotFound) — instead of fetching + running the
160
+ * freshly-published source. Verified reproducible (09.06, 0.2.8 deploy): with NO
161
+ * `iapeer` on PATH the same npx invocation prints `command not found` — it never
162
+ * installs the package — so this is a structural bin-name collision, NOT the
163
+ * publish-propagation transient (waiting/retry does not cure it).
164
+ *
165
+ * Deterministic path instead — no npx command-resolution in the loop:
166
+ * 1. `npm pack <pkg>@<v>` → the published tarball (rooted at `package/`).
167
+ * 2. `tar xzf` → extract.
168
+ * 3. `npm install --omit=dev` in the extracted dir — the tarball ships only
169
+ * src/bin (no node_modules), and the source build imports prod deps
170
+ * (@modelcontextprotocol/sdk, …).
171
+ * 4. run the package's OWN bin shim `bash <pkg>/bin/iapeer install` — that is
172
+ * `bun src/cli/index.ts install` from the REAL fetched source → builds the prod
173
+ * binary atomically (keeps `.prev`).
174
+ * Needs npm + tar + bash + bun on PATH (the toolchain the bootstrap already assumes).
175
+ */
148
176
  function defaultRunInstall(version: string, env: NodeJS.ProcessEnv): boolean {
149
177
  if (env.IAPEER_TEST_SANDBOX === '1') {
150
- // A real npx install rebuilds the prod ~/.local/bin/iapeer — never under a test.
151
- throw new IapError('refusing a real `npx install` under IAPEER_TEST_SANDBOX=1 — inject runInstall in tests')
178
+ // A real install rebuilds the prod ~/.local/bin/iapeer — never under a test.
179
+ throw new IapError('refusing a real install under IAPEER_TEST_SANDBOX=1 — inject runInstall in tests')
180
+ }
181
+ const tmp = mkdtempSync(join(tmpdir(), 'iapeer-deploy-'))
182
+ try {
183
+ const pack = spawnSync('npm', ['pack', '--silent', '--pack-destination', tmp, `${IAPEER_PACKAGE}@${version}`], { encoding: 'utf8', env })
184
+ if (pack.status !== 0) return false
185
+ const tgz = readdirSync(tmp).find(f => f.endsWith('.tgz'))
186
+ if (!tgz) return false
187
+ if (spawnSync('tar', ['xzf', join(tmp, tgz), '-C', tmp], { env }).status !== 0) return false
188
+ const pkg = join(tmp, 'package') // npm-pack tarballs always root at `package/`
189
+ const deps = spawnSync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund', '--silent'], { cwd: pkg, stdio: 'inherit', env })
190
+ if (deps.status !== 0) return false
191
+ const build = spawnSync('bash', [join(pkg, 'bin', 'iapeer'), 'install'], { stdio: 'inherit', env })
192
+ return build.status === 0
193
+ } finally {
194
+ rmSync(tmp, { recursive: true, force: true })
152
195
  }
153
- const r = spawnSync('npx', ['-y', `${IAPEER_PACKAGE}@${version}`, 'install'], { stdio: 'inherit', env })
154
- return r.status === 0
155
196
  }
156
197
 
157
198
  /**
@@ -137,9 +137,9 @@ describe('updateIapeer — failure paths', () => {
137
137
  })
138
138
 
139
139
  describe('updateIapeer — real-installer sandbox guard', () => {
140
- test('default runInstall refuses a real npx install under IAPEER_TEST_SANDBOX', () => {
140
+ test('default runInstall refuses a real install under IAPEER_TEST_SANDBOX', () => {
141
141
  // fetchLatest injected (newer) so the gate proceeds to the DEFAULT installer,
142
- // which must refuse rather than npx-install over the prod ~/.local/bin/iapeer.
142
+ // which must refuse rather than fetch+build over the prod ~/.local/bin/iapeer.
143
143
  expect(() =>
144
144
  updateIapeer({
145
145
  env: { IAPEER_TEST_SANDBOX: '1' },