@agfpd/iapeer 0.4.47 → 0.4.50
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/init/index.ts +39 -2
- package/src/launch/codexHooksTrust.ts +1 -8
- package/src/launch/nativeMemory.ts +39 -2
- package/src/launch/tomlKey.ts +17 -0
- package/src/supervisor/client.ts +5 -2
- package/src/supervisor/index.ts +54 -4
- package/src/supervisor/paths.ts +27 -2
package/package.json
CHANGED
package/src/init/index.ts
CHANGED
|
@@ -216,7 +216,34 @@ export function codexConfigPath(options: StorageOptions = {}): string {
|
|
|
216
216
|
* serves every codex peer with its own identity (env_http_headers, verified live).
|
|
217
217
|
* Returns {path, added} (added=false when the block already existed).
|
|
218
218
|
*/
|
|
219
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Rewrite the `url = …` line INSIDE the `[mcp_servers.iapeer]` section to `url`, leaving
|
|
221
|
+
* the rest of the shared config byte-for-byte. The section runs from its header to the next
|
|
222
|
+
* `[…]` header (the `.env_http_headers` sub-table ends it — the url sits above that anyway).
|
|
223
|
+
* Returns the input unchanged when the url already matches (idempotent) or no url line exists.
|
|
224
|
+
*/
|
|
225
|
+
function healCodexMcpUrl(text: string, url: string): string {
|
|
226
|
+
const want = `url = ${JSON.stringify(url)}`
|
|
227
|
+
const lines = text.split('\n')
|
|
228
|
+
let inSection = false
|
|
229
|
+
for (let i = 0; i < lines.length; i++) {
|
|
230
|
+
const header = lines[i]!.match(/^\s*\[([^\]]+)\]\s*$/)
|
|
231
|
+
if (header) {
|
|
232
|
+
inSection = header[1] === `mcp_servers.${IAPEER_MCP_SERVER_NAME}`
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
if (inSection && /^\s*url\s*=/.test(lines[i]!)) {
|
|
236
|
+
if (lines[i]!.trim() !== want) lines[i] = want
|
|
237
|
+
break // exactly one url line per server section
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return lines.join('\n')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function writeCodexMcpConfig(
|
|
244
|
+
daemonUrl: string,
|
|
245
|
+
options: StorageOptions = {},
|
|
246
|
+
): { path: string; added: boolean; healed?: boolean } {
|
|
220
247
|
const path = codexConfigPath(options)
|
|
221
248
|
let existing = ''
|
|
222
249
|
try {
|
|
@@ -224,7 +251,17 @@ export function writeCodexMcpConfig(daemonUrl: string, options: StorageOptions =
|
|
|
224
251
|
} catch {
|
|
225
252
|
/* no config yet → create it */
|
|
226
253
|
}
|
|
227
|
-
if (/\[mcp_servers\.iapeer\]/.test(existing))
|
|
254
|
+
if (/\[mcp_servers\.iapeer\]/.test(existing)) {
|
|
255
|
+
// В44 — the block already exists, but the OLD code returned added:false WITHOUT checking
|
|
256
|
+
// the url. init bakes a fallback :8765 endpoint BEFORE the daemon publishes its real port;
|
|
257
|
+
// a later IAPEER_PORT change re-inits the claude side but left this codex block pinned to a
|
|
258
|
+
// now-DEAD port forever, while initPeer reported success. Heal the url in place (idempotent
|
|
259
|
+
// by content — only our section's `url=` line is touched, everything else is preserved).
|
|
260
|
+
const healed = healCodexMcpUrl(existing, daemonUrl)
|
|
261
|
+
if (healed === existing) return { path, added: false } // already correct → no write
|
|
262
|
+
writeFileAtomic(path, healed, 0o644)
|
|
263
|
+
return { path, added: false, healed: true }
|
|
264
|
+
}
|
|
228
265
|
const block =
|
|
229
266
|
`\n[mcp_servers.${IAPEER_MCP_SERVER_NAME}]\n` +
|
|
230
267
|
`url = ${JSON.stringify(daemonUrl)}\n` +
|
|
@@ -49,6 +49,7 @@ import { createHash } from 'crypto'
|
|
|
49
49
|
import { dirname } from 'path'
|
|
50
50
|
import { writeFileAtomic } from '../storage/index.ts'
|
|
51
51
|
import { codexGlobalConfigPath } from './nativeMemory.ts'
|
|
52
|
+
import { assertTomlSafeKey } from './tomlKey.ts'
|
|
52
53
|
|
|
53
54
|
/** hooks.json event names → the snake_case labels codex keys state with
|
|
54
55
|
* (upstream `hook_event_key_label`). */
|
|
@@ -226,14 +227,6 @@ function resolveHooksSource(hooksJsonPath: string): string {
|
|
|
226
227
|
return realpathSync(hooksJsonPath)
|
|
227
228
|
}
|
|
228
229
|
|
|
229
|
-
/** TOML basic strings treat `\` and `"` specially — paths carrying them would
|
|
230
|
-
* need escaping codex-side too; refuse instead of guessing. */
|
|
231
|
-
function assertTomlSafeKey(key: string): void {
|
|
232
|
-
if (key.includes('"') || key.includes('\\')) {
|
|
233
|
-
throw new Error(`hooks.json path is not TOML-key-safe (contains " or \\): ${key}`)
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
230
|
/**
|
|
238
231
|
* Pre-seed the codex hooks trust state for every command hook in the given
|
|
239
232
|
* hooks.json — the deterministic, headless replacement for the "Hooks need
|
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
// (slot-gated, see provision/index.ts).
|
|
22
22
|
|
|
23
23
|
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs'
|
|
24
|
-
import { dirname, join } from 'path'
|
|
24
|
+
import { basename, dirname, join, resolve } from 'path'
|
|
25
25
|
import { homedir } from 'os'
|
|
26
26
|
import type { Runtime } from '../core/constants.ts'
|
|
27
27
|
import { writeFileAtomic } from '../storage/index.ts'
|
|
28
|
+
import { assertTomlSafeKey } from './tomlKey.ts'
|
|
28
29
|
|
|
29
30
|
export type NativeMemoryState = 'off' | 'on'
|
|
30
31
|
|
|
@@ -161,6 +162,11 @@ export function preTrustCodexCwd(cwd: string, env: NodeJS.ProcessEnv = process.e
|
|
|
161
162
|
} catch {
|
|
162
163
|
/* cwd not on disk yet → keep as given */
|
|
163
164
|
}
|
|
165
|
+
// В42 — the cwd becomes a QUOTED TOML key `[projects."<real>"]`. An operator-supplied
|
|
166
|
+
// `create --path` cwd carrying `"`/`\` (legal on APFS) would corrupt the SHARED global
|
|
167
|
+
// config and break codex for every peer on the host. Refuse (the try/catch reports it
|
|
168
|
+
// as a 'failed' lever); same guard the sibling hooks-trust writer uses.
|
|
169
|
+
assertTomlSafeKey(real)
|
|
164
170
|
const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
165
171
|
if (text.includes(`[projects."${real}"]`)) return { runtime: 'codex', path, state: 'already' }
|
|
166
172
|
const block = `${text.length && !text.endsWith('\n') ? '\n' : ''}${text.trim().length ? '\n' : ''}[projects."${real}"]\ntrust_level = "trusted"\n`
|
|
@@ -172,6 +178,30 @@ export function preTrustCodexCwd(cwd: string, env: NodeJS.ProcessEnv = process.e
|
|
|
172
178
|
}
|
|
173
179
|
}
|
|
174
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Resolve `p` to its real path even when the leaf (or several trailing components) no
|
|
183
|
+
* longer exists: realpath the nearest EXISTING ancestor and re-append the missing tail.
|
|
184
|
+
* Returns null only if nothing on the chain up to the root exists (impossible for `/`).
|
|
185
|
+
* Used by removeCodexCwdTrust to reconstruct the realpath-form key of an already-deleted
|
|
186
|
+
* cwd so a stale trust entry written under a symlink-resolved path is still matched.
|
|
187
|
+
*/
|
|
188
|
+
function realpathViaExistingAncestor(p: string): string | null {
|
|
189
|
+
let cur = resolve(p)
|
|
190
|
+
const tail: string[] = []
|
|
191
|
+
// Guard against an unbounded loop: dirname('/') === '/'.
|
|
192
|
+
for (let depth = 0; depth < 4096; depth++) {
|
|
193
|
+
try {
|
|
194
|
+
return tail.length ? join(realpathSync(cur), ...tail.slice().reverse()) : realpathSync(cur)
|
|
195
|
+
} catch {
|
|
196
|
+
const parent = dirname(cur)
|
|
197
|
+
if (parent === cur) return null // reached the root and even it did not resolve
|
|
198
|
+
tail.push(basename(cur))
|
|
199
|
+
cur = parent
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
175
205
|
/**
|
|
176
206
|
* Remove a peer's pre-trust entry from the codex GLOBAL config — the reap-side
|
|
177
207
|
* counterpart of preTrustCodexCwd (backlog «remove не чистит pre-trust», made
|
|
@@ -192,7 +222,14 @@ export function removeCodexCwdTrust(cwd: string, env: NodeJS.ProcessEnv = proces
|
|
|
192
222
|
} catch {
|
|
193
223
|
/* cwd already deleted → only the literal form can match */
|
|
194
224
|
}
|
|
195
|
-
|
|
225
|
+
// В43 — the writer keyed the entry on the RESOLVED real path. When the cwd is already
|
|
226
|
+
// DELETED (the throwaway-sandbox reap case), realpathSync(cwd) above throws and `real`
|
|
227
|
+
// falls back to the LITERAL cwd — which MISSES if any path component was a symlink
|
|
228
|
+
// (e.g. codex stored /private/tmp/… for a /tmp/… cwd). Reconstruct the resolved form
|
|
229
|
+
// from the nearest still-existing ANCESTOR + the deleted remainder, so the stale entry
|
|
230
|
+
// is found and removed instead of lingering as a phantom trust for that path.
|
|
231
|
+
const viaAncestor = realpathViaExistingAncestor(cwd)
|
|
232
|
+
const candidates = new Set([real, cwd, ...(viaAncestor ? [viaAncestor] : [])])
|
|
196
233
|
const lines = readFileSync(path, 'utf8').split('\n')
|
|
197
234
|
const kept: string[] = []
|
|
198
235
|
let inDoomed = false
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Shared TOML-key safety for the codex GLOBAL config.toml writers. codex's host-wide
|
|
2
|
+
// config addresses per-project state by a QUOTED-string key — `[projects."<path>"]`,
|
|
3
|
+
// `[hooks_trust."<source>"]`, `[mcp_servers.<name>]`. A TOML basic string treats `"`
|
|
4
|
+
// and `\` specially, so a key carrying either would need codex-side escaping we cannot
|
|
5
|
+
// guarantee round-trips; writing it raw corrupts the whole SHARED config and breaks
|
|
6
|
+
// codex for EVERY peer on the host. Refuse instead of guessing.
|
|
7
|
+
//
|
|
8
|
+
// Extracted from codexHooksTrust.ts (which introduced it for hooks sources) so the
|
|
9
|
+
// native-memory pre-trust writer reuses the exact same guard — one definition, one
|
|
10
|
+
// behavior, no drift.
|
|
11
|
+
|
|
12
|
+
/** Throw if `key` cannot be a raw TOML basic-string key (contains `"` or `\`). */
|
|
13
|
+
export function assertTomlSafeKey(key: string): void {
|
|
14
|
+
if (key.includes('"') || key.includes('\\')) {
|
|
15
|
+
throw new Error(`path is not TOML-key-safe (contains " or \\): ${key}`)
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/supervisor/client.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// via writeSync which works inside a signal handler), and #2 backpressure is the daemon's side.
|
|
5
5
|
import { writeSync } from 'node:fs'
|
|
6
6
|
import { FRAME_DATA, FRAME_RESIZE, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
|
|
7
|
-
import { pidStartToken, readPidFile, sockPath } from './paths.ts'
|
|
7
|
+
import { ownershipVerdict, pidStartToken, readPidFile, sockPath } from './paths.ts'
|
|
8
8
|
|
|
9
9
|
const DETACH = 0x1d // Ctrl-]
|
|
10
10
|
|
|
@@ -32,8 +32,11 @@ export function sessionAlive(runDir: string, session: string): boolean {
|
|
|
32
32
|
const now = Date.now()
|
|
33
33
|
const cached = ownerCache.get(key)
|
|
34
34
|
if (cached && cached.pid === rec.pid && cached.token === rec.token && now < cached.until) return cached.verdict
|
|
35
|
+
// Fail-SAFE on a token-READ failure (split-brain live incident 03.07): a transient `ps` spawn error
|
|
36
|
+
// returns null; treating that as "dead" let one hiccup reap a LIVE session and spawn a duplicate per
|
|
37
|
+
// message. kill-0 above already proved the pid live — only a PROVEN mismatch (a reused pid) reads dead.
|
|
35
38
|
const live = pidStartToken(rec.pid)
|
|
36
|
-
const verdict = live
|
|
39
|
+
const verdict = ownershipVerdict(live, rec.token) // same process instance unless proven otherwise
|
|
37
40
|
ownerCache.set(key, { pid: rec.pid, token: rec.token, verdict, until: now + OWNER_TTL_MS })
|
|
38
41
|
return verdict
|
|
39
42
|
}
|
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, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
|
|
11
|
+
import { defaultRunDir, logPath, ownershipVerdict, 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
|
|
@@ -66,6 +66,46 @@ export interface StartDaemonResult {
|
|
|
66
66
|
detail?: string
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** Does a unix socket at `path` ACCEPT connections right now? A dead leftover sock file refuses
|
|
70
|
+
* instantly (ECONNREFUSED → false); the timeout only bounds a pathologically wedged listener. */
|
|
71
|
+
async function sockAccepts(path: string, timeoutMs = 400): Promise<boolean> {
|
|
72
|
+
return await new Promise<boolean>(resolve => {
|
|
73
|
+
let done = false
|
|
74
|
+
const finish = (v: boolean): void => {
|
|
75
|
+
if (done) return
|
|
76
|
+
done = true
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
resolve(v)
|
|
79
|
+
}
|
|
80
|
+
const timer = setTimeout(() => finish(false), timeoutMs)
|
|
81
|
+
Bun.connect({
|
|
82
|
+
unix: path,
|
|
83
|
+
socket: {
|
|
84
|
+
open(s) {
|
|
85
|
+
// finish BEFORE end(): under Bun, end() re-enters close() SYNCHRONOUSLY, and a
|
|
86
|
+
// close()-first would read an accepting listener as dead (verified by probe).
|
|
87
|
+
finish(true)
|
|
88
|
+
try {
|
|
89
|
+
s.end()
|
|
90
|
+
} catch {
|
|
91
|
+
/* probe only */
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
data() {},
|
|
95
|
+
close() {
|
|
96
|
+
finish(false) // closed before open fired → not accepting
|
|
97
|
+
},
|
|
98
|
+
error() {
|
|
99
|
+
finish(false)
|
|
100
|
+
},
|
|
101
|
+
connectError() {
|
|
102
|
+
finish(false) // dead leftover sock file → connect refused
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
}).catch(() => finish(false))
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
69
109
|
/**
|
|
70
110
|
* Re-invoke THIS module as the detached daemon (spawn-flip Ф1 serving-wiring). From SOURCE (bun) the
|
|
71
111
|
* module is a real file on disk → run it directly (`bun <self> daemon …` → import.meta.main →
|
|
@@ -93,6 +133,15 @@ export async function startSupervisorDaemon(opts: StartDaemonOptions): Promise<S
|
|
|
93
133
|
const { session, runtime, runDir } = opts
|
|
94
134
|
if (sessionAlive(runDir, session)) return { state: 'already-running' }
|
|
95
135
|
mkdirSync(runDir, { recursive: true })
|
|
136
|
+
// Second-instance guard (В28-class, peer-session form — split-brain live incident 03.07): NEVER unlink
|
|
137
|
+
// a sock that still ACCEPTS connections. sessionAlive above keys on the pidfile, which a transient
|
|
138
|
+
// token-read failure or a clobbered pidfile can falsify — but a live supervisor HOLDS its unix socket
|
|
139
|
+
// open, so a successful connect is proof a session exists. Spawning here would create a duplicate
|
|
140
|
+
// session (split-brain: two agents answering as one peer) and orphan the live one by overwriting its
|
|
141
|
+
// pidfile. The probe client sends no FRAME_RESIZE, so it never counts as an attached operator (В23).
|
|
142
|
+
if (existsSync(sockPath(runDir, session)) && (await sockAccepts(sockPath(runDir, session)))) {
|
|
143
|
+
return { state: 'already-running' }
|
|
144
|
+
}
|
|
96
145
|
try {
|
|
97
146
|
if (existsSync(sockPath(runDir, session))) unlinkSync(sockPath(runDir, session))
|
|
98
147
|
} catch {
|
|
@@ -174,9 +223,10 @@ export function killSession(runDir: string, session: string): boolean {
|
|
|
174
223
|
// В22 — verify the pid is STILL our daemon before SIGTERM. After an abnormal death + pid reuse, a bare
|
|
175
224
|
// kill here would SIGTERM the INNOCENT process that inherited the pid. Skip the kill when the start-token
|
|
176
225
|
// no longer matches (a legacy tokenless pidfile keeps the old behavior — no regression during rollout).
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
226
|
+
// Same ownershipVerdict primitive as sessionAlive: refuse ONLY on a PROVEN mismatch (null = unreadable
|
|
227
|
+
// token, still ours — kill must stay possible when `ps` hiccups, or a stuck session becomes unstoppable).
|
|
228
|
+
if (rec.token && !ownershipVerdict(pidStartToken(rec.pid), rec.token)) {
|
|
229
|
+
return false // reused pid — NOT our session; do not signal it
|
|
180
230
|
}
|
|
181
231
|
try {
|
|
182
232
|
process.kill(rec.pid, 'SIGTERM')
|
package/src/supervisor/paths.ts
CHANGED
|
@@ -24,11 +24,22 @@ export const pidPath = (runDir: string, session: string): string => join(runDir,
|
|
|
24
24
|
|
|
25
25
|
/** The OS-reported start time of `pid` (a stable per-process-instance token), or null if it is not
|
|
26
26
|
* running / unreadable. `ps -o lstart=` is a single spawn — callers bound its frequency (see the
|
|
27
|
-
* liveness cache in client.ts).
|
|
27
|
+
* liveness cache in client.ts).
|
|
28
|
+
*
|
|
29
|
+
* LC_ALL=C is PINNED (split-brain live incident 03.07): `lstart` output is LOCALE-DEPENDENT, and the
|
|
30
|
+
* token is compared as a STRING between processes with different env — the writer (a supervisor
|
|
31
|
+
* inheriting the spawning terminal's locale, e.g. an operator's ru_RU `iapeer attach`) rendered
|
|
32
|
+
* «пятница, 3 июля …» while the verifying daemon (launchd env, C locale) rendered "Fri Jul 3 …"
|
|
33
|
+
* for the SAME start time → a PROVEN-looking mismatch → the live session read as a reused pid,
|
|
34
|
+
* got reaped, and the next message spawned a duplicate. Pinning the locale on BOTH the write and
|
|
35
|
+
* the verify path makes the token format process-env-independent. */
|
|
28
36
|
export function pidStartToken(pid: number): string | null {
|
|
29
37
|
if (!Number.isInteger(pid) || pid <= 0) return null
|
|
30
38
|
try {
|
|
31
|
-
const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
39
|
+
const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
42
|
+
})
|
|
32
43
|
const t = (r.stdout ?? '').trim()
|
|
33
44
|
return t.length > 0 ? t : null
|
|
34
45
|
} catch {
|
|
@@ -36,6 +47,20 @@ export function pidStartToken(pid: number): string | null {
|
|
|
36
47
|
}
|
|
37
48
|
}
|
|
38
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Ownership verdict from a live-read token vs the recorded one — the ONE semantics both liveness
|
|
52
|
+
* (`sessionAlive`) and kill (`killSession`) key on: the pid is ours unless a PROVEN mismatch.
|
|
53
|
+
* `live === null` means the token could not be READ (a transient `ps` spawn failure — EAGAIN under a
|
|
54
|
+
* post-reboot fork-storm, live incident 03.07: split-brain boris), NOT that the pid was reused; kill-0
|
|
55
|
+
* has already proven a live pid, so an unverifiable token must stay fail-SAFE (alive / still ours).
|
|
56
|
+
* Before this primitive, sessionAlive required `live !== null && live === token` (fail-open: a tool
|
|
57
|
+
* hiccup read a LIVE supervisor as dead → every wake spawned a duplicate session and orphaned the
|
|
58
|
+
* previous one by overwriting the pidfile), while killSession already treated null as "ours".
|
|
59
|
+
*/
|
|
60
|
+
export function ownershipVerdict(live: string | null, recorded: string): boolean {
|
|
61
|
+
return live === null ? true : live === recorded
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
/** Write the pidfile as `<pid> <startToken>` (В22). Falls back to bare `<pid>` if the token is
|
|
40
65
|
* unavailable — a tokenless pidfile is treated as legacy (owner-check skipped, kill-0 only). */
|
|
41
66
|
export function writePidFile(runDir: string, session: string, pid: number): void {
|