@agfpd/iapeer 0.2.36 → 0.2.38
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/status/index.ts +51 -2
- package/src/status/status.test.ts +43 -3
- package/src/transport/routesend-wake.test.ts +9 -2
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/status/index.ts
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
// error (fail-open). The core never acts on heartbeat staleness — it only REPORTS
|
|
7
7
|
// it (healing the provider's daemon is the provider's job, their ADR-010).
|
|
8
8
|
|
|
9
|
-
import { readFileSync, statSync } from 'fs'
|
|
9
|
+
import { closeSync, openSync, readFileSync, statSync } from 'fs'
|
|
10
10
|
import { join } from 'path'
|
|
11
11
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
12
12
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
13
13
|
import { daemonDiscoveryPath } from '../daemon/index.ts'
|
|
14
14
|
import { waitForDaemonHealthy } from '../update/index.ts'
|
|
15
|
+
import { iapeerBinPath } from '../install/index.ts'
|
|
15
16
|
|
|
16
17
|
/** The slot-declaration filename in the storage root (next to peers-profiles.json). */
|
|
17
18
|
export const MEMORY_PROVIDER_FILE = 'memory-provider.json'
|
|
@@ -135,16 +136,48 @@ export function heartbeatAgeSecs(provider: MemoryProvider, nowMs: number = Date.
|
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Probe whether the iapeer binary holds Full Disk Access (macOS TCC). Reads the
|
|
141
|
+
* user TCC.db — a canonical FDA-gated resource that is NEVER promptable (silent
|
|
142
|
+
* EPERM without the grant), so THE PROBE ITSELF raises no dialog (it must not be
|
|
143
|
+
* the very noise it detects). Byte-free: open+close to prove access, no content
|
|
144
|
+
* read. Returns:
|
|
145
|
+
* true — readable → FDA granted
|
|
146
|
+
* false — EPERM/EACCES → FDA NOT granted (peers will hit TCC prompts on
|
|
147
|
+
* protected folders + foreign app containers — see onboard contract)
|
|
148
|
+
* null — not macOS / no HOME / TCC.db absent → cannot determine (no claim)
|
|
149
|
+
*/
|
|
150
|
+
export function probeFullDiskAccess(env: NodeJS.ProcessEnv = process.env): boolean | null {
|
|
151
|
+
if (process.platform !== 'darwin') return null
|
|
152
|
+
const home = env.HOME
|
|
153
|
+
if (!home) return null
|
|
154
|
+
const marker = join(home, 'Library', 'Application Support', 'com.apple.TCC', 'TCC.db')
|
|
155
|
+
try {
|
|
156
|
+
closeSync(openSync(marker, 'r'))
|
|
157
|
+
return true
|
|
158
|
+
} catch (e) {
|
|
159
|
+
const code = (e as NodeJS.ErrnoException).code
|
|
160
|
+
if (code === 'EPERM' || code === 'EACCES') return false
|
|
161
|
+
return null // ENOENT / unexpected → undeterminable, never a false claim
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
138
165
|
export interface HostStatus {
|
|
139
166
|
version: string
|
|
140
167
|
daemon: { healthy: boolean; url: string | null; sock: string | null }
|
|
141
168
|
memory: { provider: MemoryProvider | null; heartbeatAgeSecs: number | null }
|
|
169
|
+
/** Full Disk Access of the iapeer binary: true granted / false not-granted /
|
|
170
|
+
* null undeterminable (non-macOS or TCC.db unreadable for a non-TCC reason). */
|
|
171
|
+
fda: boolean | null
|
|
142
172
|
}
|
|
143
173
|
|
|
144
174
|
export interface HostStatusOptions {
|
|
145
175
|
env?: NodeJS.ProcessEnv
|
|
146
176
|
/** Injectable daemon probe (tests). Default: the real socket probe. */
|
|
147
177
|
probe?: () => Promise<boolean>
|
|
178
|
+
/** Injectable FDA probe (tests — the default reads the real TCC.db, which a
|
|
179
|
+
* hermetic test must not touch). Default: probeFullDiskAccess(env). */
|
|
180
|
+
fdaProbe?: () => boolean | null
|
|
148
181
|
}
|
|
149
182
|
|
|
150
183
|
/** Assemble the host snapshot: baked version + daemon probe + memory slot. */
|
|
@@ -161,10 +194,12 @@ export async function hostStatus(opts: HostStatusOptions = {}): Promise<HostStat
|
|
|
161
194
|
}
|
|
162
195
|
const health = await waitForDaemonHealthy({ env, timeoutMs: 2500, needConsecutive: 1, probe: opts.probe })
|
|
163
196
|
const provider = readMemoryProvider(env)
|
|
197
|
+
const fda = (opts.fdaProbe ?? (() => probeFullDiskAccess(env)))()
|
|
164
198
|
return {
|
|
165
199
|
version: IAPEER_VERSION,
|
|
166
200
|
daemon: { healthy: health.healthy, url, sock },
|
|
167
201
|
memory: { provider, heartbeatAgeSecs: provider ? heartbeatAgeSecs(provider) : null },
|
|
202
|
+
fda,
|
|
168
203
|
}
|
|
169
204
|
}
|
|
170
205
|
|
|
@@ -184,5 +219,19 @@ export function formatHostStatus(s: HostStatus): string {
|
|
|
184
219
|
: ' (daemon not running — no heartbeat file)'
|
|
185
220
|
}
|
|
186
221
|
const memory = s.memory.provider ? `${s.memory.provider.provider} ${s.memory.provider.version}${hb}` : 'none'
|
|
187
|
-
|
|
222
|
+
// FDA line: granted → terse OK; NOT granted → the actionable hint (a fresh host
|
|
223
|
+
// without FDA silently meets TCC prompts on every new protected class — peers
|
|
224
|
+
// read protected folders + foreign app containers, attributed to this binary;
|
|
225
|
+
// see onboard contract §INSTALL). undeterminable → say so, never a false claim.
|
|
226
|
+
let fda: string
|
|
227
|
+
if (s.fda === true) {
|
|
228
|
+
fda = 'fda: granted'
|
|
229
|
+
} else if (s.fda === false) {
|
|
230
|
+
fda =
|
|
231
|
+
`fda: NOT granted — peers will hit TCC prompts on protected paths.\n` +
|
|
232
|
+
` grant: System Settings → Privacy & Security → Full Disk Access → + → ⇧⌘G → ${iapeerBinPath()}`
|
|
233
|
+
} else {
|
|
234
|
+
fda = 'fda: unknown (not macOS / undeterminable)'
|
|
235
|
+
}
|
|
236
|
+
return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\n${fda}\n`
|
|
188
237
|
}
|
|
@@ -128,7 +128,7 @@ describe('heartbeatAgeSecs', () => {
|
|
|
128
128
|
describe('hostStatus + formatHostStatus', () => {
|
|
129
129
|
test('empty slot → memory: none; daemon health from the injected probe', async () => {
|
|
130
130
|
const root = mkTmp()
|
|
131
|
-
const s = await hostStatus({ env: envFor(root), probe: async () => true })
|
|
131
|
+
const s = await hostStatus({ env: envFor(root), probe: async () => true, fdaProbe: () => null })
|
|
132
132
|
expect(s.daemon.healthy).toBe(true)
|
|
133
133
|
expect(s.memory.provider).toBeNull()
|
|
134
134
|
expect(formatHostStatus(s)).toContain('memory: none')
|
|
@@ -140,7 +140,7 @@ describe('hostStatus + formatHostStatus', () => {
|
|
|
140
140
|
const hb = join(root, 'memoryd.heartbeat')
|
|
141
141
|
writeFileSync(hb, '')
|
|
142
142
|
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, heartbeat: hb }))
|
|
143
|
-
const s = await hostStatus({ env, probe: async () => true })
|
|
143
|
+
const s = await hostStatus({ env, probe: async () => true, fdaProbe: () => null })
|
|
144
144
|
expect(formatHostStatus(s)).toMatch(/memory: iapeer-memory 0\.1\.0 \(heartbeat \d+s ago\)/)
|
|
145
145
|
})
|
|
146
146
|
|
|
@@ -152,6 +152,7 @@ describe('hostStatus + formatHostStatus', () => {
|
|
|
152
152
|
provider: { ...VALID, heartbeat: '/nope/hb' } as MemoryProvider,
|
|
153
153
|
heartbeatAgeSecs: null,
|
|
154
154
|
},
|
|
155
|
+
fda: null,
|
|
155
156
|
}
|
|
156
157
|
const text = formatHostStatus(s)
|
|
157
158
|
expect(text).toContain('memory: iapeer-memory 0.1.0 (daemon not running — no heartbeat file)')
|
|
@@ -164,9 +165,48 @@ describe('hostStatus + formatHostStatus', () => {
|
|
|
164
165
|
const stateDir = join(root, 'state', 'iapeer')
|
|
165
166
|
mkdirSync(stateDir, { recursive: true })
|
|
166
167
|
writeFileSync(join(stateDir, 'router.json'), JSON.stringify({ sock: '/x/router.sock', tcp: 'http://127.0.0.1:8765/mcp' }))
|
|
167
|
-
const s = await hostStatus({ env, probe: async () => true })
|
|
168
|
+
const s = await hostStatus({ env, probe: async () => true, fdaProbe: () => null })
|
|
168
169
|
const text = formatHostStatus(s)
|
|
169
170
|
expect(text).toContain('@ http://127.0.0.1:8765/mcp')
|
|
170
171
|
expect(text).toContain('+ /x/router.sock')
|
|
171
172
|
})
|
|
172
173
|
})
|
|
174
|
+
|
|
175
|
+
describe('FDA detection (iapeer status — fresh-host observability)', () => {
|
|
176
|
+
function base(fda: boolean | null): HostStatus {
|
|
177
|
+
return {
|
|
178
|
+
version: '0.0.0',
|
|
179
|
+
daemon: { healthy: true, url: null, sock: null },
|
|
180
|
+
memory: { provider: null, heartbeatAgeSecs: null },
|
|
181
|
+
fda,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
test('granted → terse OK line, no instruction', () => {
|
|
186
|
+
const text = formatHostStatus(base(true))
|
|
187
|
+
expect(text).toContain('fda: granted')
|
|
188
|
+
expect(text).not.toContain('NOT granted')
|
|
189
|
+
expect(text).not.toContain('Full Disk Access')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('NOT granted → actionable hint with the binary path', () => {
|
|
193
|
+
const text = formatHostStatus(base(false))
|
|
194
|
+
expect(text).toContain('fda: NOT granted')
|
|
195
|
+
expect(text).toContain('Full Disk Access')
|
|
196
|
+
expect(text).toMatch(/iapeer$|iapeer\n/m) // the grant path ends at the binary
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('undeterminable → explicit unknown, never a false claim', () => {
|
|
200
|
+
const text = formatHostStatus(base(null))
|
|
201
|
+
expect(text).toContain('fda: unknown')
|
|
202
|
+
expect(text).not.toContain('NOT granted')
|
|
203
|
+
expect(text).not.toContain('granted\n') // not the bare "granted" either
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('hostStatus threads the injected fdaProbe verbatim', async () => {
|
|
207
|
+
const root = mkTmp()
|
|
208
|
+
const s = await hostStatus({ env: envFor(root), probe: async () => true, fdaProbe: () => false })
|
|
209
|
+
expect(s.fda).toBe(false)
|
|
210
|
+
expect(formatHostStatus(s)).toContain('fda: NOT granted')
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -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.
|
|
@@ -91,7 +98,7 @@ afterAll(() => {
|
|
|
91
98
|
rmSync(root, { recursive: true, force: true })
|
|
92
99
|
})
|
|
93
100
|
|
|
94
|
-
describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
|
|
101
|
+
describe.if(tmuxAvailable)('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
|
|
95
102
|
test('taskDelivered:false → routeSend delivers the envelope itself via the live path', async () => {
|
|
96
103
|
const marker = 'SECOND-SENDER-ENVELOPE-7741'
|
|
97
104
|
// The loser-of-the-lock outcome: the session is up (the WINNING wake booted it),
|
|
@@ -145,7 +152,7 @@ describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
|
|
|
145
152
|
// one it woke with (defect 11.06: stale wake-time marker → false fresh).
|
|
146
153
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
147
154
|
|
|
148
|
-
describe('routeSend noteLiveTopic seam (live-delivered topic)', () => {
|
|
155
|
+
describe.if(tmuxAvailable)('routeSend noteLiveTopic seam (live-delivered topic)', () => {
|
|
149
156
|
test('live hit with a topic → hook fired ONCE with the TARGET identity + topic', async () => {
|
|
150
157
|
startTargetSession()
|
|
151
158
|
const notes: Array<[string, string]> = []
|