@agfpd/iapeer 0.2.77 → 0.2.79
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/LICENSE +202 -0
- package/README.md +2 -2
- package/package.json +21 -2
- package/src/cli/index.ts +95 -78
- package/src/connect/index.ts +4 -4
- package/src/core/constants.ts +3 -3
- package/src/core/normalize.ts +2 -2
- package/src/create/index.ts +1 -1
- package/src/daemon/index.ts +5 -5
- package/src/daemon/main.ts +9 -9
- package/src/enable/index.ts +19 -18
- package/src/enable/provisionCommand.ts +10 -10
- package/src/identity/index.ts +2 -2
- package/src/identity/profileStandard.ts +8 -8
- package/src/init/index.ts +10 -10
- package/src/install/index.ts +2 -2
- package/src/install/signing.ts +3 -4
- package/src/launch/adapters/claude.ts +7 -7
- package/src/launch/adapters/codex.ts +5 -6
- package/src/launch/canary.ts +8 -8
- package/src/launch/cmdlog.ts +3 -3
- package/src/launch/composeSystemPrompt.ts +2 -1
- package/src/launch/index.ts +5 -6
- package/src/launch/invocation.ts +1 -1
- package/src/launch/launchd.ts +14 -15
- package/src/launch/launchdRun.ts +6 -6
- package/src/launch/nativeMemory.ts +9 -9
- package/src/launch/ptyHost.ts +1 -1
- package/src/launch/readyGateModel.ts +3 -3
- package/src/launch/types.ts +7 -7
- package/src/lifecycle/eventlog.ts +2 -2
- package/src/lifecycle/index.ts +21 -21
- package/src/lifecycle/queue.ts +1 -1
- package/src/onboard/index.ts +19 -13
- package/src/onboard/memory.ts +5 -5
- package/src/onboard/steps.ts +8 -9
- package/src/provision/index.ts +2 -3
- package/src/runtime/deploy.ts +2 -2
- package/src/runtime/index.ts +4 -4
- package/src/shadow/gate.ts +6 -6
- package/src/shadow/index.ts +2 -2
- package/src/status/index.ts +11 -11
- package/src/storage/index.ts +1 -1
- package/src/supervisor/daemon.ts +20 -13
- package/src/transport/index.ts +11 -11
- package/src/update/index.ts +4 -4
- package/src/cli/cli.test.ts +0 -603
- package/src/cli/listTui.test.ts +0 -71
- package/src/codec/codec.test.ts +0 -271
- package/src/connect/connect.test.ts +0 -163
- package/src/core/constants.test.ts +0 -32
- package/src/core/normalize.test.ts +0 -98
- package/src/create/create.test.ts +0 -143
- package/src/daemon/daemon-http.test.ts +0 -114
- package/src/daemon/daemon.test.ts +0 -267
- package/src/daemon/main.test.ts +0 -413
- package/src/enable/enable.test.ts +0 -95
- package/src/enable/provisionCommand.test.ts +0 -107
- package/src/identity/identity.test.ts +0 -279
- package/src/identity/profileStandard.test.ts +0 -204
- package/src/init/init.test.ts +0 -215
- package/src/init/runtime-resolve.test.ts +0 -49
- package/src/install/install.test.ts +0 -66
- package/src/install/signing.test.ts +0 -84
- package/src/launch/bootdeliver.test.ts +0 -108
- package/src/launch/bootstrap.test.ts +0 -56
- package/src/launch/canary.test.ts +0 -346
- package/src/launch/cmdlog.test.ts +0 -94
- package/src/launch/codexHooksTrust.test.ts +0 -248
- package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
- package/src/launch/composeSystemPrompt.test.ts +0 -145
- package/src/launch/hostComposerOccupied.test.ts +0 -63
- package/src/launch/invocation.test.ts +0 -174
- package/src/launch/launch.test.ts +0 -359
- package/src/launch/launchd.test.ts +0 -499
- package/src/launch/nativeMemory.test.ts +0 -193
- package/src/launch/ptyHost.test.ts +0 -38
- package/src/launch/readyGateModel.test.ts +0 -72
- package/src/launch/sockdir.test.ts +0 -74
- package/src/lifecycle/eventlog.test.ts +0 -114
- package/src/lifecycle/lifecycle.test.ts +0 -1056
- package/src/lifecycle/queue.test.ts +0 -185
- package/src/onboard/memory.test.ts +0 -157
- package/src/onboard/onboard.test.ts +0 -39
- package/src/onboard/steps.test.ts +0 -201
- package/src/provision/provision.test.ts +0 -288
- package/src/registry/registry.test.ts +0 -432
- package/src/runtime/runtime.test.ts +0 -226
- package/src/runtime/update.test.ts +0 -192
- package/src/shadow/enumerate-skip.test.ts +0 -67
- package/src/shadow/gate.test.ts +0 -286
- package/src/shadow/install.test.ts +0 -153
- package/src/shadow/shadow.test.ts +0 -42
- package/src/status/status.test.ts +0 -208
- package/src/storage/peers-home.test.ts +0 -34
- package/src/storage/rotatelog.test.ts +0 -44
- package/src/storage/storage.test.ts +0 -65
- package/src/supervisor/attached-marker.test.ts +0 -84
- package/src/supervisor/boot.test.ts +0 -66
- package/src/supervisor/client.attach.test.ts +0 -107
- package/src/supervisor/daemon.test.ts +0 -213
- package/src/supervisor/deliver.test.ts +0 -58
- package/src/supervisor/panelog.test.ts +0 -95
- package/src/supervisor/protocol.test.ts +0 -222
- package/src/supervisor/selfargv.test.ts +0 -22
- package/src/supervisor/spinner-strip.test.ts +0 -52
- package/src/transport/compactDone.test.ts +0 -144
- package/src/transport/composerQueue.test.ts +0 -204
- package/src/transport/hostControl.test.ts +0 -87
- package/src/transport/hostDeliver.test.ts +0 -137
- package/src/transport/hostLive.test.ts +0 -50
- package/src/transport/routesend-wake.test.ts +0 -357
- package/src/update/update.test.ts +0 -208
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
// createPeer — cwd-independent peer creation: resolve a location (default
|
|
2
|
-
// ~/.iapeer/peers/<p> or --path), scaffold the folder (no-clobber), init it. All
|
|
3
|
-
// writes go under IAPEER_ROOT / IAPEER_LAUNCHAGENTS_DIR temp dirs; IAPEER_TEST_SANDBOX
|
|
4
|
-
// (set by the test script) makes launchctlBootstrap a no-op (skipped-sandbox), so the
|
|
5
|
-
// suite never loads a real launchd job.
|
|
6
|
-
|
|
7
|
-
import { afterEach, describe, expect, test } from 'bun:test'
|
|
8
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
9
|
-
import { tmpdir } from 'os'
|
|
10
|
-
import { join } from 'path'
|
|
11
|
-
import { createPeer } from './index.ts'
|
|
12
|
-
import { defaultPeerCwd, peerProfilePath } from '../storage/index.ts'
|
|
13
|
-
import { readPeerProfile, writePeerProfileAtomic } from '../identity/index.ts'
|
|
14
|
-
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
15
|
-
import { isFoundationOwnedPlist, launchdPlistPath } from '../launch/index.ts'
|
|
16
|
-
|
|
17
|
-
const roots: string[] = []
|
|
18
|
-
function mkTmp(): string {
|
|
19
|
-
const d = mkdtempSync(join(tmpdir(), 'iapeer-create-'))
|
|
20
|
-
roots.push(d)
|
|
21
|
-
return d
|
|
22
|
-
}
|
|
23
|
-
afterEach(() => {
|
|
24
|
-
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
function fakeBin(name: string): { dir: string; bin: string } {
|
|
28
|
-
const dir = mkTmp()
|
|
29
|
-
const bin = join(dir, name)
|
|
30
|
-
writeFileSync(bin, '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
|
|
31
|
-
return { dir, bin }
|
|
32
|
-
}
|
|
33
|
-
function envFor(root: string, path?: string): NodeJS.ProcessEnv {
|
|
34
|
-
return {
|
|
35
|
-
IAPEER_ROOT: join(root, 'iapeer'),
|
|
36
|
-
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
|
|
37
|
-
IAPEER_TEST_SANDBOX: '1', // never load a real launchd job from the suite
|
|
38
|
-
HOME: root,
|
|
39
|
-
...(path ? { PATH: path } : {}),
|
|
40
|
-
} as NodeJS.ProcessEnv
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe('createPeer — default location', () => {
|
|
44
|
-
test('claude peer lands at ~/.iapeer/peers/<p>, scaffolds folder + profile + registry + .mcp.json', async () => {
|
|
45
|
-
const root = mkTmp()
|
|
46
|
-
const env = envFor(root)
|
|
47
|
-
const r = await createPeer({ personality: 'worker', runtime: 'claude', env })
|
|
48
|
-
|
|
49
|
-
expect(r.location).toBe(defaultPeerCwd('worker', { env }))
|
|
50
|
-
expect(r.createdFolder).toBe(true)
|
|
51
|
-
expect(r.runtime).toBe('claude')
|
|
52
|
-
// folder + profile
|
|
53
|
-
expect(existsSync(peerProfilePath(r.location))).toBe(true)
|
|
54
|
-
expect(readPeerProfile(r.location)!.personality).toBe('worker')
|
|
55
|
-
// registry
|
|
56
|
-
expect(findPeer(readPeersIndex({ env }), 'worker')?.cwd).toBe(r.location)
|
|
57
|
-
// claude transport wiring
|
|
58
|
-
expect(r.mcpConfigPaths.length).toBe(1)
|
|
59
|
-
expect(existsSync(join(r.location, '.mcp.json'))).toBe(true)
|
|
60
|
-
// agentic → no plist, no bootstrap
|
|
61
|
-
expect(r.plistPath).toBeUndefined()
|
|
62
|
-
expect(r.bootstrapped).toBeUndefined()
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test('personality is normalized; default home derives from the normalized name', async () => {
|
|
66
|
-
const root = mkTmp()
|
|
67
|
-
const env = envFor(root)
|
|
68
|
-
const r = await createPeer({ personality: 'Maria', runtime: 'claude', env })
|
|
69
|
-
expect(r.personality).toBe('maria')
|
|
70
|
-
expect(r.location).toBe(defaultPeerCwd('maria', { env }))
|
|
71
|
-
})
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
describe('createPeer — infra (telegram human / notifier function): plist + auto-bootstrap', () => {
|
|
75
|
-
test('telegram peer: natural intelligence, foundation-owned plist, bootstrap skipped under sandbox', async () => {
|
|
76
|
-
const root = mkTmp()
|
|
77
|
-
const { dir: bindir, bin } = fakeBin('telegram-runtime')
|
|
78
|
-
const env = envFor(root, bindir)
|
|
79
|
-
|
|
80
|
-
const r = await createPeer({ personality: 'maria', runtime: 'telegram', env })
|
|
81
|
-
|
|
82
|
-
expect(r.runtime).toBe('telegram')
|
|
83
|
-
expect(r.intelligence).toBe('natural') // telegram zone default (human)
|
|
84
|
-
const plist = launchdPlistPath('maria', env)
|
|
85
|
-
expect(existsSync(plist)).toBe(true)
|
|
86
|
-
expect(isFoundationOwnedPlist(plist)).toBe(true)
|
|
87
|
-
expect(readFileSync(plist, 'utf8')).toContain(`<string>${bin}</string>`)
|
|
88
|
-
// AUTO-bootstrap attempted, but the sandbox guard makes it a no-op
|
|
89
|
-
expect(r.bootstrapped?.state).toBe('skipped-sandbox')
|
|
90
|
-
// router runtime → no MCP client config
|
|
91
|
-
expect(r.mcpConfigPaths.length).toBe(0)
|
|
92
|
-
expect(r.codexMcpConfigPath).toBeUndefined()
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
test('two telegram humans → two distinct plists (1 always-on peer = 1 plist)', async () => {
|
|
96
|
-
const root = mkTmp()
|
|
97
|
-
const { dir: bindir } = fakeBin('telegram-runtime')
|
|
98
|
-
const env = envFor(root, bindir)
|
|
99
|
-
|
|
100
|
-
await createPeer({ personality: 'maria', runtime: 'telegram', env })
|
|
101
|
-
await createPeer({ personality: 'pavel', runtime: 'telegram', env })
|
|
102
|
-
|
|
103
|
-
expect(existsSync(launchdPlistPath('maria', env))).toBe(true)
|
|
104
|
-
expect(existsSync(launchdPlistPath('pavel', env))).toBe(true)
|
|
105
|
-
expect(findPeer(readPeersIndex({ env }), 'maria')).not.toBeNull()
|
|
106
|
-
expect(findPeer(readPeersIndex({ env }), 'pavel')).not.toBeNull()
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
test('re-create of the same infra peer is idempotent (no duplicate, no clobber)', async () => {
|
|
110
|
-
const root = mkTmp()
|
|
111
|
-
const { dir: bindir } = fakeBin('telegram-runtime')
|
|
112
|
-
const env = envFor(root, bindir)
|
|
113
|
-
|
|
114
|
-
await createPeer({ personality: 'maria', runtime: 'telegram', env })
|
|
115
|
-
const r2 = await createPeer({ personality: 'maria', runtime: 'telegram', env })
|
|
116
|
-
|
|
117
|
-
expect(r2.createdFolder).toBe(false)
|
|
118
|
-
expect(readPeersIndex({ env }).peers.filter(p => p.personality === 'maria').length).toBe(1)
|
|
119
|
-
})
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
describe('createPeer — location resolution + no-clobber', () => {
|
|
123
|
-
test('--path overrides the default home', async () => {
|
|
124
|
-
const root = mkTmp()
|
|
125
|
-
const env = envFor(root)
|
|
126
|
-
const custom = join(mkTmp(), 'custom-peer')
|
|
127
|
-
const r = await createPeer({ personality: 'worker', runtime: 'claude', path: custom, env })
|
|
128
|
-
expect(r.location).toBe(custom)
|
|
129
|
-
expect(existsSync(peerProfilePath(custom))).toBe(true)
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
test('refuses to create into a folder already holding a DIFFERENT peer', async () => {
|
|
133
|
-
const root = mkTmp()
|
|
134
|
-
const env = envFor(root)
|
|
135
|
-
const dir = join(mkTmp(), 'occupied')
|
|
136
|
-
// Seed a different peer's profile in the target.
|
|
137
|
-
writePeerProfileAtomic(dir, { personality: 'boris', runtime: 'claude', runtimes: ['claude'] })
|
|
138
|
-
|
|
139
|
-
await expect(createPeer({ personality: 'maria', runtime: 'claude', path: dir, env })).rejects.toThrow(
|
|
140
|
-
/already holds peer "boris"/,
|
|
141
|
-
)
|
|
142
|
-
})
|
|
143
|
-
})
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
// HTTP integration test: the daemon driven by a REAL MCP client
|
|
2
|
-
// (@modelcontextprotocol/sdk Client + StreamableHTTPClientTransport) over a TCP
|
|
3
|
-
// loopback listener. Because both ends use the canonical SDK transport, this is
|
|
4
|
-
// the on-wire equivalent of a real claude/codex http MCP client connecting — it
|
|
5
|
-
// is what makes the hand-rolled-handshake H2 risk moot.
|
|
6
|
-
//
|
|
7
|
-
// No delivery to a live peer here (boris/arthur are only used as CALLERS; the
|
|
8
|
-
// only send_to_peer target is the offline fixture peer), so the test has no side
|
|
9
|
-
// effects on the live fleet.
|
|
10
|
-
|
|
11
|
-
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
12
|
-
import { mkdtempSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
13
|
-
import { tmpdir } from 'os'
|
|
14
|
-
import { join } from 'path'
|
|
15
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
16
|
-
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
17
|
-
import { CALLER_HEADER, startDaemon, type DaemonHandle } from './index.ts'
|
|
18
|
-
|
|
19
|
-
let root: string
|
|
20
|
-
let daemon: DaemonHandle
|
|
21
|
-
const prevRoot = process.env.IAPEER_ROOT
|
|
22
|
-
|
|
23
|
-
const FIXTURE = {
|
|
24
|
-
version: 2,
|
|
25
|
-
peers: [
|
|
26
|
-
{ personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: 'Напарник', intelligence: 'artificial', cwd: '/tmp/boris' },
|
|
27
|
-
{ personality: 'arthur', runtime: 'telegram', runtimes: ['telegram', 'claude'], description: 'Артур', intelligence: 'human', cwd: '/tmp/arthur' },
|
|
28
|
-
{ personality: 'offlinepeer', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/offlinepeer' },
|
|
29
|
-
],
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
beforeAll(async () => {
|
|
33
|
-
root = mkdtempSync(join(tmpdir(), 'iapeer-http-'))
|
|
34
|
-
writeFileSync(join(root, 'peers-profiles.json'), JSON.stringify(FIXTURE))
|
|
35
|
-
process.env.IAPEER_ROOT = root
|
|
36
|
-
daemon = await startDaemon({ port: 0, host: '127.0.0.1' }) // TCP loopback for real http MCP clients
|
|
37
|
-
})
|
|
38
|
-
afterAll(async () => {
|
|
39
|
-
await daemon.close()
|
|
40
|
-
if (prevRoot === undefined) delete process.env.IAPEER_ROOT
|
|
41
|
-
else process.env.IAPEER_ROOT = prevRoot
|
|
42
|
-
rmSync(root, { recursive: true, force: true })
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
async function connect(identity?: string): Promise<Client> {
|
|
46
|
-
const headers: Record<string, string> = {}
|
|
47
|
-
if (identity) headers['X-IAPeer-Identity'] = identity
|
|
48
|
-
const transport = new StreamableHTTPClientTransport(new URL(daemon.url!), { requestInit: { headers } })
|
|
49
|
-
const client = new Client({ name: 'iapeer-test-client', version: '0.0.0' })
|
|
50
|
-
await client.connect(transport)
|
|
51
|
-
return client
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
describe('daemon over SDK StreamableHTTP (real MCP client)', () => {
|
|
55
|
-
test('a real SDK client completes the initialize handshake and lists the tool-set', async () => {
|
|
56
|
-
const client = await connect('claude-boris')
|
|
57
|
-
const { tools } = await client.listTools()
|
|
58
|
-
// ONLY send_to_peer — list_online_peers is deprecated by contract (no extra
|
|
59
|
-
// agent-facing tool; liveness is the CLI `list` verb).
|
|
60
|
-
expect(tools.map(t => t.name).sort()).toEqual(['send_to_peer'])
|
|
61
|
-
await client.close()
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
test('PER-REQUEST identity: the header decides the caller, not a per-process default', async () => {
|
|
65
|
-
// Caller resolution runs BEFORE tool dispatch, so send_to_peer exercises it.
|
|
66
|
-
// A KNOWN caller (boris) is accepted → routing proceeds and fails with the
|
|
67
|
-
// OFFLINE error (not an identity rejection): the target offlinepeer is dead.
|
|
68
|
-
const ok = await connect('claude-boris')
|
|
69
|
-
const okRes = await ok.callTool({ name: 'send_to_peer', arguments: { personality: 'offlinepeer', message: 'hi' } })
|
|
70
|
-
expect(okRes.isError).toBe(true)
|
|
71
|
-
expect((okRes.content as any)[0].text).not.toMatch(/unknown caller/)
|
|
72
|
-
expect((okRes.content as any)[0].text).toMatch(/offline/)
|
|
73
|
-
await ok.close()
|
|
74
|
-
|
|
75
|
-
// a different client with an UNKNOWN caller header → rejected at identity
|
|
76
|
-
const ghost = await connect('claude-ghost')
|
|
77
|
-
const ghostRes = await ghost.callTool({ name: 'send_to_peer', arguments: { personality: 'offlinepeer', message: 'hi' } })
|
|
78
|
-
expect(ghostRes.isError).toBe(true)
|
|
79
|
-
expect((ghostRes.content as any)[0].text).toMatch(/unknown caller/)
|
|
80
|
-
await ghost.close()
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
test('CallTool WITHOUT identity header → rejected (no silent default)', async () => {
|
|
84
|
-
const client = await connect(undefined)
|
|
85
|
-
const res = await client.callTool({ name: 'send_to_peer', arguments: { personality: 'offlinepeer', message: 'hi' } })
|
|
86
|
-
expect(res.isError).toBe(true)
|
|
87
|
-
expect((res.content as any)[0].text).toMatch(new RegExp(CALLER_HEADER))
|
|
88
|
-
await client.close()
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
test('send_to_peer to an offline fixture peer → explicit offline error (no wake in Ф1)', async () => {
|
|
92
|
-
const client = await connect('claude-boris')
|
|
93
|
-
const res = await client.callTool({
|
|
94
|
-
name: 'send_to_peer',
|
|
95
|
-
arguments: { personality: 'offlinepeer', message: 'hi' },
|
|
96
|
-
})
|
|
97
|
-
expect(res.isError).toBe(true)
|
|
98
|
-
expect((res.content as any)[0].text).toMatch(/offline/)
|
|
99
|
-
await client.close()
|
|
100
|
-
})
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
describe('daemon over unix socket (H8 base)', () => {
|
|
104
|
-
test('binds a 0600 unix socket', async () => {
|
|
105
|
-
const sockRoot = mkdtempSync(join(tmpdir(), 'iapeer-sock-'))
|
|
106
|
-
const h = await startDaemon({ socketPath: join(sockRoot, 'router.sock') })
|
|
107
|
-
try {
|
|
108
|
-
expect((statSync(h.socketPath!).mode & 0o777).toString(8)).toBe('600')
|
|
109
|
-
} finally {
|
|
110
|
-
await h.close()
|
|
111
|
-
rmSync(sockRoot, { recursive: true, force: true })
|
|
112
|
-
}
|
|
113
|
-
})
|
|
114
|
-
})
|
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
3
|
-
import { tmpdir } from 'os'
|
|
4
|
-
import { join } from 'path'
|
|
5
|
-
import {
|
|
6
|
-
CALLER_HEADER,
|
|
7
|
-
callTool,
|
|
8
|
-
listTools,
|
|
9
|
-
parseCallerHeader,
|
|
10
|
-
resolveCallerFromHeader,
|
|
11
|
-
} from './index.ts'
|
|
12
|
-
import { readPeersIndex } from '../registry/index.ts'
|
|
13
|
-
|
|
14
|
-
// Point the registry at a fixture root via IAPEER_ROOT so these unit tests are
|
|
15
|
-
// deterministic and never touch the live host registry.
|
|
16
|
-
let root: string
|
|
17
|
-
const prevRoot = process.env.IAPEER_ROOT
|
|
18
|
-
|
|
19
|
-
const FIXTURE = {
|
|
20
|
-
version: 2,
|
|
21
|
-
peers: [
|
|
22
|
-
{ personality: 'arthur', runtime: 'telegram', runtimes: ['telegram', 'claude'], description: 'Артур', intelligence: 'human', cwd: '/Users/macmini/Peers/arthur', interfaces: { telegram: { user_id: '409502965' } } },
|
|
23
|
-
{ personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: 'Напарник', intelligence: 'artificial', cwd: '/Users/macmini/Peers/boris' },
|
|
24
|
-
{ personality: 'offlinepeer', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/offlinepeer' },
|
|
25
|
-
],
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
beforeAll(() => {
|
|
29
|
-
root = mkdtempSync(join(tmpdir(), 'iapeer-daemon-'))
|
|
30
|
-
writeFileSync(join(root, 'peers-profiles.json'), JSON.stringify(FIXTURE))
|
|
31
|
-
process.env.IAPEER_ROOT = root
|
|
32
|
-
})
|
|
33
|
-
afterAll(() => {
|
|
34
|
-
if (prevRoot === undefined) delete process.env.IAPEER_ROOT
|
|
35
|
-
else process.env.IAPEER_ROOT = prevRoot
|
|
36
|
-
rmSync(root, { recursive: true, force: true })
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
describe('caller identity from header', () => {
|
|
40
|
-
test('parses <runtime>-<personality>', () => {
|
|
41
|
-
expect(parseCallerHeader('claude-boris')).toEqual({ personality: 'boris', runtime: 'claude' })
|
|
42
|
-
expect(parseCallerHeader('telegram-arthur')).toEqual({ personality: 'arthur', runtime: 'telegram' })
|
|
43
|
-
expect(parseCallerHeader('claude-company-checker')).toEqual({ personality: 'company-checker', runtime: 'claude' })
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('rejects empty / malformed header', () => {
|
|
47
|
-
expect(parseCallerHeader(undefined)).toBeNull()
|
|
48
|
-
expect(parseCallerHeader('')).toBeNull()
|
|
49
|
-
expect(parseCallerHeader('noseparator')).toBeNull()
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
test('PER-REQUEST: two different headers resolve to two different callers', () => {
|
|
53
|
-
const index = readPeersIndex()
|
|
54
|
-
const a = resolveCallerFromHeader('claude-boris', index)
|
|
55
|
-
const b = resolveCallerFromHeader('telegram-arthur', index)
|
|
56
|
-
expect(a.address).toBe('claude-boris')
|
|
57
|
-
expect(b.address).toBe('telegram-arthur')
|
|
58
|
-
// read-compat carried through: arthur's legacy 'human' resolves to 'natural'
|
|
59
|
-
expect(b.intelligence).toBe('natural')
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
test('missing header → throws (mentions the header name)', () => {
|
|
63
|
-
expect(() => resolveCallerFromHeader(undefined, readPeersIndex())).toThrow(new RegExp(CALLER_HEADER))
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
test('unknown caller → throws (spoofing guard)', () => {
|
|
67
|
-
expect(() => resolveCallerFromHeader('claude-ghost', readPeersIndex())).toThrow(/unknown caller/)
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
test('undeclared runtime for a known caller → throws', () => {
|
|
71
|
-
expect(() => resolveCallerFromHeader('codex-boris', readPeersIndex())).toThrow(/not declared/)
|
|
72
|
-
})
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
describe('listTools', () => {
|
|
76
|
-
test('serves ONLY send_to_peer (list_online_peers deprecated) with alwaysLoad meta and NO embedded roster', () => {
|
|
77
|
-
const tools = listTools() as any[]
|
|
78
|
-
expect(tools.map(t => t.name).sort()).toEqual(['send_to_peer'])
|
|
79
|
-
expect(tools.every(t => t._meta['anthropic/alwaysLoad'] === true)).toBe(true)
|
|
80
|
-
const sendTool = tools.find(t => t.name === 'send_to_peer')
|
|
81
|
-
// Regression guard: the peer roster lives ONLY in the system prompt
|
|
82
|
-
// (composeSystemPrompt → "## Known peers"), NEVER in the tool description —
|
|
83
|
-
// embedding it here duplicated the list into every agent's context per turn.
|
|
84
|
-
expect(sendTool.description).not.toMatch(/Known peers/)
|
|
85
|
-
expect(sendTool.description).not.toContain('arthur')
|
|
86
|
-
expect(sendTool.description).not.toMatch(/Runtimes:/)
|
|
87
|
-
// Purpose + parameter semantics stay.
|
|
88
|
-
expect(sendTool.description).toContain('Send a message to a known iapeer peer')
|
|
89
|
-
expect(sendTool.description).toContain('wakes it and delivers there')
|
|
90
|
-
expect(sendTool.description).toContain('parallel to the default runtime')
|
|
91
|
-
expect(sendTool.inputSchema.properties.runtime.description).toContain('wakes it warm-on-demand')
|
|
92
|
-
expect(sendTool.inputSchema.properties.runtime.description).toContain('parallel session')
|
|
93
|
-
})
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
describe('callTool (no wake passed → Ф1 offline behaviour, never spawns)', () => {
|
|
97
|
-
test('send_to_peer to an OFFLINE peer → explicit peer-offline error', async () => {
|
|
98
|
-
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
99
|
-
const r = await callTool(caller, 'send_to_peer', { personality: 'offlinepeer', message: 'hi' })
|
|
100
|
-
expect(r.isError).toBe(true)
|
|
101
|
-
expect(r.content[0].text).toMatch(/offline/)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
test('send_to_peer to a peer NOT in the registry → not-delivered error', async () => {
|
|
105
|
-
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
106
|
-
const r = await callTool(caller, 'send_to_peer', { personality: 'nobody', message: 'hi' })
|
|
107
|
-
expect(r.isError).toBe(true)
|
|
108
|
-
expect(r.content[0].text).toMatch(/not in the iapeer peers index/)
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
test('unknown tool → error', async () => {
|
|
112
|
-
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
113
|
-
expect((await callTool(caller, 'bogus', {})).isError).toBe(true)
|
|
114
|
-
})
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
describe('per-delivery outcome log (Ф-#8a — delivery.log)', () => {
|
|
118
|
-
test('with deliveryLogDir wired: ONE logfmt outcome line per attempt, metadata only', async () => {
|
|
119
|
-
const logDir = join(root, 'logs', 'iapeer')
|
|
120
|
-
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
121
|
-
await callTool(caller, 'send_to_peer', { personality: 'offlinepeer', message: 'hi' }, { deliveryLogDir: logDir })
|
|
122
|
-
await callTool(
|
|
123
|
-
caller,
|
|
124
|
-
'send_to_peer',
|
|
125
|
-
{ personality: 'nobody', message: 'hello there', topic: 'probe topic' },
|
|
126
|
-
{ deliveryLogDir: logDir },
|
|
127
|
-
)
|
|
128
|
-
const lines = readFileSync(join(logDir, 'delivery.log'), 'utf8').trim().split('\n')
|
|
129
|
-
expect(lines.length).toBe(2)
|
|
130
|
-
// attempt 1: known-but-offline peer (no wake wired → Ф1 explicit offline)
|
|
131
|
-
expect(lines[0]).toContain('ev=delivery')
|
|
132
|
-
expect(lines[0]).toContain('caller=claude-boris')
|
|
133
|
-
expect(lines[0]).toContain('to=offlinepeer')
|
|
134
|
-
expect(lines[0]).toContain('ok=false')
|
|
135
|
-
expect(lines[0]).toMatch(/err=".*offline.*"/)
|
|
136
|
-
expect(lines[0]).toContain('len=2') // body length, NEVER the body itself
|
|
137
|
-
expect(lines[0]).not.toContain('hi"') // the message text must not leak
|
|
138
|
-
// attempt 2: unknown peer — validation failures are recorded too
|
|
139
|
-
expect(lines[1]).toContain('to=nobody')
|
|
140
|
-
expect(lines[1]).toContain('ok=false')
|
|
141
|
-
expect(lines[1]).toContain('topic="probe topic"')
|
|
142
|
-
expect(lines[1]).toContain('len=11')
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
test('without deliveryLogDir (default): nothing is written — library/test daemons stay hermetic', async () => {
|
|
146
|
-
const probeDir = join(root, 'logs', 'unwired')
|
|
147
|
-
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
148
|
-
await callTool(caller, 'send_to_peer', { personality: 'offlinepeer', message: 'hi' })
|
|
149
|
-
expect(existsSync(join(probeDir, 'delivery.log'))).toBe(false)
|
|
150
|
-
})
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
describe('onDelivered hook (M2 arm-on-outbound seam)', () => {
|
|
154
|
-
test('NOT fired on a FAILED delivery — a worker whose reply did not land must not be armed', async () => {
|
|
155
|
-
const fired: string[] = []
|
|
156
|
-
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
157
|
-
// offline target, no wake → routeSend fails → hook must stay silent
|
|
158
|
-
const r = await callTool(
|
|
159
|
-
caller,
|
|
160
|
-
'send_to_peer',
|
|
161
|
-
{ personality: 'offlinepeer', message: 'hi' },
|
|
162
|
-
{ onDelivered: c => fired.push(c.address) },
|
|
163
|
-
)
|
|
164
|
-
expect(r.isError).toBe(true)
|
|
165
|
-
expect(fired).toEqual([])
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
test('a throwing hook never fails the tool call (fail-safe)', async () => {
|
|
169
|
-
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
170
|
-
// even on the error path nothing throws; the ok-path guard is the same try/catch
|
|
171
|
-
const r = await callTool(
|
|
172
|
-
caller,
|
|
173
|
-
'send_to_peer',
|
|
174
|
-
{ personality: 'offlinepeer', message: 'hi' },
|
|
175
|
-
{
|
|
176
|
-
onDelivered: () => {
|
|
177
|
-
throw new Error('hook boom')
|
|
178
|
-
},
|
|
179
|
-
},
|
|
180
|
-
)
|
|
181
|
-
expect(r.isError).toBe(true) // the routeSend offline error, NOT the hook's
|
|
182
|
-
expect(r.content[0].text).toMatch(/offline/)
|
|
183
|
-
})
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
describe('ephemeral serial-queue seam (M3)', () => {
|
|
187
|
-
test('ephemeral TARGET → deliver() handles it (no live/miss routing); queued result + delivery.log queued/qd', async () => {
|
|
188
|
-
const logDir = join(root, 'logs', 'm3')
|
|
189
|
-
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
190
|
-
const delivered: string[] = []
|
|
191
|
-
const r = await callTool(
|
|
192
|
-
caller,
|
|
193
|
-
'send_to_peer',
|
|
194
|
-
{ personality: 'offlinepeer', message: 'job for the worker', topic: 'job-1' },
|
|
195
|
-
{
|
|
196
|
-
deliveryLogDir: logDir,
|
|
197
|
-
ephemeral: {
|
|
198
|
-
isEphemeral: cwd => {
|
|
199
|
-
delivered.push(`checked:${cwd}`)
|
|
200
|
-
return true // offlinepeer plays an ephemeral worker
|
|
201
|
-
},
|
|
202
|
-
deliver: async ({ peer, envelope, topic }) => {
|
|
203
|
-
delivered.push(`deliver:${peer.personality}:${topic}`)
|
|
204
|
-
expect(envelope).toContain('job for the worker') // the routed envelope, not the raw body
|
|
205
|
-
return {
|
|
206
|
-
ok: true,
|
|
207
|
-
value: {
|
|
208
|
-
ok: true as const,
|
|
209
|
-
delivered_to: { personality: peer.personality, runtime: 'claude' },
|
|
210
|
-
woke: false,
|
|
211
|
-
queued: true,
|
|
212
|
-
queuedBy: 'ephemeral',
|
|
213
|
-
queueDepth: 2,
|
|
214
|
-
ts: 'now',
|
|
215
|
-
},
|
|
216
|
-
}
|
|
217
|
-
},
|
|
218
|
-
},
|
|
219
|
-
},
|
|
220
|
-
)
|
|
221
|
-
expect(r.isError).toBeUndefined()
|
|
222
|
-
expect(delivered).toEqual(['checked:/tmp/offlinepeer', 'deliver:offlinepeer:job-1'])
|
|
223
|
-
const line = readFileSync(join(logDir, 'delivery.log'), 'utf8').trim()
|
|
224
|
-
expect(line).toContain('queued=true')
|
|
225
|
-
expect(line).toContain('qkind=ephemeral')
|
|
226
|
-
expect(line).toContain('qd=2')
|
|
227
|
-
expect(line).toContain('ok=true')
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
test('ephemeral self-send is refused up front (a worker enqueueing itself would deadlock its reap)', async () => {
|
|
231
|
-
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
232
|
-
const r = await callTool(
|
|
233
|
-
caller,
|
|
234
|
-
'send_to_peer',
|
|
235
|
-
{ personality: 'boris', message: 'to myself' },
|
|
236
|
-
{
|
|
237
|
-
ephemeral: {
|
|
238
|
-
isEphemeral: () => true,
|
|
239
|
-
deliver: async () => {
|
|
240
|
-
throw new Error('must not be reached')
|
|
241
|
-
},
|
|
242
|
-
},
|
|
243
|
-
},
|
|
244
|
-
)
|
|
245
|
-
expect(r.isError).toBe(true)
|
|
246
|
-
expect(r.content[0].text).toMatch(/cannot send to self/)
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
test('non-ephemeral target ignores the seam (normal offline path)', async () => {
|
|
250
|
-
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
251
|
-
const r = await callTool(
|
|
252
|
-
caller,
|
|
253
|
-
'send_to_peer',
|
|
254
|
-
{ personality: 'offlinepeer', message: 'hi' },
|
|
255
|
-
{
|
|
256
|
-
ephemeral: {
|
|
257
|
-
isEphemeral: () => false,
|
|
258
|
-
deliver: async () => {
|
|
259
|
-
throw new Error('must not be reached')
|
|
260
|
-
},
|
|
261
|
-
},
|
|
262
|
-
},
|
|
263
|
-
)
|
|
264
|
-
expect(r.isError).toBe(true)
|
|
265
|
-
expect(r.content[0].text).toMatch(/offline/) // took the ordinary miss path
|
|
266
|
-
})
|
|
267
|
-
})
|