@agfpd/iapeer 0.4.87 → 0.4.88
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/docs/09-runtimes.md +2 -0
- package/docs/ru/09-/321/200/320/260/320/275/321/202/320/260/320/271/320/274/321/213.md +2 -0
- package/package.json +1 -1
- package/src/cli/index.ts +49 -24
- package/src/core/constants.ts +6 -0
- package/src/identity/index.ts +14 -25
- package/src/init/index.ts +9 -2
- package/src/launch/index.ts +4 -0
- package/src/launch/launchd.ts +141 -38
- package/src/lifecycle/index.ts +17 -8
- package/src/provision/index.ts +5 -2
- package/src/update/index.ts +13 -9
package/docs/09-runtimes.md
CHANGED
|
@@ -43,6 +43,8 @@ These are long-lived router processes, not AI. They run continuously under launc
|
|
|
43
43
|
|
|
44
44
|
Infrastructure peers' plists, unlike the daemon's plist, are **loaded automatically** on `iapeer create --runtime telegram|notifier` and on `iapeer onboard` (unless `--no-bootstrap` is passed). So no separate `launchctl bootstrap` is needed for them.
|
|
45
45
|
|
|
46
|
+
**Plist scheme (multi-infra).** One personality can carry several always-on channels (e.g. a human peer with both a Telegram bridge and a web console). Each (personality, runtime) pair resolves to its own launchd label: a **legacy base plist** `com.iapeer.<personality>` stays the channel of the runtime it already holds (no forced migration — existing single-channel hosts keep their plists untouched), while every new install gets the **per-runtime plist** `com.iapeer.<personality>.<runtime>`. Both channels are held by launchd KeepAlive simultaneously and independently; `stop`/`start <peer> <runtime>` bootout/bootstrap exactly that runtime's label, `remove` tears down **all** the personality's plists. The suffixed label can't collide with anything: peer names can't contain dots.
|
|
47
|
+
|
|
46
48
|
## Several runtimes on a peer
|
|
47
49
|
|
|
48
50
|
A peer profile has two runtime fields: `runtimes` (all the peer can be present on) and `default_runtime` (the primary — where messages route and which comes up first). Extending the options and switching the primary runtime are different commands.
|
|
@@ -43,6 +43,8 @@ iapeer codex # в Codex
|
|
|
43
43
|
|
|
44
44
|
Плисты инфраструктурных пиров, в отличие от плиста демона, **загружаются автоматически** при `iapeer create --runtime telegram|notifier` и при `iapeer onboard` (если не передан `--no-bootstrap`). Поэтому отдельный `launchctl bootstrap` для них не нужен.
|
|
45
45
|
|
|
46
|
+
**Схема плистов (мульти-инфра).** Одна personality может нести несколько always-on каналов (например, человек с Telegram-мостом и веб-консолью одновременно). Каждая пара (personality, runtime) резолвится в собственный launchd-label: **легаси базовый плист** `com.iapeer.<personality>` остаётся каналом того рантайма, который он уже держит (без принудительной миграции — существующие одноканальные хосты не трогаются), а каждая новая установка получает **per-runtime плист** `com.iapeer.<personality>.<runtime>`. Оба канала держатся launchd KeepAlive одновременно и независимо; `stop`/`start <peer> <runtime>` делают bootout/bootstrap ровно label этого рантайма, `remove` сносит **все** плисты personality. Суффиксный label ни с чем не коллидирует: имена пиров не могут содержать точек.
|
|
47
|
+
|
|
46
48
|
## Несколько рантаймов у пира
|
|
47
49
|
|
|
48
50
|
В профиле пира два поля про рантаймы: `runtimes` (все, на которых пир может присутствовать) и `default_runtime` (основной — куда маршрутизируются сообщения и какой поднимается первым). Расширить возможности и переключить основной рантайм — разные команды.
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -52,7 +52,12 @@ import {
|
|
|
52
52
|
import { getAdapter } from '../launch/index.ts'
|
|
53
53
|
import { hostRunDir } from '../launch/ptyHost.ts' // pty-only: attach via the supervisor client
|
|
54
54
|
import { runSupervisorClient } from '../supervisor/client.ts' // @xterm-free attach client (both reattach port-deps baked in)
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
cycleDaemon,
|
|
57
|
+
existingAlwaysOnPlists,
|
|
58
|
+
launchctlBootstrap,
|
|
59
|
+
resolveAlwaysOnTarget,
|
|
60
|
+
} from '../launch/launchd.ts'
|
|
56
61
|
import { readPeerProfile, renamePeer, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
|
|
57
62
|
import { claudeProjectsRoot, transcriptSlug } from '../launch/adapters/claude.ts'
|
|
58
63
|
import { peerProfilePath } from '../storage/index.ts'
|
|
@@ -206,9 +211,13 @@ function uid(): string {
|
|
|
206
211
|
}
|
|
207
212
|
|
|
208
213
|
/** FLEET GUARD: a peer launchd-managed by a NON-foundation plist (persistent-peer)
|
|
209
|
-
* is off-limits to stop/start — the foundation is read-only for it (H4).
|
|
214
|
+
* is off-limits to stop/start — the foundation is read-only for it (H4). Multi-infra
|
|
215
|
+
* (0.4.88): the peer is foreign iff ANY of its existing template plists (legacy base
|
|
216
|
+
* OR per-runtime suffixed) lacks the ownership sentinel. A personality whose only
|
|
217
|
+
* plists are our suffixed ones must NOT read as foreign (the pre-multi-infra check
|
|
218
|
+
* keyed on the base path alone, which is absent in that layout). */
|
|
210
219
|
function isForeignLaunchd(personality: string, env: NodeJS.ProcessEnv): boolean {
|
|
211
|
-
return
|
|
220
|
+
return existingAlwaysOnPlists(personality, env).some(p => !p.foundationOwned)
|
|
212
221
|
}
|
|
213
222
|
|
|
214
223
|
function targetRuntimes(peer: PeerRecord, runtime: string | undefined): Runtime[] {
|
|
@@ -249,7 +258,13 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
|
|
|
249
258
|
// stop: the desired state ("not running") already holds, so it carries NO reason
|
|
250
259
|
// and must not fail the verb. Any other non-zero is a REAL error and sets reason
|
|
251
260
|
// (the CLI maps a reasoned bootout to exit 1).
|
|
252
|
-
|
|
261
|
+
// Multi-infra: bootout the label that ACTUALLY holds this (personality, runtime)
|
|
262
|
+
// channel — the legacy base or the per-runtime suffixed one (resolveAlwaysOnTarget,
|
|
263
|
+
// the same authority install uses). A runtime with no plist at all (manually-run
|
|
264
|
+
// session) resolves to its suffixed label → bootout exit 3 (benign) → killSession
|
|
265
|
+
// below still tears the session down.
|
|
266
|
+
const target = resolveAlwaysOnTarget(personality, rt, env)
|
|
267
|
+
const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${target.label}`], { encoding: 'utf8' })
|
|
253
268
|
killSession(sock, identity, env)
|
|
254
269
|
const stderrText = (r.stderr ?? '').trim()
|
|
255
270
|
const benign = r.status === 0 || r.status === 3 || /No such process/i.test(stderrText)
|
|
@@ -295,7 +310,9 @@ export function startPeer(personality: string, runtime: string | undefined, opts
|
|
|
295
310
|
for (const rt of targetRuntimes(peer, runtime)) {
|
|
296
311
|
const identity = buildProcessAddress(rt, personality)
|
|
297
312
|
if (isInfraRuntime(rt)) {
|
|
298
|
-
|
|
313
|
+
// Multi-infra: load the plist that holds THIS (personality, runtime) channel
|
|
314
|
+
// (legacy base or per-runtime suffixed — the same resolution install/stop use).
|
|
315
|
+
const plist = resolveAlwaysOnTarget(personality, rt, env).path
|
|
299
316
|
// UNDEAD-JOB-SAFE start: a bootstrap right after a bootout used to hit the
|
|
300
317
|
// still-dismantling job (exit 5 I/O
|
|
301
318
|
// error) and leave the router DOWN. launchctlBootstrap now waits for the
|
|
@@ -552,7 +569,7 @@ export async function newPeer(
|
|
|
552
569
|
personality,
|
|
553
570
|
runtime: rt,
|
|
554
571
|
action: 'refused-infra',
|
|
555
|
-
reason: `infra runtime "${rt}" is launchd-held — restart it with: launchctl kickstart -k gui/$(id -u)/${
|
|
572
|
+
reason: `infra runtime "${rt}" is launchd-held — restart it with: launchctl kickstart -k gui/$(id -u)/${resolveAlwaysOnTarget(personality, rt, env).label}`,
|
|
556
573
|
}
|
|
557
574
|
}
|
|
558
575
|
const identity = buildProcessAddress(rt, personality)
|
|
@@ -785,25 +802,33 @@ export async function removePeerCli(
|
|
|
785
802
|
}
|
|
786
803
|
plistTeardown = 'skipped-foreign (H4 — foreign plist left intact; --force dropped only the registry record)'
|
|
787
804
|
} else {
|
|
788
|
-
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
//
|
|
797
|
-
bootoutNote
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
+
// Multi-infra (0.4.88): tear down EVERY foundation plist of the personality —
|
|
806
|
+
// the legacy base AND each per-runtime suffixed one (a two-channel peer like
|
|
807
|
+
// arthur telegram+web holds two). A forgotten plist would leave launchd
|
|
808
|
+
// KeepAlive crash-looping run-infra against the deleted record (the exact
|
|
809
|
+
// "remove must dismantle ALL artifacts" class).
|
|
810
|
+
const notes: string[] = []
|
|
811
|
+
for (const entry of existingAlwaysOnPlists(personality, env)) {
|
|
812
|
+
// Sandbox/test: never invoke real launchctl (a test peer's label isn't a real
|
|
813
|
+
// job); still remove the (temp-dir) plist file so the teardown is observable.
|
|
814
|
+
let bootoutNote: string
|
|
815
|
+
if (env.IAPEER_TEST_SANDBOX === '1') {
|
|
816
|
+
bootoutNote = 'skipped-sandbox'
|
|
817
|
+
} else {
|
|
818
|
+
const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${entry.label}`], { encoding: 'utf8' })
|
|
819
|
+
// bootout exits non-zero when the job was already unloaded — benign for a teardown.
|
|
820
|
+
bootoutNote = r.status === 0 ? 'bootout' : `bootout (exit ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''})`
|
|
821
|
+
}
|
|
822
|
+
let rmNote: string
|
|
823
|
+
try {
|
|
824
|
+
rmSync(entry.path, { force: true })
|
|
825
|
+
rmNote = ' + plist removed'
|
|
826
|
+
} catch (e) {
|
|
827
|
+
rmNote = ` (plist rm FAILED: ${e instanceof Error ? e.message : String(e)} — remove ${entry.path} manually)`
|
|
828
|
+
}
|
|
829
|
+
notes.push(`${entry.label}: ${bootoutNote}${rmNote}`)
|
|
805
830
|
}
|
|
806
|
-
plistTeardown =
|
|
831
|
+
plistTeardown = notes.join('; ')
|
|
807
832
|
}
|
|
808
833
|
}
|
|
809
834
|
await removePeer(personality, { env })
|
package/src/core/constants.ts
CHANGED
|
@@ -96,6 +96,12 @@ export function isIntelligenceAllowedForRuntime(runtime: string, intelligence: I
|
|
|
96
96
|
// This set gates always-on launchd plist generation (src/launch/launchd.ts).
|
|
97
97
|
const INFRA_RUNTIMES = new Set(['notifier', 'telegram', 'voicetalk', 'web'])
|
|
98
98
|
|
|
99
|
+
/** The infra runtimes as a list — for callers that must ENUMERATE the always-on
|
|
100
|
+
* plist candidates of a personality (H4 detector, remove teardown, update
|
|
101
|
+
* re-cycle walk the per-runtime plist scheme `com.iapeer.<p>.<rt>.plist`).
|
|
102
|
+
* Same membership as isInfraRuntime — single source, they cannot drift. */
|
|
103
|
+
export const INFRA_RUNTIME_LIST: readonly string[] = [...INFRA_RUNTIMES]
|
|
104
|
+
|
|
99
105
|
export function isInfraRuntime(runtime: string): boolean {
|
|
100
106
|
return INFRA_RUNTIMES.has(runtime)
|
|
101
107
|
}
|
package/src/identity/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
// KeepAlive, so creating one installs its always-on plist. Imported from the
|
|
27
27
|
// launchd module directly (not the launch barrel) to keep the dependency surface
|
|
28
28
|
// minimal — launchd.ts pulls only core/*, so identity → launch introduces no cycle.
|
|
29
|
-
import { installAlwaysOnPlist
|
|
29
|
+
import { installAlwaysOnPlist } from '../launch/launchd.ts'
|
|
30
30
|
import { buildProcessAddress } from '../core/socket.ts'
|
|
31
31
|
import { IapError } from '../core/errors.ts'
|
|
32
32
|
import {
|
|
@@ -623,30 +623,19 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
|
|
|
623
623
|
// and leaves no half-updated profile. Idempotent for our own (sentinel) plist.
|
|
624
624
|
let intelligence = existing.intelligence
|
|
625
625
|
if (isInfraRuntime(options.runtime)) {
|
|
626
|
-
// MULTI-INFRA
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
options.
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
`until per-runtime plists land, bring the session up manually: iapeer run-infra ${existing.personality} ${options.runtime}`,
|
|
640
|
-
)
|
|
641
|
-
} else {
|
|
642
|
-
installAlwaysOnPlist({
|
|
643
|
-
personality: existing.personality,
|
|
644
|
-
runtime: options.runtime,
|
|
645
|
-
cwd,
|
|
646
|
-
runtimeBin: options.runtimeBin,
|
|
647
|
-
env: options.env,
|
|
648
|
-
})
|
|
649
|
-
}
|
|
626
|
+
// MULTI-INFRA (0.4.88): installAlwaysOnPlist resolves the per-(personality,
|
|
627
|
+
// runtime) target itself — the legacy base plist when it already carries this
|
|
628
|
+
// runtime, else the suffixed `com.iapeer.<p>.<rt>`. A second infra channel
|
|
629
|
+
// (arthur: telegram, now declaring web) therefore gets its OWN plist and the
|
|
630
|
+
// first channel's plist is untouched — the 0.4.87 declare-without-plist bridge
|
|
631
|
+
// (manual `run-infra` bring-up) is retired.
|
|
632
|
+
installAlwaysOnPlist({
|
|
633
|
+
personality: existing.personality,
|
|
634
|
+
runtime: options.runtime,
|
|
635
|
+
cwd,
|
|
636
|
+
runtimeBin: options.runtimeBin,
|
|
637
|
+
env: options.env,
|
|
638
|
+
})
|
|
650
639
|
// Re-provision honors an EXPLICIT nature (a manifest-declared absent service bot survives
|
|
651
640
|
// update-runtime re-provision), else the runtime default (the legacy vocab-flip this fix
|
|
652
641
|
// preserved). resolvedIntelligence validates the explicit against the runtime's allowed set.
|
package/src/init/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from '../storage/index.ts'
|
|
31
31
|
import { provisionPeer, type ProvisionResult } from '../provision/index.ts'
|
|
32
32
|
import { claudeSettingsPath } from '../launch/nativeMemory.ts'
|
|
33
|
-
import { launchctlBootstrap,
|
|
33
|
+
import { launchctlBootstrap, resolveAlwaysOnTarget, type BootstrapResult } from '../launch/launchd.ts'
|
|
34
34
|
import { runtimeSelfConfig, type SelfConfigResult } from '../runtime/index.ts'
|
|
35
35
|
import type { Intelligence } from '../core/constants.ts'
|
|
36
36
|
|
|
@@ -568,7 +568,14 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
|
|
|
568
568
|
} else if (opts.bootstrap !== false) {
|
|
569
569
|
// (2) AUTO-bootstrap (contract Фаза §5): load the plist NOW instead of
|
|
570
570
|
// write-and-wait. Fleet-safe / idempotent / sandbox-skipped inside.
|
|
571
|
-
|
|
571
|
+
// Multi-infra: load the plist provision actually installed for THIS runtime
|
|
572
|
+
// (legacy base or per-runtime suffixed — resolveAlwaysOnTarget is stable
|
|
573
|
+
// post-install, so this is the same file installAlwaysOnPlist wrote).
|
|
574
|
+
bootstrapped = launchctlBootstrap(
|
|
575
|
+
provisioned.personality,
|
|
576
|
+
resolveAlwaysOnTarget(provisioned.personality, provisioned.runtime, env).path,
|
|
577
|
+
env,
|
|
578
|
+
)
|
|
572
579
|
if (bootstrapped.state === 'failed' || bootstrapped.state === 'refused-foreign') {
|
|
573
580
|
opts.warn?.(`bootstrap ${bootstrapped.state}: ${bootstrapped.detail ?? ''}`)
|
|
574
581
|
}
|
package/src/launch/index.ts
CHANGED
|
@@ -55,6 +55,8 @@ export {
|
|
|
55
55
|
launchdLabel,
|
|
56
56
|
launchAgentsDir,
|
|
57
57
|
launchdPlistPath,
|
|
58
|
+
resolveAlwaysOnTarget,
|
|
59
|
+
existingAlwaysOnPlists,
|
|
58
60
|
renderLaunchdPlist,
|
|
59
61
|
installAlwaysOnPlist,
|
|
60
62
|
installedPlistRuntime,
|
|
@@ -69,6 +71,8 @@ export {
|
|
|
69
71
|
IAPEER_PLIST_OWNER_KEY,
|
|
70
72
|
} from './launchd.ts'
|
|
71
73
|
export type {
|
|
74
|
+
AlwaysOnTarget,
|
|
75
|
+
PeerPlistEntry,
|
|
72
76
|
LaunchdPlistSpec,
|
|
73
77
|
InstallAlwaysOnPlistOptions,
|
|
74
78
|
BootstrapResult,
|
package/src/launch/launchd.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
IAPEER_DIR,
|
|
24
24
|
INFRA_RUNTIME_BIN_ENV,
|
|
25
25
|
INFRA_RUNTIME_DEFAULT_BIN,
|
|
26
|
+
INFRA_RUNTIME_LIST,
|
|
26
27
|
LAUNCHD_LABEL_PREFIX,
|
|
27
28
|
isInfraRuntime,
|
|
28
29
|
type Runtime,
|
|
@@ -82,12 +83,12 @@ function readPlistEnvVar(path: string, key: string): string | undefined {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
|
-
* The PEER_RUNTIME baked into the personality's EXISTING foundation-owned
|
|
86
|
-
* plist
|
|
87
|
-
*
|
|
88
|
-
* runtime
|
|
89
|
-
* the
|
|
90
|
-
*
|
|
86
|
+
* The PEER_RUNTIME baked into the personality's EXISTING foundation-owned BASE
|
|
87
|
+
* always-on plist (`com.iapeer.<p>.plist` — the legacy default channel), or
|
|
88
|
+
* undefined when there is no base plist / it is foreign-owned / it carries no
|
|
89
|
+
* runtime. This is the "who occupies the base label" probe resolveAlwaysOnTarget
|
|
90
|
+
* builds on: the base stays that runtime's channel (no forced migration), every
|
|
91
|
+
* other runtime resolves to its per-runtime suffixed plist.
|
|
91
92
|
*/
|
|
92
93
|
export function installedPlistRuntime(personality: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
93
94
|
const path = launchdPlistPath(personality, env)
|
|
@@ -136,6 +137,93 @@ export function launchdPlistPath(personality: string, env: NodeJS.ProcessEnv = p
|
|
|
136
137
|
return join(launchAgentsDir(env), `${launchdLabel(personality)}.plist`)
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
// MULTI-INFRA plist scheme — per-(personality, runtime) resolution
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export interface AlwaysOnTarget {
|
|
145
|
+
/** The launchd Label — also the plist basename stem. */
|
|
146
|
+
label: string
|
|
147
|
+
/** The plist path under launchAgentsDir. */
|
|
148
|
+
path: string
|
|
149
|
+
/** Which scheme resolved: the legacy one-per-personality base plist
|
|
150
|
+
* (`com.iapeer.<p>`) or the per-runtime suffixed one (`com.iapeer.<p>.<rt>`). */
|
|
151
|
+
scheme: 'legacy-base' | 'per-runtime'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the always-on plist TARGET for one (personality, runtime) pair — the
|
|
156
|
+
* single scheme authority every side shares (install / stop / start / remove /
|
|
157
|
+
* update re-cycle), so the writer and the operators can never disagree on which
|
|
158
|
+
* label holds which channel.
|
|
159
|
+
*
|
|
160
|
+
* Resolution: the LEGACY base plist (`com.iapeer.<p>`) is the personality's
|
|
161
|
+
* default channel — it is the target iff it EXISTS, is foundation-owned and
|
|
162
|
+
* carries THIS runtime (its baked PEER_RUNTIME matches). Every other case —
|
|
163
|
+
* base absent, base occupied by another infra runtime, base foreign — resolves
|
|
164
|
+
* to the per-runtime label `com.iapeer.<p>.<rt>`. So a legacy single-infra host
|
|
165
|
+
* keeps its base plists untouched (no forced migration), while every NEW install
|
|
166
|
+
* (and every second channel of a personality) gets its own plist and the two
|
|
167
|
+
* channels can be KeepAlive simultaneously.
|
|
168
|
+
*
|
|
169
|
+
* The suffixed label is structurally collision-free: NAME_RE forbids '.' in
|
|
170
|
+
* personalities and RUNTIME_RE forbids it in runtimes, so `com.iapeer.<p>.<rt>`
|
|
171
|
+
* can never spell another personality's base label (and the persistent-peer
|
|
172
|
+
* fleet only ever uses base-shaped labels).
|
|
173
|
+
*
|
|
174
|
+
* Resolution is STABLE across install→bootstrap→stop/start: installing a
|
|
175
|
+
* suffixed plist never flips a later resolve (the base check looks at the base
|
|
176
|
+
* file only), and re-resolving after an install sees the same target.
|
|
177
|
+
*/
|
|
178
|
+
export function resolveAlwaysOnTarget(
|
|
179
|
+
personality: string,
|
|
180
|
+
runtime: string,
|
|
181
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
182
|
+
): AlwaysOnTarget {
|
|
183
|
+
if (installedPlistRuntime(personality, env) === runtime) {
|
|
184
|
+
return { label: launchdLabel(personality), path: launchdPlistPath(personality, env), scheme: 'legacy-base' }
|
|
185
|
+
}
|
|
186
|
+
const label = `${launchdLabel(personality)}.${runtime}`
|
|
187
|
+
return { label, path: join(launchAgentsDir(env), `${label}.plist`), scheme: 'per-runtime' }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface PeerPlistEntry {
|
|
191
|
+
label: string
|
|
192
|
+
path: string
|
|
193
|
+
/** The runtime the plist holds: the base plist's baked PEER_RUNTIME (undefined
|
|
194
|
+
* when unreadable/foreign), or the suffix of a per-runtime plist. */
|
|
195
|
+
runtime?: string
|
|
196
|
+
foundationOwned: boolean
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Every EXISTING always-on plist of a personality under the foundation TEMPLATE
|
|
201
|
+
* scheme: the base `com.iapeer.<p>.plist` (legacy default channel — also the shape
|
|
202
|
+
* a foreign persistent-peer plist takes) plus the suffixed `com.iapeer.<p>.<rt>.plist`
|
|
203
|
+
* for each known infra runtime. The enumerator for personality-wide operations:
|
|
204
|
+
* the H4 detector mirror, remove's full teardown, and the update re-cycle.
|
|
205
|
+
*/
|
|
206
|
+
export function existingAlwaysOnPlists(personality: string, env: NodeJS.ProcessEnv = process.env): PeerPlistEntry[] {
|
|
207
|
+
const out: PeerPlistEntry[] = []
|
|
208
|
+
const basePath = launchdPlistPath(personality, env)
|
|
209
|
+
if (existsSync(basePath)) {
|
|
210
|
+
out.push({
|
|
211
|
+
label: launchdLabel(personality),
|
|
212
|
+
path: basePath,
|
|
213
|
+
runtime: readPlistEnvVar(basePath, 'PEER_RUNTIME'),
|
|
214
|
+
foundationOwned: isFoundationOwnedPlist(basePath),
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
for (const rt of INFRA_RUNTIME_LIST) {
|
|
218
|
+
const label = `${launchdLabel(personality)}.${rt}`
|
|
219
|
+
const path = join(launchAgentsDir(env), `${label}.plist`)
|
|
220
|
+
if (existsSync(path)) {
|
|
221
|
+
out.push({ label, path, runtime: rt, foundationOwned: isFoundationOwnedPlist(path) })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
226
|
+
|
|
139
227
|
// XML-escape a plist <string> text node. cwd / personality / PATH can carry '&',
|
|
140
228
|
// '<', '>' (or unicode) — a literal '&' or '<' would corrupt the XML, so escape the
|
|
141
229
|
// three significant characters. Also DROP XML-1.0-illegal control characters (NUL +
|
|
@@ -538,12 +626,17 @@ export function launchctlBootstrap(
|
|
|
538
626
|
plistPath: string,
|
|
539
627
|
env: NodeJS.ProcessEnv = process.env,
|
|
540
628
|
): BootstrapResult {
|
|
541
|
-
|
|
629
|
+
// The label is the plist BASENAME stem — true for BOTH schemes (legacy base
|
|
630
|
+
// `com.iapeer.<p>.plist` and per-runtime `com.iapeer.<p>.<rt>.plist`), and the
|
|
631
|
+
// single derivation that cannot drift from the file actually being loaded.
|
|
632
|
+
// (Deriving from `personality` alone would mis-label a per-runtime plist.)
|
|
633
|
+
const base = plistPath.slice(plistPath.lastIndexOf('/') + 1)
|
|
634
|
+
const label = base.endsWith('.plist') ? base.slice(0, -'.plist'.length) : launchdLabel(personality)
|
|
542
635
|
if (!isFoundationOwnedPlist(plistPath)) {
|
|
543
636
|
return {
|
|
544
637
|
state: 'refused-foreign',
|
|
545
638
|
label,
|
|
546
|
-
detail: `${plistPath} is not foundation-owned (no ${IAPEER_PLIST_OWNER_KEY} sentinel) — refusing to launchctl bootstrap a foreign plist`,
|
|
639
|
+
detail: `${plistPath} (peer "${personality}") is not foundation-owned (no ${IAPEER_PLIST_OWNER_KEY} sentinel) — refusing to launchctl bootstrap a foreign plist`,
|
|
547
640
|
}
|
|
548
641
|
}
|
|
549
642
|
// SANDBOX FAIL-CLOSED: `launchctl bootstrap gui/<uid>` is HOST-GLOBAL — it loads a
|
|
@@ -599,18 +692,24 @@ export interface InstallAlwaysOnPlistOptions {
|
|
|
599
692
|
* entrypoint with PEER_* env + WorkingDirectory=cwd; logs land under
|
|
600
693
|
* <cwd>/.iapeer/logs/<runtime>/.
|
|
601
694
|
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
695
|
+
* LABEL SCHEME (multi-infra, 0.4.88): the target label/path come from
|
|
696
|
+
* resolveAlwaysOnTarget — the legacy base `com.iapeer.<personality>` when that plist
|
|
697
|
+
* already carries this runtime (default channel, no forced migration), else the
|
|
698
|
+
* per-runtime `com.iapeer.<personality>.<runtime>`. Two infra channels of one
|
|
699
|
+
* personality (e.g. arthur: telegram + web) therefore hold two separate KeepAlive
|
|
700
|
+
* plists and never clobber each other.
|
|
701
|
+
*
|
|
702
|
+
* COLLISION GUARD (H4 — shared label namespace): the BASE label is SHARED with the
|
|
604
703
|
* already-deployed persistent-peer fleet (~/Library/LaunchAgents/
|
|
605
704
|
* com.iapeer.<persistent-peer>.plist run by start.sh). A personality collision (a
|
|
606
|
-
* notifier peer named like a live PP peer) must NOT
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
705
|
+
* notifier peer named like a live PP peer) must NOT put a second manager on that
|
|
706
|
+
* name — so we REFUSE when the base plist exists and is not foundation-owned
|
|
707
|
+
* (isFoundationOwnedPlist: it lacks the sentinel renderLaunchdPlist embeds), and
|
|
708
|
+
* equally refuse a foreign file squatting the resolved per-runtime path. The label
|
|
709
|
+
* prefix alone cannot tell ours from theirs (PP is com.iapeer.* too); the sentinel
|
|
710
|
+
* can. Re-installing our OWN plist (sentinel present) is allowed (idempotent
|
|
711
|
+
* re-provision). The guards run FIRST, before any mkdir/write, so a refusal leaves
|
|
712
|
+
* the filesystem untouched.
|
|
614
713
|
*/
|
|
615
714
|
export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string {
|
|
616
715
|
if (!isInfraRuntime(opts.runtime)) {
|
|
@@ -619,29 +718,33 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
|
|
|
619
718
|
)
|
|
620
719
|
}
|
|
621
720
|
const env = opts.env ?? process.env
|
|
622
|
-
//
|
|
623
|
-
|
|
624
|
-
|
|
721
|
+
// PERSONALITY COLLISION GUARD FIRST — never touch a personality whose BASE label is
|
|
722
|
+
// owned by a foreign manager (the live persistent-peer fleet writes base-shaped
|
|
723
|
+
// com.iapeer.<p> plists). Even though a second channel would land on the suffixed
|
|
724
|
+
// label, installing ANY foundation plist for a foreign-managed personality would
|
|
725
|
+
// put two managers on one peer name — refuse before any write.
|
|
726
|
+
const basePath = launchdPlistPath(opts.personality, env)
|
|
727
|
+
if (existsSync(basePath) && !isFoundationOwnedPlist(basePath)) {
|
|
625
728
|
throw new IapError(
|
|
626
|
-
`refusing to
|
|
627
|
-
|
|
628
|
-
`or other manager owns it; rename the peer to avoid
|
|
729
|
+
`refusing to install an always-on plist for "${opts.personality}": label ` +
|
|
730
|
+
`${launchdLabel(opts.personality)} is not foundation-managed (no ${IAPEER_PLIST_OWNER_KEY} ` +
|
|
731
|
+
`sentinel) — a persistent-peer or other manager owns it; rename the peer to avoid ` +
|
|
732
|
+
`the com.iapeer.<personality> collision`,
|
|
629
733
|
)
|
|
630
734
|
}
|
|
631
|
-
// MULTI-INFRA
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
const
|
|
638
|
-
|
|
735
|
+
// MULTI-INFRA (per-runtime plists): resolve the (personality, runtime) target —
|
|
736
|
+
// the legacy base plist iff it already carries THIS runtime (default channel, no
|
|
737
|
+
// forced migration), else the suffixed com.iapeer.<p>.<rt>. A second infra runtime
|
|
738
|
+
// therefore gets its OWN plist and can be KeepAlive alongside the first channel —
|
|
739
|
+
// the pre-0.4.88 fail-loud "one plist per personality" refusal is retired.
|
|
740
|
+
const target = resolveAlwaysOnTarget(opts.personality, opts.runtime, env)
|
|
741
|
+
const path = target.path
|
|
742
|
+
// Belt for the suffixed path too: a hand-planted foreign file at our per-runtime
|
|
743
|
+
// label is never clobbered (same sentinel rule as the base guard above).
|
|
744
|
+
if (existsSync(path) && !isFoundationOwnedPlist(path)) {
|
|
639
745
|
throw new IapError(
|
|
640
|
-
`refusing to overwrite launchd plist ${path}: it
|
|
641
|
-
`
|
|
642
|
-
`personality) — installing "${opts.runtime}" here would silently kill that channel. ` +
|
|
643
|
-
`Multi-infra per personality needs per-runtime plists (not yet supported); for a ` +
|
|
644
|
-
`deliberate runtime switch: iapeer stop ${opts.personality}, delete the plist, re-provision.`,
|
|
746
|
+
`refusing to overwrite launchd plist ${path}: it exists but is not foundation-managed ` +
|
|
747
|
+
`(no ${IAPEER_PLIST_OWNER_KEY} sentinel) — remove or rename the foreign file first`,
|
|
645
748
|
)
|
|
646
749
|
}
|
|
647
750
|
// GLOBAL infra logs (Фаза §8): ~/.iapeer/logs/<personality>/, NOT per-peer
|
|
@@ -692,7 +795,7 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
|
|
|
692
795
|
if (fallback) environment.NOTIFIER_FALLBACK_TARGET = fallback
|
|
693
796
|
}
|
|
694
797
|
const spec: LaunchdPlistSpec = {
|
|
695
|
-
label:
|
|
798
|
+
label: target.label,
|
|
696
799
|
programArguments: [...(opts.entrypointArgv ?? defaultEntrypointArgv(env)), opts.personality, opts.runtime],
|
|
697
800
|
workingDirectory: opts.cwd,
|
|
698
801
|
environment,
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { join } from 'path'
|
|
|
18
18
|
import { spawnSync } from 'child_process'
|
|
19
19
|
import * as lockfile from 'proper-lockfile'
|
|
20
20
|
import {
|
|
21
|
+
INFRA_RUNTIME_LIST,
|
|
21
22
|
STATE_DIR,
|
|
22
23
|
LOGS_DIR,
|
|
23
24
|
isRuntime,
|
|
@@ -131,18 +132,26 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
|
|
|
131
132
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
132
133
|
|
|
133
134
|
/**
|
|
134
|
-
* True iff the peer is launchd-managed:
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
135
|
+
* True iff the peer is launchd-managed: ANY foundation-template always-on plist
|
|
136
|
+
* of the personality exists under ~/Library/LaunchAgents — the legacy base
|
|
137
|
+
* `com.iapeer.<personality>.plist` (also the shape a foreign persistent-peer
|
|
138
|
+
* plist takes) OR a per-runtime `com.iapeer.<personality>.<runtime>.plist`
|
|
139
|
+
* (multi-infra, 0.4.88). A launchd peer is in the launchd domain — KeepAlive
|
|
140
|
+
* owns its lifecycle — and the daemon must be READ-ONLY for it (deliver to it
|
|
141
|
+
* if live, but never wake / reap / respawn / sweep it). This is the hard guard
|
|
142
|
+
* against fighting launchd on the live fleet. H4 stays PERSONALITY-scoped: one
|
|
143
|
+
* launchd-held channel marks the whole peer launchd-owned, same as before.
|
|
139
144
|
*/
|
|
140
145
|
export function isLaunchdManaged(personality: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
141
146
|
// Label + LaunchAgents dir come from the SAME helpers the plist generator uses
|
|
142
147
|
// (launch/launchd.ts), so this H4 detector and installAlwaysOnPlist can never
|
|
143
|
-
// disagree on
|
|
144
|
-
// the
|
|
145
|
-
|
|
148
|
+
// disagree on the scheme. The per-runtime candidates are enumerated from
|
|
149
|
+
// INFRA_RUNTIME_LIST (the same set that gates plist generation) — cheap
|
|
150
|
+
// existsSync probes, no file reads, since this runs on every route/sweep.
|
|
151
|
+
// IAPEER_LAUNCHAGENTS_DIR overrides the dir for tests.
|
|
152
|
+
const dir = launchAgentsDir(env)
|
|
153
|
+
if (existsSync(join(dir, `${launchdLabel(personality)}.plist`))) return true
|
|
154
|
+
return INFRA_RUNTIME_LIST.some(rt => existsSync(join(dir, `${launchdLabel(personality)}.${rt}.plist`)))
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/provision/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { IapError } from '../core/errors.ts'
|
|
|
24
24
|
import { peerProfilePath } from '../storage/index.ts'
|
|
25
25
|
import { ensurePeerProfile } from '../identity/index.ts'
|
|
26
26
|
import { readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
27
|
-
import {
|
|
27
|
+
import { resolveAlwaysOnTarget, resolveExecutable } from '../launch/launchd.ts'
|
|
28
28
|
|
|
29
29
|
export interface ProvisionPeerOptions {
|
|
30
30
|
/** The peer's working directory. personality defaults to normalized basename. */
|
|
@@ -222,7 +222,10 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
222
222
|
cwd,
|
|
223
223
|
profilePath: peerProfilePath(cwd),
|
|
224
224
|
intelligence: opts.intelligence ?? profile.intelligence,
|
|
225
|
-
|
|
225
|
+
// Multi-infra: the plist ensurePeerProfile actually installed for THIS runtime
|
|
226
|
+
// (legacy base or per-runtime suffixed) — resolveAlwaysOnTarget re-resolves to
|
|
227
|
+
// the same target the install used (resolution is stable once the file exists).
|
|
228
|
+
plistPath: isInfraRuntime(opts.runtime) ? resolveAlwaysOnTarget(profile.personality, opts.runtime, env).path : undefined,
|
|
226
229
|
runtimeBin,
|
|
227
230
|
}
|
|
228
231
|
}
|
package/src/update/index.ts
CHANGED
|
@@ -42,8 +42,7 @@ import {
|
|
|
42
42
|
cycleDaemon,
|
|
43
43
|
cycleLaunchdJob,
|
|
44
44
|
isFoundationOwnedPlist,
|
|
45
|
-
|
|
46
|
-
launchdPlistPath,
|
|
45
|
+
resolveAlwaysOnTarget,
|
|
47
46
|
type DaemonRestartResult,
|
|
48
47
|
type LaunchdJobCycleResult,
|
|
49
48
|
} from '../launch/launchd.ts'
|
|
@@ -261,13 +260,18 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
|
|
|
261
260
|
const out: InfraJobRecycleResult[] = []
|
|
262
261
|
const index = readPeersIndex({ env })
|
|
263
262
|
for (const peer of index.peers) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
263
|
+
// Multi-infra (0.4.88): a personality can hold SEVERAL always-on channels
|
|
264
|
+
// (e.g. arthur: telegram on the legacy base plist + web on the per-runtime
|
|
265
|
+
// one) — re-cycle EVERY declared infra runtime's job, not just the first.
|
|
266
|
+
// resolveAlwaysOnTarget is the same scheme authority install/stop/start use;
|
|
267
|
+
// an absent plist reads as not-foundation-owned → skipped.
|
|
268
|
+
const infraRuntimes = [...new Set([peer.runtime, ...peer.runtimes])].filter(isInfraRuntime)
|
|
269
|
+
for (const runtime of infraRuntimes) {
|
|
270
|
+
const target = resolveAlwaysOnTarget(peer.personality, runtime, env)
|
|
271
|
+
if (!isFoundationOwnedPlist(target.path)) continue
|
|
272
|
+
const r = cycleLaunchdJob(target.label, target.path, env)
|
|
273
|
+
out.push({ personality: peer.personality, runtime, label: target.label, state: r.state, detail: r.detail })
|
|
274
|
+
}
|
|
271
275
|
}
|
|
272
276
|
return out
|
|
273
277
|
}
|