@agfpd/iapeer 0.4.39 → 0.4.41
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 +2 -2
- package/src/daemon/index.ts +35 -1
- package/src/daemon/main.ts +22 -3
- package/src/supervisor/client.ts +21 -4
- package/src/supervisor/daemon.ts +2 -2
- package/src/supervisor/index.ts +32 -8
- package/src/supervisor/paths.ts +44 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -64,7 +64,7 @@ import {
|
|
|
64
64
|
validateProfileStandard,
|
|
65
65
|
} from '../identity/profileStandard.ts'
|
|
66
66
|
import { runAlwaysOn } from '../launch/launchdRun.ts'
|
|
67
|
-
import { ensureDaemonStarted, installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
|
|
67
|
+
import { ensureDaemonStarted, installDaemonPlist, parseDaemonPort, startConfiguredDaemon } from '../daemon/main.ts'
|
|
68
68
|
import { MARKETPLACE_NAME, onboardHost, runtimeAuthNote, tccFullDiskAccessNote } from '../onboard/index.ts'
|
|
69
69
|
import { probeFullDiskAccess, readMemoryProvider } from '../status/index.ts'
|
|
70
70
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
@@ -2242,7 +2242,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2242
2242
|
return 0
|
|
2243
2243
|
}
|
|
2244
2244
|
const handle = await startConfiguredDaemon({
|
|
2245
|
-
port: env.IAPEER_PORT
|
|
2245
|
+
port: parseDaemonPort(env.IAPEER_PORT), // В29 — strict: NaN/out-of-range → default, never an ephemeral port
|
|
2246
2246
|
socketPath: env.IAPEER_DAEMON_SOCKET?.trim() || undefined,
|
|
2247
2247
|
env,
|
|
2248
2248
|
})
|
package/src/daemon/index.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// same-uid and should add a bearer token before production exposure — OPEN.
|
|
22
22
|
|
|
23
23
|
import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'http'
|
|
24
|
+
import { createConnection } from 'net'
|
|
24
25
|
import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'fs'
|
|
25
26
|
import { dirname, join } from 'path'
|
|
26
27
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
@@ -49,6 +50,30 @@ import { appendDeliveryEvent } from './deliverylog.ts'
|
|
|
49
50
|
export const CALLER_HEADER = 'x-iapeer-identity'
|
|
50
51
|
const SERVER_INFO = { name: 'iapeer', version: '0.0.0' }
|
|
51
52
|
|
|
53
|
+
/** Does a LIVE listener answer on this unix socket path? (В28) A successful connect ⇒ a daemon is
|
|
54
|
+
* running; ECONNREFUSED ⇒ a stale socket file (safe to unlink). Bounded by a short timeout so a wedged
|
|
55
|
+
* socket never hangs startup. */
|
|
56
|
+
function socketIsLive(socketPath: string, timeoutMs = 500): Promise<boolean> {
|
|
57
|
+
return new Promise<boolean>(resolve => {
|
|
58
|
+
let settled = false
|
|
59
|
+
const finish = (live: boolean): void => {
|
|
60
|
+
if (settled) return
|
|
61
|
+
settled = true
|
|
62
|
+
try {
|
|
63
|
+
c.destroy()
|
|
64
|
+
} catch {
|
|
65
|
+
/* */
|
|
66
|
+
}
|
|
67
|
+
resolve(live)
|
|
68
|
+
}
|
|
69
|
+
const c = createConnection(socketPath)
|
|
70
|
+
c.once('connect', () => finish(true))
|
|
71
|
+
c.once('error', () => finish(false))
|
|
72
|
+
const t = setTimeout(() => finish(false), timeoutMs)
|
|
73
|
+
if (typeof t.unref === 'function') t.unref()
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
78
|
// Caller identity from the request header
|
|
54
79
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -487,7 +512,16 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
487
512
|
// Unix-socket listener (H8 same-uid base, 0600).
|
|
488
513
|
if (socketPath) {
|
|
489
514
|
mkdirSync(dirname(socketPath), { recursive: true, mode: 0o700 })
|
|
490
|
-
if (existsSync(socketPath))
|
|
515
|
+
if (existsSync(socketPath)) {
|
|
516
|
+
// В28 — a LIVE daemon may already own this socket. Unlinking it out from under a live listener
|
|
517
|
+
// silently breaks every unix-socket caller (CLI/notifier/telegram) until a restart. Probe first:
|
|
518
|
+
// if a daemon answers, REFUSE to start (touch nothing). Only a STALE socket file (no listener,
|
|
519
|
+
// ECONNREFUSED) is removed. Guards the documented `iapeer daemon` foreground acceptance mode.
|
|
520
|
+
if (await socketIsLive(socketPath)) {
|
|
521
|
+
throw new Error(`a daemon is already listening on ${socketPath} — refusing to start a second instance`)
|
|
522
|
+
}
|
|
523
|
+
unlinkSync(socketPath)
|
|
524
|
+
}
|
|
491
525
|
const s = createHttpServer(handle)
|
|
492
526
|
await new Promise<void>((resolve, reject) => {
|
|
493
527
|
s.once('error', reject)
|
package/src/daemon/main.ts
CHANGED
|
@@ -72,6 +72,19 @@ import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index
|
|
|
72
72
|
* a STABLE port, not an ephemeral one. Override with IAPEER_PORT. */
|
|
73
73
|
export const DEFAULT_DAEMON_PORT = 8765
|
|
74
74
|
|
|
75
|
+
/** Parse IAPEER_PORT STRICTLY (В29). `Number('abc')` = NaN passes `?? DEFAULT` (NaN is not nullish) →
|
|
76
|
+
* listen(NaN) binds an EPHEMERAL port while the fleet's .mcp.json points at the fixed 8765 → the whole
|
|
77
|
+
* TCP-MCP surface is silently offline yet the daemon reads "healthy" (health probes only the unix
|
|
78
|
+
* socket). Unset/empty → the default; anything not an integer in 1–65535 → a LOUD warn + the default. */
|
|
79
|
+
export function parseDaemonPort(raw: string | undefined, dflt: number = DEFAULT_DAEMON_PORT): number {
|
|
80
|
+
const t = raw?.trim()
|
|
81
|
+
if (!t) return dflt
|
|
82
|
+
const n = Number(t)
|
|
83
|
+
if (Number.isInteger(n) && n >= 1 && n <= 65535) return n
|
|
84
|
+
process.stderr.write(`[iapeer-daemon] WARN invalid IAPEER_PORT=${JSON.stringify(raw)} — falling back to ${dflt}\n`)
|
|
85
|
+
return dflt
|
|
86
|
+
}
|
|
87
|
+
|
|
75
88
|
/** Default supervise-tick cadence (idle-reap / zombie-sweep). idleSecs (1h default)
|
|
76
89
|
* is the reap threshold; this is just how often the timer checks. */
|
|
77
90
|
export const DEFAULT_SUPERVISE_INTERVAL_MS = 60_000
|
|
@@ -213,7 +226,8 @@ function queuedFailureMessage(job: ComposerQueuedDelivery, reason: string): stri
|
|
|
213
226
|
export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): ComposerQueueRouteDeps {
|
|
214
227
|
const noteLiveTopic = makeNoteLiveTopic(cfg, env)
|
|
215
228
|
const onDelivered = makeArmEphemeralOnDelivered(cfg)
|
|
216
|
-
|
|
229
|
+
// NB: no `wake` here — the composer-queue failure outlet (notifyFailed) deliberately does NOT wake an
|
|
230
|
+
// offline sender (В30); it fires on failAll during shutdown/update.
|
|
217
231
|
const ephemeral = makeEphemeralRouteDeps(cfg, env)
|
|
218
232
|
|
|
219
233
|
return createComposerDeliveryQueue({
|
|
@@ -269,7 +283,12 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
|
|
|
269
283
|
// Do not pass composerQueue here: this path is the queue's failure outlet
|
|
270
284
|
// (including SIGTERM/update). Recursively queueing a failure notification
|
|
271
285
|
// could turn shutdown re-fail into another queued item.
|
|
272
|
-
|
|
286
|
+
// В30 — NO wake dep either: this outlet fires on failAll (SIGTERM/update). Waking an offline
|
|
287
|
+
// sender to notify it takes 30-60s (ready-gate), blowing past launchd's ExitTimeOut → SIGKILL
|
|
288
|
+
// drops the remaining notifications (the very silent loss failAll exists to prevent) and stalls
|
|
289
|
+
// the restart. An ephemeral sender still gets a DURABLE-queued notification (ephemeral dep); a
|
|
290
|
+
// non-ephemeral offline sender degrades to a delivery.log record (best-effort) — never a wake.
|
|
291
|
+
{ ephemeral, noteLiveTopic },
|
|
273
292
|
)
|
|
274
293
|
appendDeliveryEvent(cfg.eventLogDir, {
|
|
275
294
|
ev: 'composer-queue-failed-notify',
|
|
@@ -542,7 +561,7 @@ if (import.meta.main) {
|
|
|
542
561
|
} else {
|
|
543
562
|
const env = process.env
|
|
544
563
|
const handle = await startConfiguredDaemon({
|
|
545
|
-
port:
|
|
564
|
+
port: parseDaemonPort(env.IAPEER_PORT),
|
|
546
565
|
socketPath: env.IAPEER_DAEMON_SOCKET?.trim() || undefined,
|
|
547
566
|
env,
|
|
548
567
|
})
|
package/src/supervisor/client.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// Block 2, ported from PoC pts.mjs). Detach key Ctrl-] (session keeps running). The two reattach
|
|
3
3
|
// port-deps live here + in protocol.ts: #1 TERM_RESET on EVERY exit path (incl. SIGHUP window-close,
|
|
4
4
|
// via writeSync which works inside a signal handler), and #2 backpressure is the daemon's side.
|
|
5
|
-
import {
|
|
5
|
+
import { writeSync } from 'node:fs'
|
|
6
6
|
import { FRAME_DATA, FRAME_RESIZE, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
|
|
7
|
-
import {
|
|
7
|
+
import { pidStartToken, readPidFile, sockPath } from './paths.ts'
|
|
8
8
|
|
|
9
9
|
const DETACH = 0x1d // Ctrl-]
|
|
10
10
|
|
|
@@ -16,9 +16,26 @@ const pidAlive = (p: number): boolean => {
|
|
|
16
16
|
return false
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
// В22 — OWNER-validated liveness. kill(pid,0) is the fast fail (dead pid → not alive). When the pid is
|
|
21
|
+
// alive AND the pidfile carries a start-token, verify the token matches the pid's CURRENT start time so a
|
|
22
|
+
// REUSED pid (a different process that inherited the number) never reads as our live session. `ps` is
|
|
23
|
+
// bounded by a short per-session cache — repeated liveness reads within a tick do not re-spawn it.
|
|
24
|
+
const OWNER_TTL_MS = 3000
|
|
25
|
+
const ownerCache = new Map<string, { pid: number; token: string; verdict: boolean; until: number }>()
|
|
26
|
+
|
|
19
27
|
export function sessionAlive(runDir: string, session: string): boolean {
|
|
20
|
-
const
|
|
21
|
-
|
|
28
|
+
const rec = readPidFile(runDir, session)
|
|
29
|
+
if (!rec || !pidAlive(rec.pid)) return false
|
|
30
|
+
if (!rec.token) return true // legacy tokenless pidfile → kill-0 only (no regression during rollout)
|
|
31
|
+
const key = `${runDir}\0${session}`
|
|
32
|
+
const now = Date.now()
|
|
33
|
+
const cached = ownerCache.get(key)
|
|
34
|
+
if (cached && cached.pid === rec.pid && cached.token === rec.token && now < cached.until) return cached.verdict
|
|
35
|
+
const live = pidStartToken(rec.pid)
|
|
36
|
+
const verdict = live !== null && live === rec.token // same process instance, not a reused pid
|
|
37
|
+
ownerCache.set(key, { pid: rec.pid, token: rec.token, verdict, until: now + OWNER_TTL_MS })
|
|
38
|
+
return verdict
|
|
22
39
|
}
|
|
23
40
|
|
|
24
41
|
/** Attach an interactive terminal client to a live daemon session. Resolves when the client
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
splitDanglingEscape,
|
|
26
26
|
stripQueries,
|
|
27
27
|
} from './protocol.ts'
|
|
28
|
-
import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
|
|
28
|
+
import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
|
|
29
29
|
import { nextBootAction, nextNagAction, type BootPredicates, type NagPredicate } from './boot.ts'
|
|
30
30
|
import { modelToPlainText } from './render.ts'
|
|
31
31
|
import { codexAdapter } from '../launch/adapters/codex.ts'
|
|
@@ -515,7 +515,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
515
515
|
},
|
|
516
516
|
},
|
|
517
517
|
})
|
|
518
|
-
|
|
518
|
+
writePidFile(runDir, session, process.pid) // В22 — `<pid> <startToken>` so a reused pid can't impersonate this session
|
|
519
519
|
writeGeometry(runDir, session, serveCols, serveRows) // initial geometry sidecar (= serve); updated on every real child resize
|
|
520
520
|
|
|
521
521
|
let down = false
|
package/src/supervisor/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { spawn as nodeSpawn } from 'node:child_process'
|
|
|
8
8
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { runSupervisorClient, sessionAlive } from './client.ts'
|
|
11
|
-
import { defaultRunDir, logPath, pidPath, servePath, sockPath } from './paths.ts'
|
|
11
|
+
import { defaultRunDir, logPath, pidPath, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
|
|
12
12
|
|
|
13
13
|
// daemon.ts loads @xterm. It is dynamic-imported ONLY in the 'daemon' CLI case below (the detached
|
|
14
14
|
// daemon process, where @xterm belongs), so importing THIS module — e.g. launch resolving
|
|
@@ -125,17 +125,35 @@ export async function startSupervisorDaemon(opts: StartDaemonOptions): Promise<S
|
|
|
125
125
|
const deadline = Date.now() + (opts.timeoutMs ?? 5000)
|
|
126
126
|
while (Date.now() < deadline) {
|
|
127
127
|
if (existsSync(sockPath(runDir, session)) && sessionAlive(runDir, session)) {
|
|
128
|
-
return { state: 'started', pid:
|
|
128
|
+
return { state: 'started', pid: readPidFile(runDir, session)?.pid }
|
|
129
129
|
}
|
|
130
130
|
await Bun.sleep(80) // yield the loop — never a sync block in the daemon's wake path
|
|
131
131
|
}
|
|
132
|
-
// Failed to come up —
|
|
132
|
+
// Failed to come up in time. В20 — KILL the spawned child FIRST. A merely-slow child that becomes the
|
|
133
|
+
// daemon AFTER we unlink the serve-spec would readServeSpec → undefined → come up as a BARE CONDUIT
|
|
134
|
+
// ([bin] runtime with NO PEER_IDENTITY / composed argv / doctrine) that captures the peer's identity:
|
|
135
|
+
// liveness then reads it "online", messages route into an identity-less session, and if it slipped
|
|
136
|
+
// past enroll-on-FAILED it is invisible to idle-reap (an unbounded orphan). Better a dead peer (the
|
|
137
|
+
// next wake respawns clean) than an identity-less conduit. Kill, THEN drop the spec + any stale
|
|
138
|
+
// sock/pid the dying child created (SIGKILL skips the child's own cleanup).
|
|
139
|
+
try {
|
|
140
|
+
child.kill('SIGKILL')
|
|
141
|
+
} catch {
|
|
142
|
+
/* already gone */
|
|
143
|
+
}
|
|
133
144
|
if (opts.serve)
|
|
134
145
|
try {
|
|
135
146
|
unlinkSync(servePath(runDir, session))
|
|
136
147
|
} catch {
|
|
137
148
|
/* */
|
|
138
149
|
}
|
|
150
|
+
for (const p of [sockPath(runDir, session), pidPath(runDir, session)]) {
|
|
151
|
+
try {
|
|
152
|
+
if (existsSync(p)) unlinkSync(p)
|
|
153
|
+
} catch {
|
|
154
|
+
/* best-effort */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
139
157
|
return { state: 'failed', detail: `daemon did not come up; see ${logPath(runDir, session)}` }
|
|
140
158
|
}
|
|
141
159
|
|
|
@@ -146,16 +164,22 @@ export function listSessions(runDir: string): Array<{ session: string; alive: bo
|
|
|
146
164
|
.map(f => f.replace(/\.sock$/, ''))
|
|
147
165
|
.map(session => {
|
|
148
166
|
const alive = sessionAlive(runDir, session)
|
|
149
|
-
return { session, alive, pid: alive ?
|
|
167
|
+
return { session, alive, pid: alive ? readPidFile(runDir, session)?.pid : undefined }
|
|
150
168
|
})
|
|
151
169
|
}
|
|
152
170
|
|
|
153
171
|
export function killSession(runDir: string, session: string): boolean {
|
|
154
|
-
const
|
|
155
|
-
if (!
|
|
156
|
-
|
|
172
|
+
const rec = readPidFile(runDir, session)
|
|
173
|
+
if (!rec) return false
|
|
174
|
+
// В22 — verify the pid is STILL our daemon before SIGTERM. After an abnormal death + pid reuse, a bare
|
|
175
|
+
// kill here would SIGTERM the INNOCENT process that inherited the pid. Skip the kill when the start-token
|
|
176
|
+
// no longer matches (a legacy tokenless pidfile keeps the old behavior — no regression during rollout).
|
|
177
|
+
if (rec.token) {
|
|
178
|
+
const live = pidStartToken(rec.pid)
|
|
179
|
+
if (live !== null && live !== rec.token) return false // reused pid — NOT our session; do not signal it
|
|
180
|
+
}
|
|
157
181
|
try {
|
|
158
|
-
process.kill(pid, 'SIGTERM')
|
|
182
|
+
process.kill(rec.pid, 'SIGTERM')
|
|
159
183
|
} catch {
|
|
160
184
|
/* already gone */
|
|
161
185
|
}
|
package/src/supervisor/paths.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// socket/pid files never touch a real location.
|
|
4
4
|
import { renameSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
6
7
|
import { join } from 'node:path'
|
|
7
8
|
import { pluginStateDir } from '../storage/index.ts'
|
|
8
9
|
|
|
@@ -13,6 +14,49 @@ export function defaultRunDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
13
14
|
|
|
14
15
|
export const sockPath = (runDir: string, session: string): string => join(runDir, `${session}.sock`)
|
|
15
16
|
export const pidPath = (runDir: string, session: string): string => join(runDir, `${session}.pid`)
|
|
17
|
+
|
|
18
|
+
// ── pid-OWNERSHIP token (В22) ───────────────────────────────────────────────────
|
|
19
|
+
// kill(pid,0) proves SOME process holds the pid, not that it is STILL our daemon: after an abnormal
|
|
20
|
+
// death (SIGKILL/panic/reboot) the pidfile survives, and if the OS reuses that pid for another same-uid
|
|
21
|
+
// process, a bare kill(pid,0) reads the dead session as "alive" (peer undeliverable + unreapable) and a
|
|
22
|
+
// kill() would SIGTERM the innocent reuser. The pidfile therefore stores `<pid> <startToken>` where the
|
|
23
|
+
// token is the process's start time; a reused pid has a DIFFERENT start time, so ownership is verifiable.
|
|
24
|
+
|
|
25
|
+
/** The OS-reported start time of `pid` (a stable per-process-instance token), or null if it is not
|
|
26
|
+
* running / unreadable. `ps -o lstart=` is a single spawn — callers bound its frequency (see the
|
|
27
|
+
* liveness cache in client.ts). */
|
|
28
|
+
export function pidStartToken(pid: number): string | null {
|
|
29
|
+
if (!Number.isInteger(pid) || pid <= 0) return null
|
|
30
|
+
try {
|
|
31
|
+
const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' })
|
|
32
|
+
const t = (r.stdout ?? '').trim()
|
|
33
|
+
return t.length > 0 ? t : null
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Write the pidfile as `<pid> <startToken>` (В22). Falls back to bare `<pid>` if the token is
|
|
40
|
+
* unavailable — a tokenless pidfile is treated as legacy (owner-check skipped, kill-0 only). */
|
|
41
|
+
export function writePidFile(runDir: string, session: string, pid: number): void {
|
|
42
|
+
const token = pidStartToken(pid)
|
|
43
|
+
writeFileSync(pidPath(runDir, session), token ? `${pid} ${token}` : String(pid))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse the pidfile → { pid, token } (token '' for a legacy bare-pid file). null when absent/invalid. */
|
|
47
|
+
export function readPidFile(runDir: string, session: string): { pid: number; token: string } | null {
|
|
48
|
+
let raw: string
|
|
49
|
+
try {
|
|
50
|
+
raw = readFileSync(pidPath(runDir, session), 'utf8').trim()
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
if (!raw) return null
|
|
55
|
+
const sp = raw.indexOf(' ')
|
|
56
|
+
const pid = Number(sp < 0 ? raw : raw.slice(0, sp))
|
|
57
|
+
if (!Number.isInteger(pid) || pid <= 0) return null
|
|
58
|
+
return { pid, token: sp < 0 ? '' : raw.slice(sp + 1).trim() }
|
|
59
|
+
}
|
|
16
60
|
export const logPath = (runDir: string, session: string): string => join(runDir, `${session}.log`)
|
|
17
61
|
/** Serving spec (cutover Block 2 slice b): the composed {argv, env, cwd} a detached daemon serves.
|
|
18
62
|
* Written 0600 by startSupervisorDaemon BEFORE spawn (env can carry launch.env secrets), read by
|