@agfpd/iapeer 0.2.37 → 0.2.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/index.ts +33 -7
- package/src/identity/index.ts +6 -0
- package/src/identity/profileStandard.test.ts +89 -1
- package/src/identity/profileStandard.ts +77 -10
- package/src/lifecycle/lifecycle.test.ts +5 -1
- package/src/registry/index.ts +15 -2
- package/src/transport/index.ts +55 -0
- package/src/transport/routesend-wake.test.ts +100 -3
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
|
|
|
48
48
|
import { peerProfilePath } from '../storage/index.ts'
|
|
49
49
|
import {
|
|
50
50
|
isConformant,
|
|
51
|
+
migrateProfileRuntimeField,
|
|
51
52
|
reconcileIndex,
|
|
52
53
|
reindexFromLocals,
|
|
53
54
|
validateProfileStandard,
|
|
@@ -554,7 +555,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
554
555
|
init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
|
|
555
556
|
create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
|
|
556
557
|
list [--json] registered peers + per-runtime liveness
|
|
557
|
-
verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index)
|
|
558
|
+
verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index + migrates legacy runtime→default_runtime in local profiles)
|
|
558
559
|
stop <peer> [runtime] | --all warm peer: kill the live session + set a durable stop-flag — the daemon will NOT wake it
|
|
559
560
|
until \`start\`; always-on (notifier/telegram): launchctl bootout. --all = every registered peer
|
|
560
561
|
start <peer> [runtime] | --all warm peer: clear the stop-flag — wakeable again; does NOT launch a session (the daemon brings
|
|
@@ -886,12 +887,18 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
886
887
|
}
|
|
887
888
|
case 'verify': {
|
|
888
889
|
// Profile conformance + index↔local self-heal reconciliation. Read-only by
|
|
889
|
-
// default; --fix self-heals the index (reindex from local profiles)
|
|
890
|
-
//
|
|
890
|
+
// default; --fix self-heals the index (reindex from local profiles) AND
|
|
891
|
+
// migrates conformant local profiles off the legacy `runtime` field shape
|
|
892
|
+
// (Phase 2 of the staged default_runtime story). A drift is a signal that
|
|
893
|
+
// the index self-heal lapsed — caught by construction here.
|
|
891
894
|
const index = readPeersIndex({ env })
|
|
892
895
|
let errors = 0
|
|
893
896
|
let driftCount = 0
|
|
894
897
|
const lines: string[] = []
|
|
898
|
+
// Conformant profiles still carrying the LEGACY runtime field shape (no
|
|
899
|
+
// default_runtime, or a diverged mirror) — candidates for the --fix data
|
|
900
|
+
// migration (Phase 2 of the staged default_runtime story).
|
|
901
|
+
const legacyRuntimeProfiles: Array<{ personality: string; cwd: string }> = []
|
|
895
902
|
for (const peer of index.peers) {
|
|
896
903
|
const path = peerProfilePath(peer.cwd)
|
|
897
904
|
if (!existsSync(path)) {
|
|
@@ -916,6 +923,12 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
916
923
|
} else if (warns.length > 0 && flags.json !== true) {
|
|
917
924
|
lines.push(`⚠ ${peer.personality}: ${warns.map(i => i.field).join(', ')}`)
|
|
918
925
|
}
|
|
926
|
+
// Migration candidate: conformant, but the default_runtime warn fired
|
|
927
|
+
// (legacy-only field or diverged mirror). Errored profiles are NEVER
|
|
928
|
+
// rewritten — migration heals shape, it does not guess at broken data.
|
|
929
|
+
if (errs.length === 0 && warns.some(i => i.field === 'default_runtime')) {
|
|
930
|
+
legacyRuntimeProfiles.push({ personality: peer.personality, cwd: peer.cwd })
|
|
931
|
+
}
|
|
919
932
|
}
|
|
920
933
|
const reconcile = reconcileIndex({ env })
|
|
921
934
|
for (const r of reconcile) {
|
|
@@ -932,10 +945,23 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
932
945
|
for (const l of lines) out(l + '\n')
|
|
933
946
|
out(`\n${index.peers.length} peers · ${errors} error(s) · ${driftCount} index↔local drift(s)\n`)
|
|
934
947
|
}
|
|
935
|
-
if (flags.fix === true
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
948
|
+
if (flags.fix === true) {
|
|
949
|
+
// Phase-2 data migration (staged default_runtime story): rewrite conformant
|
|
950
|
+
// local profiles still on the legacy field shape — the writer now emits
|
|
951
|
+
// `default_runtime` + the in-sync legacy `runtime` mirror. Runs BEFORE the
|
|
952
|
+
// reindex so the healed index is projected from migrated locals.
|
|
953
|
+
const migrated: string[] = []
|
|
954
|
+
for (const p of legacyRuntimeProfiles) {
|
|
955
|
+
if (migrateProfileRuntimeField(p.cwd)) migrated.push(p.personality)
|
|
956
|
+
}
|
|
957
|
+
if (migrated.length > 0) {
|
|
958
|
+
out(`migrated runtime→default_runtime (legacy mirror kept): ${migrated.join(', ')}\n`)
|
|
959
|
+
}
|
|
960
|
+
if (driftCount > 0 || errors === 0) {
|
|
961
|
+
const { healed, missing } = await reindexFromLocals({ env })
|
|
962
|
+
if (healed.length > 0) out(`self-healed index from local profiles:\n ${healed.join('\n ')}\n`)
|
|
963
|
+
if (missing.length > 0) errOut(`peers with no local profile (left untouched): ${missing.join(', ')}\n`)
|
|
964
|
+
}
|
|
939
965
|
}
|
|
940
966
|
return errors > 0 || (driftCount > 0 && flags.fix !== true) ? 1 : 0
|
|
941
967
|
}
|
package/src/identity/index.ts
CHANGED
|
@@ -292,6 +292,12 @@ export function writePeerProfileAtomic(cwd: string, profile: PeerProfileWrite):
|
|
|
292
292
|
const merged: Record<string, unknown> = {
|
|
293
293
|
...existing,
|
|
294
294
|
personality: profile.personality,
|
|
295
|
+
// Phase-2 write-flip (contract Идентичность, staged default_runtime migration):
|
|
296
|
+
// emit the contract field `default_runtime` AND keep the legacy `runtime` as an
|
|
297
|
+
// IN-SYNC MIRROR — readers that still read `runtime` only (IAP peers.ts,
|
|
298
|
+
// Spawned-Peer, external scripts) stay alive with zero blast radius. Phase 3
|
|
299
|
+
// (gated on every reader shipping dual-read) removes the mirror.
|
|
300
|
+
default_runtime: profile.runtime,
|
|
295
301
|
runtime: profile.runtime,
|
|
296
302
|
runtimes: profile.runtimes,
|
|
297
303
|
description,
|
|
@@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
|
4
4
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
5
5
|
import { tmpdir } from 'os'
|
|
6
6
|
import { join } from 'path'
|
|
7
|
-
import { isConformant, reconcileIndex, reindexFromLocals, validateProfileStandard } from './profileStandard.ts'
|
|
7
|
+
import { isConformant, migrateProfileRuntimeField, reconcileIndex, reindexFromLocals, validateProfileStandard } from './profileStandard.ts'
|
|
8
|
+
import { readFileSync } from 'fs'
|
|
8
9
|
import { readPeersIndex } from '../registry/index.ts'
|
|
9
10
|
|
|
10
11
|
describe('validateProfileStandard', () => {
|
|
@@ -28,6 +29,47 @@ describe('validateProfileStandard', () => {
|
|
|
28
29
|
expect(issues.find(i => i.field === 'default_runtime')?.severity).toBe('warn')
|
|
29
30
|
})
|
|
30
31
|
|
|
32
|
+
test('default_runtime + in-sync legacy mirror (Phase-2 write shape) → NO issues', () => {
|
|
33
|
+
const issues = validateProfileStandard(
|
|
34
|
+
{ personality: 'boris', default_runtime: 'claude', runtime: 'claude', runtimes: ['claude'], description: 'PM', intelligence: 'artificial' },
|
|
35
|
+
cwd,
|
|
36
|
+
)
|
|
37
|
+
expect(issues).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('DIVERGED legacy mirror → WARN on default_runtime, still conformant', () => {
|
|
41
|
+
const issues = validateProfileStandard(
|
|
42
|
+
{ personality: 'boris', default_runtime: 'codex', runtime: 'claude', runtimes: ['codex', 'claude'], description: '', intelligence: 'artificial' },
|
|
43
|
+
cwd,
|
|
44
|
+
)
|
|
45
|
+
expect(isConformant(issues)).toBe(true)
|
|
46
|
+
const warn = issues.find(i => i.field === 'default_runtime')
|
|
47
|
+
expect(warn?.severity).toBe('warn')
|
|
48
|
+
expect(warn?.message).toContain('diverged')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('aliases misfiled in interfaces.telegram → WARN (owner migration to expansion.aliases pending)', () => {
|
|
52
|
+
const issues = validateProfileStandard(
|
|
53
|
+
{
|
|
54
|
+
personality: 'boris', default_runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial',
|
|
55
|
+
interfaces: { telegram: { bot: 'k', bot_username: 'x_bot', aliases: { '/alias-new': 'txt' } } },
|
|
56
|
+
},
|
|
57
|
+
cwd,
|
|
58
|
+
)
|
|
59
|
+
expect(isConformant(issues)).toBe(true)
|
|
60
|
+
expect(issues.some(i => i.field === 'interfaces.telegram.aliases' && i.severity === 'warn')).toBe(true)
|
|
61
|
+
// passport-only telegram section → silent
|
|
62
|
+
const clean = validateProfileStandard(
|
|
63
|
+
{
|
|
64
|
+
personality: 'boris', default_runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial',
|
|
65
|
+
interfaces: { telegram: { bot: 'k', bot_username: 'x_bot' } },
|
|
66
|
+
expansion: { aliases: { '/alias-new': 'txt' } },
|
|
67
|
+
},
|
|
68
|
+
cwd,
|
|
69
|
+
)
|
|
70
|
+
expect(clean).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
|
|
31
73
|
test('top-level aliases → WARN (owner migration pending), still conformant', () => {
|
|
32
74
|
const issues = validateProfileStandard(
|
|
33
75
|
{ personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', aliases: { '/new': 'x' } },
|
|
@@ -113,4 +155,50 @@ describe('reconcileIndex / reindexFromLocals (self-heal)', () => {
|
|
|
113
155
|
// after the heal, reconcile is clean
|
|
114
156
|
expect(reconcileIndex({ env: process.env }).find(e => e.personality === 'boris')?.drift).toEqual([])
|
|
115
157
|
})
|
|
158
|
+
|
|
159
|
+
test('reindex writes the Phase-2 disk shape: default_runtime + in-sync legacy mirror', async () => {
|
|
160
|
+
await reindexFromLocals({ env: process.env })
|
|
161
|
+
const disk = JSON.parse(readFileSync(join(root, 'peers-profiles.json'), 'utf8'))
|
|
162
|
+
expect(disk.peers[0].default_runtime).toBe('claude')
|
|
163
|
+
expect(disk.peers[0].runtime).toBe('claude') // legacy mirror until Phase 3
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('migrateProfileRuntimeField: legacy-only profile → rewritten with default_runtime + mirror, owner fields preserved, idempotent', () => {
|
|
167
|
+
const profilePath = join(borisCwd, '.iapeer', 'peer-profile.json')
|
|
168
|
+
// enrich the legacy profile with owner-section + lifecycle fields that MUST survive
|
|
169
|
+
writeFileSync(
|
|
170
|
+
profilePath,
|
|
171
|
+
JSON.stringify({
|
|
172
|
+
personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: 'd', intelligence: 'human',
|
|
173
|
+
initial_prompt: 'boot', wake_policy: 'ephemeral',
|
|
174
|
+
interfaces: { telegram: { bot: 'k' } },
|
|
175
|
+
expansion: { aliases: { '/alias-new': 'txt' } },
|
|
176
|
+
notifier: { triggers: [] },
|
|
177
|
+
}),
|
|
178
|
+
)
|
|
179
|
+
expect(migrateProfileRuntimeField(borisCwd)).toBe(true)
|
|
180
|
+
const after = JSON.parse(readFileSync(profilePath, 'utf8'))
|
|
181
|
+
expect(after.default_runtime).toBe('claude')
|
|
182
|
+
expect(after.runtime).toBe('claude') // in-sync legacy mirror
|
|
183
|
+
expect(after.intelligence).toBe('human') // legacy vocab preserved VERBATIM (no silent migration)
|
|
184
|
+
expect(after.initial_prompt).toBe('boot')
|
|
185
|
+
expect(after.wake_policy).toBe('ephemeral')
|
|
186
|
+
expect(after.interfaces).toEqual({ telegram: { bot: 'k' } })
|
|
187
|
+
expect(after.expansion).toEqual({ aliases: { '/alias-new': 'txt' } })
|
|
188
|
+
expect(after.notifier).toEqual({ triggers: [] })
|
|
189
|
+
// idempotent: already in shape → untouched
|
|
190
|
+
expect(migrateProfileRuntimeField(borisCwd)).toBe(false)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('migrateProfileRuntimeField: DIVERGED mirror heals to default_runtime (the contract side wins)', () => {
|
|
194
|
+
const profilePath = join(borisCwd, '.iapeer', 'peer-profile.json')
|
|
195
|
+
writeFileSync(
|
|
196
|
+
profilePath,
|
|
197
|
+
JSON.stringify({ personality: 'boris', default_runtime: 'codex', runtime: 'claude', runtimes: ['codex', 'claude'], description: '', intelligence: 'artificial' }),
|
|
198
|
+
)
|
|
199
|
+
expect(migrateProfileRuntimeField(borisCwd)).toBe(true)
|
|
200
|
+
const after = JSON.parse(readFileSync(profilePath, 'utf8'))
|
|
201
|
+
expect(after.default_runtime).toBe('codex')
|
|
202
|
+
expect(after.runtime).toBe('codex') // mirror re-synced to the governing field
|
|
203
|
+
})
|
|
116
204
|
})
|
|
@@ -8,16 +8,20 @@
|
|
|
8
8
|
// ORDER = preference, first = primary), description, intelligence ∈
|
|
9
9
|
// {artificial|natural|absent}. Optional lifecycle: initial_prompt, wake_policy.
|
|
10
10
|
// • OWNER sections (validated only as well-typed, NEVER restructured here): interfaces
|
|
11
|
-
// (telegram/capability passport
|
|
12
|
-
// relocates to
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// (telegram/capability passport — PASSPORT ONLY: aliases are misfiled there and the
|
|
12
|
+
// contract relocates them to `expansion.aliases`, the runtime-agnostic plugin-config
|
|
13
|
+
// section owned by the chat-runtime — telegram-runtime today, future discord/matrix
|
|
14
|
+
// read the SAME section per the 25.05 expansion design), notifier (notifier-runtime),
|
|
15
|
+
// expansion (chat-runtime aliases), <plugin> sections — each plugin writes only its
|
|
16
|
+
// own field via atomic merge. Plugin-config sections are NOT projected into the
|
|
17
|
+
// registry index (private config is read from the LOCAL profile by its owner).
|
|
15
18
|
//
|
|
16
19
|
// peers-profiles.json is a regenerable self-heal PROJECTION of the local profiles —
|
|
17
20
|
// projectProfileToRecord builds the record the index SHOULD hold; verify compares the
|
|
18
21
|
// live index against it, reindex rewrites it. The index is never the source of truth.
|
|
19
22
|
|
|
20
23
|
import { basename } from 'path'
|
|
24
|
+
import { existsSync, readFileSync } from 'fs'
|
|
21
25
|
import { isRuntime, normalizeIntelligenceValue, normalizeNameCandidate } from '../core/constants.ts'
|
|
22
26
|
import {
|
|
23
27
|
clampDescription,
|
|
@@ -26,7 +30,8 @@ import {
|
|
|
26
30
|
type PeerRecord,
|
|
27
31
|
type PeersUpdateOptions,
|
|
28
32
|
} from '../registry/index.ts'
|
|
29
|
-
import {
|
|
33
|
+
import { peerProfilePath } from '../storage/index.ts'
|
|
34
|
+
import { readPeerProfile, writePeerProfileAtomic } from './index.ts'
|
|
30
35
|
|
|
31
36
|
export type IssueSeverity = 'error' | 'warn'
|
|
32
37
|
export interface ProfileIssue {
|
|
@@ -60,12 +65,21 @@ export function validateProfileStandard(raw: unknown, cwd: string): ProfileIssue
|
|
|
60
65
|
})
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
// default_runtime (contract) / runtime (legacy, staged migration).
|
|
68
|
+
// default_runtime (contract) / runtime (legacy, staged migration). Since the Phase-2
|
|
69
|
+
// write-flip the writer emits BOTH (legacy `runtime` as an in-sync mirror); a profile
|
|
70
|
+
// carrying only the legacy name, or a DIVERGED mirror (a legacy writer updated
|
|
71
|
+
// `runtime` behind the contract field — dual-read ignores it), is a migration WARN.
|
|
64
72
|
const dr = obj.default_runtime ?? obj.runtime
|
|
65
73
|
if (!isRuntime(dr)) {
|
|
66
74
|
issues.push({ severity: 'error', field: 'default_runtime', message: `must be a valid runtime id, got "${String(dr ?? '')}"` })
|
|
67
75
|
} else if (obj.default_runtime === undefined) {
|
|
68
76
|
issues.push({ severity: 'warn', field: 'default_runtime', message: 'legacy field name `runtime` — standard is `default_runtime` (staged migration)' })
|
|
77
|
+
} else if (obj.runtime !== undefined && obj.runtime !== obj.default_runtime) {
|
|
78
|
+
issues.push({
|
|
79
|
+
severity: 'warn',
|
|
80
|
+
field: 'default_runtime',
|
|
81
|
+
message: `legacy mirror \`runtime\`="${String(obj.runtime)}" diverged from default_runtime="${String(obj.default_runtime)}" — default_runtime governs; re-run \`iapeer verify --fix\` to re-sync the mirror`,
|
|
82
|
+
})
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
// runtimes[] — array of valid runtime ids that includes the default_runtime.
|
|
@@ -106,14 +120,35 @@ export function validateProfileStandard(raw: unknown, cwd: string): ProfileIssue
|
|
|
106
120
|
if (obj.interfaces !== undefined && (typeof obj.interfaces !== 'object' || obj.interfaces === null || Array.isArray(obj.interfaces))) {
|
|
107
121
|
issues.push({ severity: 'error', field: 'interfaces', message: 'must be a JSON object' })
|
|
108
122
|
}
|
|
109
|
-
// Legacy top-level aliases — contract relocates to
|
|
110
|
-
// the /alias-* namespace (clean slash-keys are reserved for the
|
|
111
|
-
// telegram-runtime owns that migration; flagged, not an error.
|
|
123
|
+
// Legacy top-level aliases — contract relocates to the `expansion.aliases` plugin-
|
|
124
|
+
// config section with the /alias-* namespace (clean slash-keys are reserved for the
|
|
125
|
+
// control layer). telegram-runtime owns that migration; flagged, not an error.
|
|
112
126
|
if (obj.aliases !== undefined) {
|
|
113
127
|
issues.push({
|
|
114
128
|
severity: 'warn',
|
|
115
129
|
field: 'aliases',
|
|
116
|
-
message: 'legacy top-level `aliases` — contract relocates to
|
|
130
|
+
message: 'legacy top-level `aliases` — contract relocates to expansion.aliases (/alias-* namespace); owner migration pending',
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
// aliases misfiled in the PASSPORT (interfaces.telegram) — the 07.06 interim home.
|
|
134
|
+
// Taxonomy revision (11.06, Артур): aliases are PRIVATE plugin config (one reader,
|
|
135
|
+
// the chat-runtime at expansion), not a public passport attribute — the contract
|
|
136
|
+
// relocates them to the runtime-agnostic `expansion.aliases` section (25.05 замысел:
|
|
137
|
+
// future discord/matrix runtimes read the SAME aliases). Transition observability:
|
|
138
|
+
// this warn goes silent once the owner migration moves the data.
|
|
139
|
+
const interfacesObj =
|
|
140
|
+
obj.interfaces && typeof obj.interfaces === 'object' && !Array.isArray(obj.interfaces)
|
|
141
|
+
? (obj.interfaces as Record<string, unknown>)
|
|
142
|
+
: undefined
|
|
143
|
+
const telegramSection =
|
|
144
|
+
interfacesObj?.telegram && typeof interfacesObj.telegram === 'object' && !Array.isArray(interfacesObj.telegram)
|
|
145
|
+
? (interfacesObj.telegram as Record<string, unknown>)
|
|
146
|
+
: undefined
|
|
147
|
+
if (telegramSection?.aliases !== undefined) {
|
|
148
|
+
issues.push({
|
|
149
|
+
severity: 'warn',
|
|
150
|
+
field: 'interfaces.telegram.aliases',
|
|
151
|
+
message: 'aliases are plugin config, not passport — contract relocates to expansion.aliases; owner migration pending',
|
|
117
152
|
})
|
|
118
153
|
}
|
|
119
154
|
|
|
@@ -125,6 +160,38 @@ export function isConformant(issues: readonly ProfileIssue[]): boolean {
|
|
|
125
160
|
return !issues.some(i => i.severity === 'error')
|
|
126
161
|
}
|
|
127
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Phase-2 DATA migration of the staged default_runtime story: rewrite the LOCAL
|
|
165
|
+
* profile at `cwd` so it carries the contract field `default_runtime` with the legacy
|
|
166
|
+
* `runtime` kept as an in-sync mirror (writePeerProfileAtomic emits both since the
|
|
167
|
+
* write-flip). Heals BOTH legacy shapes: a profile with only `runtime`, and a DIVERGED
|
|
168
|
+
* mirror — the rewrite goes through readPeerProfile's dual-read, so default_runtime
|
|
169
|
+
* wins divergence (the contract: default_runtime governs the primary). Owner sections
|
|
170
|
+
* (interfaces / notifier / expansion / …), initial_prompt, wake_policy and the raw
|
|
171
|
+
* legacy intelligence vocab are preserved verbatim by the H1 merge-write. Returns true
|
|
172
|
+
* when the file was rewritten; false when already in shape, absent, or unparseable
|
|
173
|
+
* (verify reports those as errors — migration never guesses).
|
|
174
|
+
*/
|
|
175
|
+
export function migrateProfileRuntimeField(cwd: string): boolean {
|
|
176
|
+
const path = peerProfilePath(cwd)
|
|
177
|
+
if (!existsSync(path)) return false
|
|
178
|
+
let raw: unknown
|
|
179
|
+
try {
|
|
180
|
+
raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
181
|
+
} catch {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false
|
|
185
|
+
const obj = raw as Record<string, unknown>
|
|
186
|
+
const inShape =
|
|
187
|
+
isRuntime(obj.default_runtime) && (obj.runtime === undefined || obj.runtime === obj.default_runtime)
|
|
188
|
+
if (inShape) return false
|
|
189
|
+
const profile = readPeerProfile(cwd)
|
|
190
|
+
if (!profile) return false
|
|
191
|
+
writePeerProfileAtomic(cwd, profile)
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
|
|
128
195
|
/**
|
|
129
196
|
* The PeerRecord the index SHOULD hold for the peer at `cwd`, projected from its LOCAL
|
|
130
197
|
* profile (the self-heal source of truth) — readPeerProfile already self-heals the
|
|
@@ -290,7 +290,11 @@ describe('wakeOrSpawn H4 refusal', () => {
|
|
|
290
290
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
291
291
|
|
|
292
292
|
describe('wakeOrSpawn live-session fast path', () => {
|
|
293
|
-
|
|
293
|
+
// Live tmux required (spawns a real session). Skipped where tmux is absent — a
|
|
294
|
+
// clean CI runner has none; the foundation's preversion gate runs it locally
|
|
295
|
+
// (this machine has tmux), so the release path keeps the coverage.
|
|
296
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
297
|
+
test.if(tmuxAvailable)('READY with taskDelivered:false — the winning wake delivered only ITS envelope', async () => {
|
|
294
298
|
const root = mkdtempSync(join(tmpdir(), 'iapeer-fpl-root-'))
|
|
295
299
|
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-fpl-la-')) // empty → not launchd-managed
|
|
296
300
|
const sockDir = join(root, 'socks')
|
package/src/registry/index.ts
CHANGED
|
@@ -159,7 +159,8 @@ function normalizePeer(raw: unknown): PeerRecord {
|
|
|
159
159
|
)
|
|
160
160
|
}
|
|
161
161
|
// default_runtime is the contract field; `runtime` is legacy. Dual-read accepts
|
|
162
|
-
// both during the staged migration
|
|
162
|
+
// both during the staged migration; when both are present default_runtime WINS
|
|
163
|
+
// (the write emits default_runtime + the legacy mirror since Phase 2).
|
|
163
164
|
const defaultRuntime = obj.default_runtime ?? obj.runtime
|
|
164
165
|
if (!isRuntime(defaultRuntime)) {
|
|
165
166
|
throw new IapError(
|
|
@@ -314,9 +315,21 @@ function writePeersIndexAtomic(paths: PeersPaths, index: PeersIndex): void {
|
|
|
314
315
|
// Persist the RAW intelligence verbatim (legacy-safe round-trip) and DROP the
|
|
315
316
|
// intelligenceRaw shadow field from the on-disk shape — the file carries the
|
|
316
317
|
// legacy vocab the live IAP reads, never the foundation's in-memory normalization.
|
|
318
|
+
// Phase-2 write-flip (staged default_runtime migration): the disk shape carries the
|
|
319
|
+
// contract field `default_runtime` PLUS the legacy `runtime` as an in-sync mirror,
|
|
320
|
+
// so not-yet-dual-read readers keep working. Phase 3 removes the mirror.
|
|
317
321
|
const peersForDisk = [...index.peers]
|
|
318
322
|
.sort((a, b) => a.personality.localeCompare(b.personality))
|
|
319
|
-
.map(
|
|
323
|
+
.map(peer => ({
|
|
324
|
+
personality: peer.personality,
|
|
325
|
+
default_runtime: peer.runtime,
|
|
326
|
+
runtime: peer.runtime,
|
|
327
|
+
runtimes: peer.runtimes,
|
|
328
|
+
description: peer.description,
|
|
329
|
+
intelligence: peer.intelligenceRaw ?? peer.intelligence,
|
|
330
|
+
cwd: peer.cwd,
|
|
331
|
+
...(peer.interfaces ? { interfaces: peer.interfaces } : {}),
|
|
332
|
+
}))
|
|
320
333
|
const normalized = { version: PEERS_SCHEMA_VERSION, peers: peersForDisk }
|
|
321
334
|
writeFileSync(tmp, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
|
|
322
335
|
renameSync(tmp, paths.peersFile)
|
package/src/transport/index.ts
CHANGED
|
@@ -487,6 +487,46 @@ function validateAttachments(value: readonly string[] = []): Result<string[]> {
|
|
|
487
487
|
return ok(out)
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
+
// ─── Telegram-domain sender policy (defect track 11.06, sanctioned by Артур) ─
|
|
491
|
+
|
|
492
|
+
/** A SENDER face in the telegram domain: a non-empty interfaces.telegram.bot (the
|
|
493
|
+
* bot machine-key) in the peer's registry passport. Deliberately bot-ONLY, not
|
|
494
|
+
* bot|user_id — the bridge picks the SENDING bot from the sender's bot key
|
|
495
|
+
* (owner fact, telegram-runtime сверка 11.06): a user_id grants a receive/operator
|
|
496
|
+
* identity but no ability to send, so a user_id-only sender passing the guard
|
|
497
|
+
* would still die at the bridge — a late asynchronous failure where the guard
|
|
498
|
+
* exists to give a synchronous one. The guard passes exactly what the bridge can
|
|
499
|
+
* deliver. */
|
|
500
|
+
export function hasTelegramPresence(record: PeerRecord): boolean {
|
|
501
|
+
const tg = record.interfaces?.telegram
|
|
502
|
+
if (!tg || typeof tg !== 'object' || Array.isArray(tg)) return false
|
|
503
|
+
const t = tg as Record<string, unknown>
|
|
504
|
+
return typeof t.bot === 'string' && t.bot.trim() !== ''
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Sender policy for the telegram domain: a peer with NO declared telegram presence
|
|
508
|
+
* must not write into it — the recipient (a human in Telegram) would face a message
|
|
509
|
+
* from a peer that has no telegram face (identity confusion: who writes, by what
|
|
510
|
+
* right?). Bot channel = declared presence; no presence — no right to the domain.
|
|
511
|
+
* Enforced at the ROUTER, not the bridge: the refusal is synchronous and lands in
|
|
512
|
+
* the sender's tool result; a bridge-side check would fire AFTER the router's
|
|
513
|
+
* ok:true — a false-OK + silent loss, the class this contract forbids. Applied to
|
|
514
|
+
* the INTENDED channel (override / target default — before any wake side-effect,
|
|
515
|
+
* since a wake delivers the envelope as the boot first-message) AND to the RESOLVED
|
|
516
|
+
* channel at both delivery points (the only-live-session fallback can land on
|
|
517
|
+
* telegram without either). */
|
|
518
|
+
function telegramSenderGuard(
|
|
519
|
+
caller: ResolvedCaller,
|
|
520
|
+
targetRuntime: string,
|
|
521
|
+
targetPersonality: string,
|
|
522
|
+
): Result<void> {
|
|
523
|
+
if (targetRuntime !== 'telegram') return ok(undefined)
|
|
524
|
+
if (hasTelegramPresence(caller.record)) return ok(undefined)
|
|
525
|
+
return err(
|
|
526
|
+
`telegram policy: sender "${caller.personality}" has no telegram face (no interfaces.telegram.bot in its passport) — message to "${targetPersonality}" NOT delivered; route via a telegram-faced peer (e.g. boris) or provision a bot for this sender`,
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
490
530
|
/**
|
|
491
531
|
* Route a send from a request-resolved caller to a target peer and deliver it.
|
|
492
532
|
* hit (live) → build envelope from the CALLER → deliverViaTmux → {woke:false}
|
|
@@ -529,6 +569,12 @@ export async function routeSend(
|
|
|
529
569
|
return err(`peer "${personality}" is not in the iapeer peers index; message NOT delivered`)
|
|
530
570
|
}
|
|
531
571
|
|
|
572
|
+
// Telegram sender policy — INTENDED channel (explicit override, or the target's
|
|
573
|
+
// default). Checked BEFORE any wake side-effect: a wake delivers the envelope as
|
|
574
|
+
// the boot first-message, past the resolved-target guards further down.
|
|
575
|
+
const intendedGuard = telegramSenderGuard(caller, runtime ?? peer.runtime, personality)
|
|
576
|
+
if (!intendedGuard.ok) return intendedGuard
|
|
577
|
+
|
|
532
578
|
// Built once — it is both the live-delivery payload and, on a miss, the wake
|
|
533
579
|
// first-message (the woken session receives it as its boot task).
|
|
534
580
|
const envelope = buildEnvelope({
|
|
@@ -554,6 +600,10 @@ export async function routeSend(
|
|
|
554
600
|
const target = resolvePeerDeliveryTarget(personality, runtime, peer)
|
|
555
601
|
if (target.ok) {
|
|
556
602
|
if (target.value.address === caller.address) return err('cannot send to self')
|
|
603
|
+
// Telegram sender policy — RESOLVED channel (the only-live-session fallback can
|
|
604
|
+
// land on telegram without an override and without telegram being the default).
|
|
605
|
+
const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
|
|
606
|
+
if (!liveGuard.ok) return liveGuard
|
|
557
607
|
// peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
|
|
558
608
|
const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
|
|
559
609
|
if (!delivered.ok) return delivered
|
|
@@ -581,6 +631,11 @@ export async function routeSend(
|
|
|
581
631
|
return err(`woke "${personality}" but the session is not live (verify-before-act): ${live.error.message}`)
|
|
582
632
|
}
|
|
583
633
|
if (live.value.address === caller.address) return err('cannot send to self')
|
|
634
|
+
// Telegram sender policy — post-wake RESOLVED channel (airtight: a wake should
|
|
635
|
+
// never bring up a telegram session — they are launchd-held — but the policy is
|
|
636
|
+
// enforced on every delivery point, not on an assumption about the wake).
|
|
637
|
+
const postWakeGuard = telegramSenderGuard(caller, live.value.runtime, personality)
|
|
638
|
+
if (!postWakeGuard.ok) return postWakeGuard
|
|
584
639
|
// taskDelivered:false — the wake took the idempotent live-session fast path (a
|
|
585
640
|
// CONCURRENT sender's boot won the wake.lock and delivered only ITS envelope as
|
|
586
641
|
// the boot first-message). OUR envelope was not delivered by anything yet —
|
|
@@ -17,7 +17,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
|
17
17
|
import { tmpdir } from 'os'
|
|
18
18
|
import { join } from 'path'
|
|
19
19
|
import { spawnSync } from 'child_process'
|
|
20
|
-
import { routeSend, type WakeFn } from './index.ts'
|
|
20
|
+
import { hasTelegramPresence, routeSend, type WakeFn } from './index.ts'
|
|
21
21
|
import type { ResolvedCaller } from '../identity/index.ts'
|
|
22
22
|
import type { PeerRecord } from '../registry/index.ts'
|
|
23
23
|
|
|
@@ -25,6 +25,13 @@ let root: string
|
|
|
25
25
|
let sockDir: string
|
|
26
26
|
const prevRoot = process.env.IAPEER_ROOT
|
|
27
27
|
|
|
28
|
+
// These suites spawn a REAL tmux session (the live-delivery path). Skip where
|
|
29
|
+
// tmux is absent — a clean CI runner has none, and an unguarded live-tmux test
|
|
30
|
+
// is what kept main's CI red (fail: "tmux new-session failed: null"). The
|
|
31
|
+
// foundation's preversion gate runs the full suite locally (this machine has
|
|
32
|
+
// tmux), so the release path keeps the live coverage; CI gates the rest.
|
|
33
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
34
|
+
|
|
28
35
|
// Target peer: NON-TUI runtime (telegram) → the C-j delivery path lands into a
|
|
29
36
|
// plain `cat` pane with no TUI prompt machinery; capture-pane then shows the
|
|
30
37
|
// envelope text verbatim.
|
|
@@ -41,6 +48,10 @@ const callerRecord = {
|
|
|
41
48
|
description: '',
|
|
42
49
|
intelligence: 'artificial',
|
|
43
50
|
cwd: '/tmp/boris',
|
|
51
|
+
// Telegram sender policy: the target of these suites is a telegram peer, so the
|
|
52
|
+
// caller must declare a telegram presence (the bot machine-key) — these suites
|
|
53
|
+
// double as the "bot-faced sender delivers as before" half of the policy criterion.
|
|
54
|
+
interfaces: { telegram: { bot: 'boris' } },
|
|
44
55
|
} as unknown as PeerRecord
|
|
45
56
|
const caller: ResolvedCaller = {
|
|
46
57
|
personality: 'boris',
|
|
@@ -91,7 +102,7 @@ afterAll(() => {
|
|
|
91
102
|
rmSync(root, { recursive: true, force: true })
|
|
92
103
|
})
|
|
93
104
|
|
|
94
|
-
describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
|
|
105
|
+
describe.if(tmuxAvailable)('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
|
|
95
106
|
test('taskDelivered:false → routeSend delivers the envelope itself via the live path', async () => {
|
|
96
107
|
const marker = 'SECOND-SENDER-ENVELOPE-7741'
|
|
97
108
|
// The loser-of-the-lock outcome: the session is up (the WINNING wake booted it),
|
|
@@ -145,7 +156,7 @@ describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
|
|
|
145
156
|
// one it woke with (defect 11.06: stale wake-time marker → false fresh).
|
|
146
157
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
147
158
|
|
|
148
|
-
describe('routeSend noteLiveTopic seam (live-delivered topic)', () => {
|
|
159
|
+
describe.if(tmuxAvailable)('routeSend noteLiveTopic seam (live-delivered topic)', () => {
|
|
149
160
|
test('live hit with a topic → hook fired ONCE with the TARGET identity + topic', async () => {
|
|
150
161
|
startTargetSession()
|
|
151
162
|
const notes: Array<[string, string]> = []
|
|
@@ -206,3 +217,89 @@ describe('routeSend noteLiveTopic seam (live-delivered topic)', () => {
|
|
|
206
217
|
killTargetSession()
|
|
207
218
|
})
|
|
208
219
|
})
|
|
220
|
+
|
|
221
|
+
// ─── Telegram sender policy (defect track 11.06) ─────────────────────────────
|
|
222
|
+
// A sender with NO declared telegram presence (interfaces.telegram.bot|user_id)
|
|
223
|
+
// must get a SYNCHRONOUS refusal when the delivery channel is telegram — the
|
|
224
|
+
// incident class: the bridge silently dropped such envelopes while the router
|
|
225
|
+
// reported ok:true. The bot-faced "delivers as before" half of the criterion is
|
|
226
|
+
// carried by the suites above (their caller declares interfaces.telegram.bot).
|
|
227
|
+
|
|
228
|
+
const facelessCaller: ResolvedCaller = {
|
|
229
|
+
personality: 'ghost',
|
|
230
|
+
runtime: 'claude',
|
|
231
|
+
address: 'claude-ghost',
|
|
232
|
+
description: '',
|
|
233
|
+
intelligence: 'artificial',
|
|
234
|
+
cwd: '/tmp/ghost',
|
|
235
|
+
record: {
|
|
236
|
+
personality: 'ghost',
|
|
237
|
+
runtime: 'claude',
|
|
238
|
+
runtimes: ['claude'],
|
|
239
|
+
description: '',
|
|
240
|
+
intelligence: 'artificial',
|
|
241
|
+
cwd: '/tmp/ghost',
|
|
242
|
+
} as unknown as PeerRecord,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
describe('telegram sender policy — hasTelegramPresence', () => {
|
|
246
|
+
const rec = (tg: unknown): PeerRecord =>
|
|
247
|
+
({ personality: 'x', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/x', ...(tg !== undefined ? { interfaces: { telegram: tg } } : {}) }) as unknown as PeerRecord
|
|
248
|
+
test('bot machine-key (agent peers) → sender face present', () => {
|
|
249
|
+
expect(hasTelegramPresence(rec({ bot: 'boris' }))).toBe(true)
|
|
250
|
+
})
|
|
251
|
+
test('user_id alone (natural peers — arthur) → NO sender face: the bridge sends FROM the bot key; user_id is a receive/operator identity (owner fact, bot-only predicate)', () => {
|
|
252
|
+
expect(hasTelegramPresence(rec({ user_id: '409502965' }))).toBe(false)
|
|
253
|
+
expect(hasTelegramPresence(rec({ user_id: 409502965 }))).toBe(false)
|
|
254
|
+
})
|
|
255
|
+
test('no interfaces / no telegram / empty bot → absent', () => {
|
|
256
|
+
expect(hasTelegramPresence(rec(undefined))).toBe(false)
|
|
257
|
+
expect(hasTelegramPresence(rec({}))).toBe(false)
|
|
258
|
+
expect(hasTelegramPresence(rec({ bot: '' }))).toBe(false)
|
|
259
|
+
expect(hasTelegramPresence(rec({ bot: ' ' }))).toBe(false)
|
|
260
|
+
expect(hasTelegramPresence(rec({ aliases: { '/alias-new': 'x' } }))).toBe(false)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
describe('telegram sender policy — routeSend refusal', () => {
|
|
265
|
+
test('INTENDED channel (target default = telegram, peer offline): refused BEFORE the wake — wake never invoked', async () => {
|
|
266
|
+
let wakeCalled = false
|
|
267
|
+
const wake: WakeFn = async () => {
|
|
268
|
+
wakeCalled = true
|
|
269
|
+
return { status: 'READY', woke: true, runtime: 'telegram', taskDelivered: true }
|
|
270
|
+
}
|
|
271
|
+
const r = await routeSend(facelessCaller, { personality: TARGET, message: 'm' }, { wake })
|
|
272
|
+
expect(r.ok).toBe(false)
|
|
273
|
+
if (!r.ok) {
|
|
274
|
+
expect(r.error.message).toContain('telegram policy')
|
|
275
|
+
expect(r.error.message).toContain('"ghost"')
|
|
276
|
+
expect(r.error.message).toContain('NOT delivered')
|
|
277
|
+
}
|
|
278
|
+
expect(wakeCalled).toBe(false)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('explicit runtime=telegram override: refused with the policy line, not "offline"', async () => {
|
|
282
|
+
const r = await routeSend(facelessCaller, { personality: TARGET, runtime: 'telegram', message: 'm' })
|
|
283
|
+
expect(r.ok).toBe(false)
|
|
284
|
+
if (!r.ok) expect(r.error.message).toContain('telegram policy')
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe.if(tmuxAvailable)('telegram sender policy — live telegram session', () => {
|
|
289
|
+
test('faceless sender + LIVE telegram target → refused, envelope NEVER reaches the pane', async () => {
|
|
290
|
+
startTargetSession()
|
|
291
|
+
const r = await routeSend(facelessCaller, { personality: TARGET, message: 'POLICY-MUST-BLOCK' })
|
|
292
|
+
expect(r.ok).toBe(false)
|
|
293
|
+
if (!r.ok) expect(r.error.message).toContain('telegram policy')
|
|
294
|
+
expect(capturePane()).not.toContain('POLICY-MUST-BLOCK')
|
|
295
|
+
killTargetSession()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('bot-faced sender (boris) + LIVE telegram target → delivered as before', async () => {
|
|
299
|
+
startTargetSession()
|
|
300
|
+
const r = await routeSend(caller, { personality: TARGET, message: 'BOT-FACED-OK' })
|
|
301
|
+
expect(r.ok).toBe(true)
|
|
302
|
+
expect(capturePane()).toContain('BOT-FACED-OK')
|
|
303
|
+
killTargetSession()
|
|
304
|
+
})
|
|
305
|
+
})
|