@agfpd/iapeer 0.1.0

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.
Files changed (63) hide show
  1. package/bin/iapeer +25 -0
  2. package/package.json +37 -0
  3. package/src/cli/cli.test.ts +130 -0
  4. package/src/cli/index.ts +608 -0
  5. package/src/cli/listTui.test.ts +70 -0
  6. package/src/cli/listTui.ts +165 -0
  7. package/src/codec/codec.test.ts +271 -0
  8. package/src/codec/index.ts +217 -0
  9. package/src/core/constants.test.ts +21 -0
  10. package/src/core/constants.ts +180 -0
  11. package/src/core/errors.ts +20 -0
  12. package/src/core/index.ts +3 -0
  13. package/src/core/normalize.test.ts +98 -0
  14. package/src/core/normalize.ts +89 -0
  15. package/src/core/socket.ts +63 -0
  16. package/src/create/create.test.ts +143 -0
  17. package/src/create/index.ts +178 -0
  18. package/src/daemon/daemon-http.test.ts +114 -0
  19. package/src/daemon/daemon.test.ts +103 -0
  20. package/src/daemon/index.ts +439 -0
  21. package/src/daemon/main.test.ts +194 -0
  22. package/src/daemon/main.ts +230 -0
  23. package/src/enable/enable.test.ts +92 -0
  24. package/src/enable/index.ts +381 -0
  25. package/src/identity/identity.test.ts +262 -0
  26. package/src/identity/index.ts +603 -0
  27. package/src/index.ts +27 -0
  28. package/src/init/index.ts +408 -0
  29. package/src/init/init.test.ts +171 -0
  30. package/src/init/runtime-resolve.test.ts +49 -0
  31. package/src/install/index.ts +84 -0
  32. package/src/install/install.test.ts +31 -0
  33. package/src/launch/adapters/claude.ts +250 -0
  34. package/src/launch/adapters/codex.ts +329 -0
  35. package/src/launch/adapters/notifier.ts +90 -0
  36. package/src/launch/adapters/telegram.ts +130 -0
  37. package/src/launch/bootstrap.test.ts +56 -0
  38. package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
  39. package/src/launch/composeSystemPrompt.test.ts +98 -0
  40. package/src/launch/composeSystemPrompt.ts +261 -0
  41. package/src/launch/index.ts +253 -0
  42. package/src/launch/launch.test.ts +233 -0
  43. package/src/launch/launchd.test.ts +363 -0
  44. package/src/launch/launchd.ts +375 -0
  45. package/src/launch/launchdRun.ts +168 -0
  46. package/src/launch/sockdir.test.ts +70 -0
  47. package/src/launch/types.ts +300 -0
  48. package/src/lifecycle/index.ts +840 -0
  49. package/src/lifecycle/lifecycle.test.ts +496 -0
  50. package/src/onboard/index.ts +135 -0
  51. package/src/onboard/onboard.test.ts +39 -0
  52. package/src/provision/index.ts +170 -0
  53. package/src/provision/provision.test.ts +104 -0
  54. package/src/registry/index.ts +453 -0
  55. package/src/registry/registry.test.ts +400 -0
  56. package/src/runtime/deploy.ts +230 -0
  57. package/src/runtime/index.ts +191 -0
  58. package/src/runtime/runtime.test.ts +226 -0
  59. package/src/storage/index.ts +331 -0
  60. package/src/storage/peers-home.test.ts +34 -0
  61. package/src/storage/storage.test.ts +65 -0
  62. package/src/transport/index.ts +522 -0
  63. package/tsconfig.json +17 -0
