@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.
- package/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Socket-path convention. Consolidated from inter-agent-protocol/src/lib/socket-parser.ts (wins).
|
|
2
|
+
// One builder/parser for /tmp/tmux-iap-<runtime>-<personality>.sock and <runtime>-<personality>.
|
|
3
|
+
|
|
4
|
+
import { basename, join } from 'path'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_SOCK_DIR,
|
|
7
|
+
NAME_RE_SOURCE,
|
|
8
|
+
RUNTIME_RE_SOURCE,
|
|
9
|
+
isTmuxRuntime,
|
|
10
|
+
isValidName,
|
|
11
|
+
type TmuxRuntime,
|
|
12
|
+
} from './constants.ts'
|
|
13
|
+
|
|
14
|
+
export interface ProcessAddress {
|
|
15
|
+
runtime: TmuxRuntime
|
|
16
|
+
personality: string
|
|
17
|
+
address: `${TmuxRuntime}-${string}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const socketNameRe = new RegExp(
|
|
21
|
+
`^tmux-iap-(${RUNTIME_RE_SOURCE.slice(1, -1)})-(${NAME_RE_SOURCE.slice(1, -1)})\\.sock$`,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export function parseSessionName(value: string): ProcessAddress | null {
|
|
25
|
+
const split = value.indexOf('-')
|
|
26
|
+
if (split <= 0) return null
|
|
27
|
+
const runtime = value.slice(0, split)
|
|
28
|
+
const personality = value.slice(split + 1)
|
|
29
|
+
if (!isTmuxRuntime(runtime) || !isValidName(personality)) return null
|
|
30
|
+
return {
|
|
31
|
+
runtime,
|
|
32
|
+
personality,
|
|
33
|
+
address: value as `${TmuxRuntime}-${string}`,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseSocketPath(path: string): ProcessAddress | null {
|
|
38
|
+
const m = basename(path).match(socketNameRe)
|
|
39
|
+
if (!m) return null
|
|
40
|
+
const runtime = m[1]
|
|
41
|
+
const personality = m[2]
|
|
42
|
+
if (!isTmuxRuntime(runtime) || !isValidName(personality)) return null
|
|
43
|
+
return {
|
|
44
|
+
runtime,
|
|
45
|
+
personality,
|
|
46
|
+
address: `${runtime}-${personality}` as const,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildProcessAddress(
|
|
51
|
+
runtime: TmuxRuntime,
|
|
52
|
+
personality: string,
|
|
53
|
+
): `${TmuxRuntime}-${string}` {
|
|
54
|
+
return `${runtime}-${personality}` as const
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildSocketPath(
|
|
58
|
+
runtime: TmuxRuntime,
|
|
59
|
+
personality: string,
|
|
60
|
+
sockDir = DEFAULT_SOCK_DIR,
|
|
61
|
+
): string {
|
|
62
|
+
return join(sockDir, `tmux-iap-${runtime}-${personality}.sock`)
|
|
63
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// create — the cwd-INDEPENDENT peer-creation verb (`iapeer create <personality>
|
|
2
|
+
// [--runtime] [--path]`, contract Фаза §1 / Примитивы). Where `init` provisions the
|
|
3
|
+
// CURRENT folder (cwd-dependent, for an implementer working IN a repo), `create`
|
|
4
|
+
// RESOLVES a location for a brand-new peer, scaffolds the folder, then inits it —
|
|
5
|
+
// from anywhere, no `cd` required.
|
|
6
|
+
//
|
|
7
|
+
// LOCATION: --path wins; otherwise the foundation-owned default home
|
|
8
|
+
// ~/.iapeer/peers/<personality> (collision-free, unlike the organic ~/Peers the
|
|
9
|
+
// legacy fleet grew in — existing ~/Peers/* peers are grandfathered, NOT migrated).
|
|
10
|
+
//
|
|
11
|
+
// NO-CLOBBER: making the folder is mkdir-recursive (never deletes); init is
|
|
12
|
+
// idempotent (profile kept, .mcp.json merged, doctrine never overwritten). The one
|
|
13
|
+
// hard refusal: a target that already holds a DIFFERENT peer's profile — creating
|
|
14
|
+
// "maria" into a folder that is already "boris" would either silently adopt boris or
|
|
15
|
+
// split an identity, so it fails loudly instead.
|
|
16
|
+
//
|
|
17
|
+
// INFRA (telegram human via operator-add / notifier function via a declared set):
|
|
18
|
+
// runtime=telegram|notifier → provision installs the always-on plist and (default)
|
|
19
|
+
// AUTO-bootstraps it. "1 always-on infra peer = 1 plist", idempotent (re-create of
|
|
20
|
+
// the same peer does not duplicate or clobber — installAlwaysOnPlist's sentinel guard
|
|
21
|
+
// allows re-writing only OUR own plist).
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync } from 'fs'
|
|
24
|
+
import { resolve } from 'path'
|
|
25
|
+
import { isValidName, normalizeNameCandidate, type Intelligence, type Runtime } from '../core/constants.ts'
|
|
26
|
+
import { IapError } from '../core/errors.ts'
|
|
27
|
+
import { defaultPeerCwd, ensureGlobalIapScaffold } from '../storage/index.ts'
|
|
28
|
+
import { readPeerProfile } from '../identity/index.ts'
|
|
29
|
+
import { initPeer, type InitPeerResult } from '../init/index.ts'
|
|
30
|
+
|
|
31
|
+
export interface CreatePeerOptions {
|
|
32
|
+
/** The peer's personality (REQUIRED; validated/normalized). Drives the default
|
|
33
|
+
* location (~/.iapeer/peers/<personality>) and the registry/profile identity. */
|
|
34
|
+
personality: string
|
|
35
|
+
/** Primary runtime (default: claude). claude/codex → agentic; telegram/notifier →
|
|
36
|
+
* infra (always-on plist + auto-bootstrap). */
|
|
37
|
+
runtime?: Runtime
|
|
38
|
+
/** Explicit location. Default: ~/.iapeer/peers/<personality> (IAPEER_ROOT-aware). */
|
|
39
|
+
path?: string
|
|
40
|
+
description?: string
|
|
41
|
+
intelligence?: Intelligence
|
|
42
|
+
/** Infra runtime launcher (abs path / PATH name) baked into the always-on plist. */
|
|
43
|
+
runtimeBin?: string
|
|
44
|
+
/** AUTO-bootstrap a freshly-provisioned infra plist (default true; infra only). */
|
|
45
|
+
bootstrap?: boolean
|
|
46
|
+
env?: NodeJS.ProcessEnv
|
|
47
|
+
warn?: (message: string) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CreatePeerResult extends InitPeerResult {
|
|
51
|
+
/** The resolved peer cwd (default home or --path). */
|
|
52
|
+
location: string
|
|
53
|
+
/** True when this run created the folder (false when it already existed). */
|
|
54
|
+
createdFolder: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a peer from anywhere: resolve a location, scaffold the folder (no-clobber),
|
|
59
|
+
* then run init in it (identity + registry + per-runtime MCP / infra plist + doctrine,
|
|
60
|
+
* with auto-bootstrap for infra). Returns the init result plus the resolved location.
|
|
61
|
+
*/
|
|
62
|
+
export async function createPeer(opts: CreatePeerOptions): Promise<CreatePeerResult> {
|
|
63
|
+
const env = opts.env ?? process.env
|
|
64
|
+
const personality = normalizeNameCandidate(opts.personality)
|
|
65
|
+
if (!isValidName(personality)) {
|
|
66
|
+
throw new IapError(
|
|
67
|
+
`invalid personality "${opts.personality}" — must normalize to /^[a-z][a-z0-9-]{0,31}$/`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Ensure the global tree (incl. ~/.iapeer/peers/) exists before landing a peer there.
|
|
72
|
+
ensureGlobalIapScaffold({ env })
|
|
73
|
+
|
|
74
|
+
const location = opts.path ? resolve(opts.path) : defaultPeerCwd(personality, { env })
|
|
75
|
+
|
|
76
|
+
// NO-CLOBBER refusal: a target that already holds a DIFFERENT peer's profile.
|
|
77
|
+
const existing = existsSync(location) ? readPeerProfile(location) : null
|
|
78
|
+
if (existing && existing.personality !== personality) {
|
|
79
|
+
throw new IapError(
|
|
80
|
+
`refusing to create peer "${personality}" at ${location}: it already holds peer "${existing.personality}" ` +
|
|
81
|
+
`(.iapeer/peer-profile.json) — choose another --path or personality`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const createdFolder = !existsSync(location)
|
|
86
|
+
mkdirSync(location, { recursive: true }) // recursive mkdir never deletes existing content
|
|
87
|
+
|
|
88
|
+
// create is cwd-INDEPENDENT with an EXPLICIT personality, so the ambient session's
|
|
89
|
+
// identity must not govern it. When `iapeer create` is run from INSIDE a peer
|
|
90
|
+
// session (an orchestrator agent provisioning another peer — a real flow), the
|
|
91
|
+
// inherited PEER_PERSONALITY/PEER_IDENTITY/PEER_RUNTIME would trip the identity gate
|
|
92
|
+
// (it refuses a mismatch). The explicit personality is authoritative here, so strip
|
|
93
|
+
// those three ABI vars from the env passed down. (`init`, cwd-dependent, keeps the
|
|
94
|
+
// gate — you init your OWN folder, the ambient identity SHOULD match.) IAPEER_ROOT
|
|
95
|
+
// and the rest of the env are preserved.
|
|
96
|
+
const childEnv: NodeJS.ProcessEnv = { ...env }
|
|
97
|
+
delete childEnv.PEER_PERSONALITY
|
|
98
|
+
delete childEnv.PEER_IDENTITY
|
|
99
|
+
delete childEnv.PEER_RUNTIME
|
|
100
|
+
|
|
101
|
+
const result = await initPeer({
|
|
102
|
+
cwd: location,
|
|
103
|
+
runtime: opts.runtime,
|
|
104
|
+
personality,
|
|
105
|
+
description: opts.description,
|
|
106
|
+
intelligence: opts.intelligence,
|
|
107
|
+
runtimeBin: opts.runtimeBin,
|
|
108
|
+
bootstrap: opts.bootstrap,
|
|
109
|
+
env: childEnv,
|
|
110
|
+
warn: opts.warn,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return { ...result, location, createdFolder }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
// CLI — `iapeer create <personality> …` / `bun src/create/index.ts <personality> …`
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
if (import.meta.main) {
|
|
121
|
+
const argv = process.argv.slice(2)
|
|
122
|
+
const positionals: string[] = []
|
|
123
|
+
const flags: Record<string, string | true> = {}
|
|
124
|
+
for (let i = 0; i < argv.length; i++) {
|
|
125
|
+
const a = argv[i]
|
|
126
|
+
if (a.startsWith('--')) {
|
|
127
|
+
const eq = a.indexOf('=')
|
|
128
|
+
if (eq > 2) {
|
|
129
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
const next = argv[i + 1]
|
|
133
|
+
if (next === undefined || next.startsWith('--')) flags[a.slice(2)] = true
|
|
134
|
+
else flags[a.slice(2)] = argv[++i]
|
|
135
|
+
} else {
|
|
136
|
+
positionals.push(a)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const personality = positionals[0]
|
|
140
|
+
if (!personality) {
|
|
141
|
+
process.stderr.write(
|
|
142
|
+
'usage: create <personality> [--runtime r] [--path dir] [--description d] [--bin abs] [--intelligence i] [--no-bootstrap]\n',
|
|
143
|
+
)
|
|
144
|
+
process.exit(2)
|
|
145
|
+
}
|
|
146
|
+
createPeer({
|
|
147
|
+
personality,
|
|
148
|
+
runtime: typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined,
|
|
149
|
+
path: typeof flags.path === 'string' ? flags.path : undefined,
|
|
150
|
+
description: typeof flags.description === 'string' ? flags.description : undefined,
|
|
151
|
+
intelligence: typeof flags.intelligence === 'string' ? (flags.intelligence as Intelligence) : undefined,
|
|
152
|
+
runtimeBin: typeof flags.bin === 'string' ? flags.bin : undefined,
|
|
153
|
+
bootstrap: flags['no-bootstrap'] === true ? false : undefined,
|
|
154
|
+
warn: m => process.stderr.write(`warn: ${m}\n`),
|
|
155
|
+
})
|
|
156
|
+
.then(r => {
|
|
157
|
+
process.stdout.write(
|
|
158
|
+
`created peer "${r.personality}" (${r.runtime}, ${r.intelligence}) at ${r.location}` +
|
|
159
|
+
`${r.createdFolder ? ' (new folder)' : ' (existing folder)'}\n` +
|
|
160
|
+
` profile: ${r.profilePath}\n` +
|
|
161
|
+
` registry: peers-profiles.json updated\n` +
|
|
162
|
+
(r.mcpConfigPaths.length
|
|
163
|
+
? ` mcp: ${r.mcpConfigPaths.join(', ')} → ${r.daemonUrl}\n`
|
|
164
|
+
: r.codexMcpConfigPath
|
|
165
|
+
? ` mcp: ${r.codexMcpConfigPath} (codex)\n`
|
|
166
|
+
: ' mcp: (none — infra/router runtime)\n') +
|
|
167
|
+
(r.plistPath ? ` plist: ${r.plistPath}\n` : '') +
|
|
168
|
+
(r.selfConfig ? ` selfcfg: ${r.selfConfig.state}${r.selfConfig.detail ? ` — ${r.selfConfig.detail}` : ''}\n` : '') +
|
|
169
|
+
(r.bootstrapped ? ` bootstrap:${r.bootstrapped.state}${r.bootstrapped.detail ? ` — ${r.bootstrapped.detail}` : ''}\n` : '') +
|
|
170
|
+
` doctrine: ${r.doctrinePath}${r.doctrineCreated ? ' (template created — fill it in)' : ' (kept)'}\n`,
|
|
171
|
+
)
|
|
172
|
+
process.exit(0)
|
|
173
|
+
})
|
|
174
|
+
.catch(e => {
|
|
175
|
+
process.stderr.write(`create failed: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
176
|
+
process.exit(1)
|
|
177
|
+
})
|
|
178
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, 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 roster', () => {
|
|
77
|
+
const tools = listTools(readPeersIndex()) 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
|
+
expect(tools.find(t => t.name === 'send_to_peer').description).toContain('arthur')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('callTool (no wake passed → Ф1 offline behaviour, never spawns)', () => {
|
|
85
|
+
test('send_to_peer to an OFFLINE peer → explicit peer-offline error', async () => {
|
|
86
|
+
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
87
|
+
const r = await callTool(caller, 'send_to_peer', { personality: 'offlinepeer', message: 'hi' })
|
|
88
|
+
expect(r.isError).toBe(true)
|
|
89
|
+
expect(r.content[0].text).toMatch(/offline/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('send_to_peer to a peer NOT in the registry → not-delivered error', async () => {
|
|
93
|
+
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
94
|
+
const r = await callTool(caller, 'send_to_peer', { personality: 'nobody', message: 'hi' })
|
|
95
|
+
expect(r.isError).toBe(true)
|
|
96
|
+
expect(r.content[0].text).toMatch(/not in the IAPeer peers index/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('unknown tool → error', async () => {
|
|
100
|
+
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
101
|
+
expect((await callTool(caller, 'bogus', {})).isError).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
})
|