@@ -0,0 +1,381 @@
1
+ // enable — per-peer capability install (contract Установка §3 + Per-рантайм
2
+ // install-асимметрия; verb `enable <capability> [peer]`). The init→enable
3
+ // capability story: a peer gains a capability (peer-voice / MergeMind) FROM our
4
+ // marketplace. iapeer ORCHESTRATES — install + enable + call `setup` ONLY if the
5
+ // plugin declares it — and deliberately does NOT resolve the plugin's dep-graph
6
+ // (`requires`/`setup` internals are the plugin's job, → Стандарт iapeer плагина).
7
+ //
8
+ // Per-рантайм install-асимметрия (contract table):
9
+ // claude → `plugin install <p>@agfpd --scope project` run IN the peer's cwd; the
10
+ // project-scope entry is keyed by projectPath, so it is ISOLATED per peer
11
+ // (a test-peer install never touches a live peer's entry). enable = that
12
+ // install (project-scope is the cold-start MCP enabler) + plugin enable.
13
+ // codex → `plugin add <p>@agfpd` GLOBAL (host-wide, once); enable is per-peer via
14
+ // config. codex has no project scope (a global mutation), so it is NOT run
15
+ // against the real host under fleet-guard without an isolated CODEX_HOME.
16
+ //
17
+ // SIMPLE vs СЛОЖНЫЙ plugin (contract): a plugin MAY ship `iapeer.json` at its root
18
+ // declaring `setup` (a multi-step installer) and `requires`. SIMPLE (peer-voice — no
19
+ // iapeer.json/no setup): install + enable → works. СЛОЖНЫЙ (MergeMind): install +
20
+ // enable → call `setup`. iapeer reads the manifest and calls setup ONLY if declared.
21
+
22
+ import { spawnSync } from 'child_process'
23
+ import { existsSync, readFileSync, realpathSync } from 'fs'
24
+ import { basename, join } from 'path'
25
+ import { homedir } from 'os'
26
+ import { MARKETPLACE_NAME } from '../onboard/index.ts'
27
+ import { findPeer, readPeersIndex } from '../registry/index.ts'
28
+ import { IapError } from '../core/errors.ts'
29
+ import { normalizeNameCandidate } from '../core/normalize.ts'
30
+
31
+ export type CapabilityRuntime = 'claude' | 'codex'
32
+
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+ // Installed-plugins parse + per-peer match (PURE — the fleet-guard hinges on it)
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+
37
+ /** One entry of `claude plugin list --json`. Project-scope entries carry projectPath
38
+ * (the per-peer key); user/local scope do not. */
39
+ export interface InstalledEntry {
40
+ id: string // `<plugin>@<marketplace>`
41
+ scope: string // 'project' | 'user' | 'local'
42
+ enabled: boolean
43
+ projectPath?: string
44
+ installPath?: string
45
+ }
46
+
47
+ /** Parse `claude plugin list --json` (a flat array) into typed entries; tolerant of
48
+ * unknown extra fields and non-array payloads (→ []). */
49
+ export function parseInstalledPlugins(json: string): InstalledEntry[] {
50
+ let data: unknown
51
+ try {
52
+ data = JSON.parse(json)
53
+ } catch {
54
+ return []
55
+ }
56
+ if (!Array.isArray(data)) return []
57
+ return data.flatMap(raw => {
58
+ if (!raw || typeof raw !== 'object') return []
59
+ const o = raw as Record<string, unknown>
60
+ if (typeof o.id !== 'string') return []
61
+ return [
62
+ {
63
+ id: o.id,
64
+ scope: typeof o.scope === 'string' ? o.scope : 'unknown',
65
+ enabled: o.enabled === true,
66
+ projectPath: typeof o.projectPath === 'string' ? o.projectPath : undefined,
67
+ installPath: typeof o.installPath === 'string' ? o.installPath : undefined,
68
+ },
69
+ ]
70
+ })
71
+ }
72
+
73
+ /** realpath-normalize a path for comparison (/tmp→/private/tmp on macOS); falls back
74
+ * to the raw path when it does not resolve. Mirrors the legacy install-gate match. */
75
+ function canonPath(p: string): string {
76
+ try {
77
+ return realpathSync(p)
78
+ } catch {
79
+ return p
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Find the project-scope entry for `<plugin>@agfpd` installed for THIS peer cwd
85
+ * (realpath-matched projectPath). Returns the entry (so the caller sees enabled +
86
+ * installPath) or null. This is what makes enable idempotent AND fleet-safe: it keys
87
+ * on the peer's OWN projectPath, never another peer's.
88
+ */
89
+ export function findPeerScopedEntry(
90
+ entries: InstalledEntry[],
91
+ plugin: string,
92
+ marketplace: string,
93
+ peerCwd: string,
94
+ ): InstalledEntry | null {
95
+ const id = `${plugin}@${marketplace}`
96
+ const want = canonPath(peerCwd)
97
+ return (
98
+ entries.find(
99
+ e => e.id === id && e.scope === 'project' && e.projectPath !== undefined && canonPath(e.projectPath) === want,
100
+ ) ?? null
101
+ )
102
+ }
103
+
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+ // Manifest — setup detection (PURE)
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+
108
+ /** The optional `setup` descriptor in a plugin's `iapeer.json`: a bare command/script
109
+ * string (resolved relative to the plugin root), or {command,args}. */
110
+ export type SetupDescriptor = string | { command: string; args?: string[] }
111
+ export interface IapeerManifest {
112
+ setup?: SetupDescriptor
113
+ requires?: unknown
114
+ }
115
+
116
+ /**
117
+ * Read `<installPath>/iapeer.json` and return its `setup` descriptor, or null when
118
+ * the plugin is SIMPLE (no manifest, no `setup`). A malformed manifest is treated as
119
+ * SIMPLE (no setup) rather than failing enable — the manifest is the plugin's contract,
120
+ * not iapeer's; install+enable already succeeded.
121
+ */
122
+ export function readSetupDescriptor(installPath: string | undefined): SetupDescriptor | null {
123
+ if (!installPath) return null
124
+ const manifestPath = join(installPath, 'iapeer.json')
125
+ if (!existsSync(manifestPath)) return null
126
+ try {
127
+ const m = JSON.parse(readFileSync(manifestPath, 'utf8')) as IapeerManifest
128
+ if (typeof m.setup === 'string' && m.setup.trim()) return m.setup
129
+ if (m.setup && typeof m.setup === 'object' && typeof m.setup.command === 'string' && m.setup.command.trim()) {
130
+ return { command: m.setup.command, args: Array.isArray(m.setup.args) ? m.setup.args.map(String) : undefined }
131
+ }
132
+ return null
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+ // Orchestrator
140
+ // ─────────────────────────────────────────────────────────────────────────────
141
+
142
+ export type RuntimeEnableState =
143
+ | 'already-enabled' // present + enabled for this peer → no-op (idempotent, fleet-safe)
144
+ | 'installed' // installed (and enabled) now
145
+ | 'enabled' // was installed-but-disabled → enabled now
146
+ | 'runtime-missing' // the runtime binary is not on the host
147
+ | 'failed' // install/enable command failed
148
+
149
+ export type SetupState = 'absent' | 'called' | 'failed' | 'skipped'
150
+
151
+ export interface RuntimeEnableResult {
152
+ runtime: CapabilityRuntime
153
+ state: RuntimeEnableState
154
+ installPath?: string
155
+ detail?: string
156
+ }
157
+
158
+ export interface EnableResult {
159
+ plugin: string
160
+ personality: string
161
+ cwd: string
162
+ runtimes: RuntimeEnableResult[]
163
+ setup: SetupState
164
+ setupDetail?: string
165
+ }
166
+
167
+ export interface EnableOptions {
168
+ plugin: string
169
+ /** Target peer by personality; default = the peer of the current cwd. */
170
+ peer?: string
171
+ /** Restrict to these runtimes (default: the peer's agentic runtimes). */
172
+ runtimes?: CapabilityRuntime[]
173
+ /** Skip the plugin's `setup` even if declared (install+enable only). */
174
+ noSetup?: boolean
175
+ env?: NodeJS.ProcessEnv
176
+ cwd?: string
177
+ }
178
+
179
+ function claudeBin(env: NodeJS.ProcessEnv): string {
180
+ return env.IAPEER_CLAUDE_BIN?.trim() || join(env.HOME?.trim() || homedir(), '.local', 'bin', 'claude')
181
+ }
182
+ function codexBin(env: NodeJS.ProcessEnv): string {
183
+ return env.IAPEER_CODEX_BIN?.trim() || 'codex'
184
+ }
185
+
186
+ /** Resolve the target peer (cwd + agentic runtimes) from a personality arg or the cwd.
187
+ * Without an explicit peer, match the registry by THIS cwd first (robust to a
188
+ * uniqueness-suffixed personality), falling back to normalize(basename(cwd)). */
189
+ function resolvePeer(opts: EnableOptions): { personality: string; cwd: string; runtimes: CapabilityRuntime[] } {
190
+ const env = opts.env ?? process.env
191
+ const index = readPeersIndex({ env })
192
+ const cwd = opts.cwd ?? process.cwd()
193
+ let rec = opts.peer
194
+ ? findPeer(index, opts.peer)
195
+ : index.peers.find(p => canonPath(p.cwd) === canonPath(cwd)) ?? findPeer(index, normalizeNameCandidate(basename(cwd)))
196
+ const personality = opts.peer ?? rec?.personality ?? normalizeNameCandidate(basename(cwd))
197
+ if (!rec) {
198
+ throw new IapError(`peer "${personality}" is not registered — run \`iapeer init\` in its folder first`)
199
+ }
200
+ const agentic = rec.runtimes.filter((r): r is CapabilityRuntime => r === 'claude' || r === 'codex')
201
+ if (agentic.length === 0) {
202
+ throw new IapError(`peer "${personality}" has no agentic runtime (claude/codex) — capability plugins need one`)
203
+ }
204
+ return { personality, cwd: rec.cwd, runtimes: agentic }
205
+ }
206
+
207
+ function isExecutable(bin: string, env: NodeJS.ProcessEnv): boolean {
208
+ const r = spawnSync(bin, ['--version'], { stdio: 'ignore', env: env as Record<string, string> })
209
+ return r.error === undefined && r.status !== null
210
+ }
211
+
212
+ /** Read the peer-scoped entry for this plugin from a fresh `claude plugin list --json`.
213
+ * CRITICAL: `enabled` is evaluated relative to the CURRENT cwd's project (verified
214
+ * live — the same entry lists enabled=false from another dir, true from its own), so
215
+ * the list MUST run with cwd = the peer cwd to read the authoritative enabled-state.
216
+ * maxBuffer is raised — a configured host's plugin list exceeds the 1 MB default. */
217
+ function claudeEntry(bin: string, plugin: string, peerCwd: string, env: NodeJS.ProcessEnv): InstalledEntry | null {
218
+ const list = spawnSync(bin, ['plugin', 'list', '--json'], {
219
+ cwd: peerCwd,
220
+ encoding: 'utf8',
221
+ env: env as Record<string, string>,
222
+ maxBuffer: 64 * 1024 * 1024,
223
+ })
224
+ return findPeerScopedEntry(parseInstalledPlugins(list.stdout ?? ''), plugin, MARKETPLACE_NAME, peerCwd)
225
+ }
226
+
227
+ /**
228
+ * claude: install `<plugin>@agfpd` project-scope IN the peer cwd. A project-scope
229
+ * install also ENABLES it for that project (verified live: enabled=true when listed
230
+ * from the peer cwd). The explicit `plugin enable` runs ONLY for a pre-existing entry
231
+ * that lists disabled — never right after a fresh install (that errors "already
232
+ * enabled"). Idempotent + fleet-safe: the entry is keyed by THIS peer's projectPath
233
+ * (realpath), so it never reads or mutates another peer's install.
234
+ */
235
+ function enableClaude(plugin: string, peerCwd: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
236
+ const bin = claudeBin(env)
237
+ if (!isExecutable(bin, env)) return { runtime: 'claude', state: 'runtime-missing' }
238
+ const id = `${plugin}@${MARKETPLACE_NAME}`
239
+ const existing = claudeEntry(bin, plugin, peerCwd, env)
240
+ if (existing?.enabled) return { runtime: 'claude', state: 'already-enabled', installPath: existing.installPath }
241
+
242
+ if (!existing) {
243
+ const inst = spawnSync(bin, ['plugin', 'install', id, '--scope', 'project'], {
244
+ cwd: peerCwd,
245
+ encoding: 'utf8',
246
+ env: env as Record<string, string>,
247
+ })
248
+ if (inst.status !== 0) {
249
+ return { runtime: 'claude', state: 'failed', detail: (inst.stderr || inst.stdout || `exit ${inst.status}`).trim() }
250
+ }
251
+ }
252
+ // the entry now exists but is DISABLED (fresh install) or was pre-existing-disabled →
253
+ // enable it (and ONLY then; an already-enabled entry would error).
254
+ const after = claudeEntry(bin, plugin, peerCwd, env)
255
+ if (after && !after.enabled) {
256
+ const en = spawnSync(bin, ['plugin', 'enable', id, '--scope', 'project'], {
257
+ cwd: peerCwd,
258
+ encoding: 'utf8',
259
+ env: env as Record<string, string>,
260
+ })
261
+ const confirmed = claudeEntry(bin, plugin, peerCwd, env)
262
+ if (!confirmed?.enabled) {
263
+ return { runtime: 'claude', state: 'failed', detail: (en.stderr || en.stdout || `exit ${en.status}`).trim() }
264
+ }
265
+ return { runtime: 'claude', state: existing ? 'enabled' : 'installed', installPath: confirmed.installPath }
266
+ }
267
+ if (!after) return { runtime: 'claude', state: 'failed', detail: 'install reported success but no project-scope entry appeared' }
268
+ return { runtime: 'claude', state: existing ? 'enabled' : 'installed', installPath: after.installPath }
269
+ }
270
+
271
+ /** Parse a `codex plugin list` STATUS column for one plugin id. The table is
272
+ * whitespace-aligned: `<id> <status> <version> <path>`; status is `not installed`
273
+ * / `installed, enabled` / `installed, disabled`. PURE → unit-testable. */
274
+ export function parseCodexPluginStatus(listOutput: string, id: string): 'enabled' | 'disabled' | 'absent' {
275
+ for (const line of listOutput.split('\n')) {
276
+ const cols = line.split(/\s{2,}/).map(c => c.trim())
277
+ if (cols[0] !== id) continue
278
+ const status = (cols[1] ?? '').toLowerCase()
279
+ if (status.includes('not installed')) return 'absent'
280
+ if (status.includes('enabled')) return 'enabled'
281
+ if (status.includes('installed')) return 'disabled'
282
+ return 'absent'
283
+ }
284
+ return 'absent'
285
+ }
286
+
287
+ function codexState(bin: string, id: string, env: NodeJS.ProcessEnv): 'enabled' | 'disabled' | 'absent' {
288
+ const r = spawnSync(bin, ['plugin', 'list'], { encoding: 'utf8', env: env as Record<string, string>, maxBuffer: 64 * 1024 * 1024 })
289
+ return parseCodexPluginStatus(r.stdout ?? '', id)
290
+ }
291
+
292
+ /** codex: add `<plugin>@agfpd` GLOBAL (host-wide; codex has no project scope). The add
293
+ * ENABLES (verified live: config.toml `[plugins."<id>"] enabled = true`) and is
294
+ * idempotent (re-add exits 0). Idempotency: skip when already enabled. installPath is
295
+ * parsed from the add output so setup-detection works for a codex-only peer. */
296
+ function enableCodex(plugin: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
297
+ const bin = codexBin(env)
298
+ if (!isExecutable(bin, env)) return { runtime: 'codex', state: 'runtime-missing' }
299
+ const id = `${plugin}@${MARKETPLACE_NAME}`
300
+ const before = codexState(bin, id, env)
301
+ if (before === 'enabled') return { runtime: 'codex', state: 'already-enabled' }
302
+ const add = spawnSync(bin, ['plugin', 'add', id], { encoding: 'utf8', env: env as Record<string, string> })
303
+ if (add.status !== 0) {
304
+ return { runtime: 'codex', state: 'failed', detail: (add.stderr || add.stdout || `exit ${add.status}`).trim() }
305
+ }
306
+ if (codexState(bin, id, env) !== 'enabled') {
307
+ return { runtime: 'codex', state: 'failed', detail: (add.stdout || 'plugin add did not enable the plugin').trim() }
308
+ }
309
+ const m = /Installed plugin root:\s*(\S.*)/.exec(add.stdout ?? '')
310
+ return { runtime: 'codex', state: before === 'disabled' ? 'enabled' : 'installed', installPath: m?.[1]?.trim() }
311
+ }
312
+
313
+ /** Invoke the plugin's `setup` (СЛОЖНЫЙ class), cwd = plugin root, peer context in env
314
+ * (namespaced IAPEER_PEER_* — NOT the bare PEER_PERSONALITY the identity-gate keys on).
315
+ * Best-effort: a setup failure is reported, it does NOT unwind the completed install. */
316
+ function callSetup(
317
+ setup: SetupDescriptor,
318
+ installPath: string,
319
+ peer: { personality: string; cwd: string },
320
+ env: NodeJS.ProcessEnv,
321
+ ): { ok: boolean; detail?: string } {
322
+ const [command, ...preArgs] = typeof setup === 'string' ? [setup] : [setup.command, ...(setup.args ?? [])]
323
+ const setupEnv: NodeJS.ProcessEnv = {
324
+ ...env,
325
+ IAPEER_PEER_PERSONALITY: peer.personality,
326
+ IAPEER_PEER_CWD: peer.cwd,
327
+ }
328
+ // a bare string resolved relative to the plugin root when it points at a file there
329
+ const resolved = typeof setup === 'string' && existsSync(join(installPath, command)) ? join(installPath, command) : command
330
+ const r = spawnSync(resolved, preArgs, {
331
+ cwd: installPath,
332
+ encoding: 'utf8',
333
+ env: setupEnv as Record<string, string>,
334
+ })
335
+ if (r.error || (r.status ?? 1) !== 0) {
336
+ return { ok: false, detail: (r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim() }
337
+ }
338
+ return { ok: true }
339
+ }
340
+
341
+ /**
342
+ * Enable a capability plugin on a peer (contract Установка §3): install per-runtime
343
+ * (claude project-scope / codex global) + enable + call `setup` ONLY if the plugin
344
+ * declares it. Idempotent and fleet-safe — a peer already enabled is a no-op; the
345
+ * claude path is keyed by the peer's projectPath so it never touches another peer.
346
+ */
347
+ export function enableCapability(opts: EnableOptions): EnableResult {
348
+ const env = opts.env ?? process.env
349
+ const peer = resolvePeer(opts)
350
+ const targetRuntimes = opts.runtimes ?? peer.runtimes
351
+ const results: RuntimeEnableResult[] = []
352
+ for (const rt of targetRuntimes) {
353
+ results.push(rt === 'claude' ? enableClaude(opts.plugin, peer.cwd, env) : enableCodex(opts.plugin, env))
354
+ }
355
+
356
+ // setup: read the manifest from whichever runtime gave us an installPath (the source
357
+ // is the same plugin regardless of runtime). Called ONLY if declared.
358
+ const installPath = results.find(r => r.installPath)?.installPath
359
+ let setupState: SetupState = 'absent'
360
+ let setupDetail: string | undefined
361
+ const setup = readSetupDescriptor(installPath)
362
+ const anyOk = results.some(r => r.state === 'installed' || r.state === 'enabled' || r.state === 'already-enabled')
363
+ if (setup && anyOk) {
364
+ if (opts.noSetup) {
365
+ setupState = 'skipped'
366
+ } else {
367
+ const s = callSetup(setup, installPath as string, peer, env)
368
+ setupState = s.ok ? 'called' : 'failed'
369
+ setupDetail = s.detail
370
+ }
371
+ }
372
+
373
+ return {
374
+ plugin: opts.plugin,
375
+ personality: peer.personality,
376
+ cwd: peer.cwd,
377
+ runtimes: results,
378
+ setup: setupState,
379
+ setupDetail,
380
+ }
381
+ }
@@ -0,0 +1,262 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+ import {
6
+ ensurePeerProfile,
7
+ readPeerProfile,
8
+ writePeerProfileAtomic,
9
+ resolveCallerIdentity,
10
+ type PeerProfileWrite,
11
+ } from './index.ts'
12
+ import { peerProfilePath } from '../storage/index.ts'
13
+ import { isFoundationOwnedPlist, launchdPlistPath } from '../launch/index.ts'
14
+ import { defaultIntelligenceForRuntime } from '../core/constants.ts'
15
+ import type { PeersIndex } from '../registry/index.ts'
16
+
17
+ let cwd: string
18
+ beforeEach(() => {
19
+ cwd = mkdtempSync(join(tmpdir(), 'iapeer-identity-'))
20
+ })
21
+ afterEach(() => {
22
+ rmSync(cwd, { recursive: true, force: true })
23
+ })
24
+
25
+ function readDisk(): Record<string, unknown> {
26
+ return JSON.parse(readFileSync(peerProfilePath(cwd), 'utf8'))
27
+ }
28
+
29
+ // WITNESS: old writePeerProfileAtomic merged-builder (identity.ts:247-263) wrote
30
+ // `intelligence: profile.intelligence` UNCONDITIONALLY. Reproduce the merge so a
31
+ // call site that lost the existing intelligence (passing the runtime default)
32
+ // would clobber a human profile.
33
+ function oldMerged(existing: Record<string, unknown>, profile: {
34
+ personality: string; runtime: string; runtimes: string[]; description: string; intelligence: string
35
+ }): Record<string, unknown> {
36
+ return {
37
+ ...existing,
38
+ personality: profile.personality,
39
+ runtime: profile.runtime,
40
+ runtimes: profile.runtimes,
41
+ description: profile.description, // empty wipes
42
+ intelligence: profile.intelligence, // unconditional overwrite
43
+ }
44
+ }
45
+
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+ // writePeerProfileAtomic — H1 preserve + unknown-field preservation
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ describe('writePeerProfileAtomic H1 preserve', () => {
51
+ test('write WITHOUT intelligence does NOT downgrade a human profile on disk', () => {
52
+ // seed a human profile with a foreign (persistent-peer) section + interfaces
53
+ mkdirSync(join(cwd, '.iapeer'), { recursive: true })
54
+ writeFileSync(
55
+ peerProfilePath(cwd),
56
+ JSON.stringify({
57
+ personality: 'arthur',
58
+ runtime: 'telegram',
59
+ runtimes: ['telegram', 'claude'],
60
+ description: 'Артур — владелец.',
61
+ intelligence: 'human',
62
+ 'persistent-peer': { initial_prompt: 'hi', aliases: { '/new': 'x' } },
63
+ interfaces: { telegram: { user_id: '409502965' } },
64
+ }),
65
+ )
66
+
67
+ // a runtimes-only update built WITHOUT re-asserting intelligence
68
+ const update: PeerProfileWrite = {
69
+ personality: 'arthur',
70
+ runtime: 'claude',
71
+ runtimes: ['telegram', 'claude'],
72
+ }
73
+ writePeerProfileAtomic(cwd, update)
74
+
75
+ const disk = readDisk()
76
+ // H1 + NO-MIGRATION: the legacy on-disk value is preserved VERBATIM (not
77
+ // downgraded, and NOT silently rewritten to 'natural' — that migration is a
78
+ // separate coordinated step that must not happen on a routine write).
79
+ expect(disk.intelligence).toBe('human')
80
+ // READ-COMPAT: but reading it back normalizes legacy human → contract natural
81
+ expect(readPeerProfile(cwd)!.intelligence).toBe('natural')
82
+ // WITNESS: old builder fed the claude runtime-default would write artificial
83
+ expect(oldMerged(disk, {
84
+ personality: 'arthur', runtime: 'claude', runtimes: ['claude'],
85
+ description: '', intelligence: defaultIntelligenceForRuntime('claude'),
86
+ }).intelligence).toBe('artificial')
87
+ // unknown foreign section preserved
88
+ expect(disk['persistent-peer']).toEqual({ initial_prompt: 'hi', aliases: { '/new': 'x' } })
89
+ // interfaces preserved
90
+ expect(disk.interfaces).toEqual({ telegram: { user_id: '409502965' } })
91
+ // empty description not written over the existing one
92
+ expect(disk.description).toBe('Артур — владелец.')
93
+ })
94
+
95
+ test('explicit intelligence in write IS applied', () => {
96
+ writePeerProfileAtomic(cwd, {
97
+ personality: 'p', runtime: 'webhook', runtimes: ['webhook'], intelligence: 'absent',
98
+ })
99
+ expect(readDisk().intelligence).toBe('absent')
100
+ })
101
+
102
+ test('new profile (no disk) without intelligence → runtime default', () => {
103
+ writePeerProfileAtomic(cwd, { personality: 'p', runtime: 'telegram', runtimes: ['telegram'] })
104
+ expect(readDisk().intelligence).toBe('natural') // telegram default
105
+ })
106
+
107
+ test('round-trips through readPeerProfile preserving foreign fields', () => {
108
+ writePeerProfileAtomic(cwd, {
109
+ personality: 'p', runtime: 'claude', runtimes: ['claude'],
110
+ description: 'desc', intelligence: 'artificial',
111
+ })
112
+ // inject a foreign field, then a second write must keep it
113
+ const disk = readDisk()
114
+ disk['persistent-peer'] = { initial_prompt: 'keep me' }
115
+ writeFileSync(peerProfilePath(cwd), JSON.stringify(disk))
116
+ writePeerProfileAtomic(cwd, { personality: 'p', runtime: 'claude', runtimes: ['claude', 'codex'] })
117
+ const after = readDisk()
118
+ expect(after['persistent-peer']).toEqual({ initial_prompt: 'keep me' })
119
+ expect(after.intelligence).toBe('artificial') // still preserved
120
+ expect(after.runtimes).toEqual(['claude', 'codex'])
121
+ const parsed = readPeerProfile(cwd)!
122
+ expect(parsed.intelligence).toBe('artificial')
123
+ })
124
+ })
125
+
126
+ // ─────────────────────────────────────────────────────────────────────────────
127
+ // resolveCallerIdentity — per-request, NO process.cwd()
128
+ // ─────────────────────────────────────────────────────────────────────────────
129
+
130
+ const index: PeersIndex = {
131
+ version: 2,
132
+ peers: [
133
+ {
134
+ personality: 'arthur',
135
+ runtime: 'telegram',
136
+ runtimes: ['telegram', 'claude'],
137
+ description: 'Артур — владелец.',
138
+ intelligence: 'natural',
139
+ cwd: '/Users/macmini/Peers/arthur',
140
+ interfaces: { telegram: { user_id: '409502965' } },
141
+ },
142
+ {
143
+ personality: 'boris',
144
+ runtime: 'claude',
145
+ runtimes: ['claude'],
146
+ description: 'Напарник.',
147
+ intelligence: 'artificial',
148
+ cwd: '/Users/macmini/Peers/boris',
149
+ },
150
+ ],
151
+ }
152
+
153
+ describe('resolveCallerIdentity (per-request)', () => {
154
+ test('resolves caller from registry record, builds address', () => {
155
+ const r = resolveCallerIdentity({ personality: 'boris', runtime: 'claude' }, index)
156
+ expect(r.address).toBe('claude-boris')
157
+ expect(r.cwd).toBe('/Users/macmini/Peers/boris')
158
+ expect(r.intelligence).toBe('artificial')
159
+ })
160
+
161
+ test('multi-runtime peer resolves each declared runtime to a distinct address', () => {
162
+ expect(resolveCallerIdentity({ personality: 'arthur', runtime: 'telegram' }, index).address).toBe(
163
+ 'telegram-arthur',
164
+ )
165
+ expect(resolveCallerIdentity({ personality: 'arthur', runtime: 'claude' }, index).address).toBe(
166
+ 'claude-arthur',
167
+ )
168
+ })
169
+
170
+ test('does NOT depend on process.cwd() — chdir does not change the result', () => {
171
+ const before = resolveCallerIdentity({ personality: 'arthur', runtime: 'claude' }, index)
172
+ const original = process.cwd()
173
+ const elsewhere = mkdtempSync(join(tmpdir(), 'iapeer-chdir-'))
174
+ try {
175
+ process.chdir(elsewhere)
176
+ const after = resolveCallerIdentity({ personality: 'arthur', runtime: 'claude' }, index)
177
+ expect(after).toEqual(before)
178
+ expect(after.cwd).toBe('/Users/macmini/Peers/arthur') // from registry, not process.cwd()
179
+ } finally {
180
+ process.chdir(original)
181
+ rmSync(elsewhere, { recursive: true, force: true })
182
+ }
183
+ })
184
+
185
+ test('unknown caller → throws (spoofing guard)', () => {
186
+ expect(() => resolveCallerIdentity({ personality: 'ghost', runtime: 'claude' }, index)).toThrow(
187
+ /unknown caller/,
188
+ )
189
+ })
190
+
191
+ test('undeclared runtime for a known caller → throws', () => {
192
+ // boris declares only claude; codex must be rejected
193
+ expect(() => resolveCallerIdentity({ personality: 'boris', runtime: 'codex' }, index)).toThrow(
194
+ /not declared/,
195
+ )
196
+ })
197
+
198
+ test('invalid personality format → throws', () => {
199
+ expect(() => resolveCallerIdentity({ personality: 'Bad Name', runtime: 'claude' }, index)).toThrow()
200
+ })
201
+ })
202
+
203
+ // ─────────────────────────────────────────────────────────────────────────────
204
+ // ensurePeerProfile — create-peer path installs the always-on plist for INFRA
205
+ // runtimes (and ONLY those). Always under IAPEER_LAUNCHAGENTS_DIR so the suite
206
+ // never writes into the real ~/Library/LaunchAgents.
207
+ // ─────────────────────────────────────────────────────────────────────────────
208
+
209
+ describe('ensurePeerProfile create-peer → always-on plist (infra only)', () => {
210
+ let root: string
211
+ beforeEach(() => {
212
+ root = mkdtempSync(join(tmpdir(), 'iapeer-provision-'))
213
+ })
214
+ afterEach(() => {
215
+ rmSync(root, { recursive: true, force: true })
216
+ })
217
+ const laEnv = () =>
218
+ // IAPEER_ROOT isolates the now-GLOBAL infra log dir (~/.iapeer/logs/<p>, Фаза §8)
219
+ // under the sandbox — without it installAlwaysOnPlist's log mkdir resolves to the
220
+ // real home.
221
+ ({ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'), IAPEER_ROOT: join(root, 'iapeer') }) as NodeJS.ProcessEnv
222
+
223
+ test('provisioning a NEW infra (notifier) peer installs a foundation-owned plist', () => {
224
+ const env = laEnv()
225
+ const peerCwd = join(root, 'timer') // basename → personality "timer"
226
+ const profile = ensurePeerProfile({ cwd: peerCwd, env, runtime: 'notifier' })
227
+ expect(profile.personality).toBe('timer')
228
+ expect(profile.intelligence).toBe('absent') // notifier zone default
229
+
230
+ const plist = launchdPlistPath('timer', env)
231
+ expect(existsSync(plist)).toBe(true)
232
+ expect(isFoundationOwnedPlist(plist)).toBe(true)
233
+ // and the profile was written (full provision)
234
+ expect(existsSync(peerProfilePath(peerCwd))).toBe(true)
235
+ })
236
+
237
+ test('provisioning a NEW warm-on-demand (claude) peer installs NO plist (unchanged)', () => {
238
+ const env = laEnv()
239
+ const peerCwd = join(root, 'worker') // personality "worker"
240
+ ensurePeerProfile({ cwd: peerCwd, env, runtime: 'claude' })
241
+ expect(existsSync(launchdPlistPath('worker', env))).toBe(false)
242
+ expect(existsSync(peerProfilePath(peerCwd))).toBe(true) // profile still created
243
+ })
244
+
245
+ test('infra provision whose Label collides with a FOREIGN plist is refused — no profile written', () => {
246
+ const env = laEnv()
247
+ const laDir = join(root, 'LaunchAgents')
248
+ mkdirSync(laDir, { recursive: true })
249
+ // a live persistent-peer "boris" already owns com.iapeer.boris.plist (no sentinel)
250
+ const foreign = '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.iapeer.boris</string></dict></plist>\n'
251
+ const plist = launchdPlistPath('boris', env)
252
+ writeFileSync(plist, foreign)
253
+
254
+ const peerCwd = join(root, 'boris') // would derive personality "boris" → collision
255
+ expect(() => ensurePeerProfile({ cwd: peerCwd, env, runtime: 'notifier' })).toThrow(
256
+ /foundation-managed|refus/i,
257
+ )
258
+ // the live PP plist is untouched, and the half-provision left NO peer-profile.json
259
+ expect(readFileSync(plist, 'utf8')).toBe(foreign)
260
+ expect(existsSync(peerProfilePath(peerCwd))).toBe(false)
261
+ })
262
+ })