@cotal-ai/manager 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/manager.d.ts +48 -1
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +332 -51
- package/dist/manager.js.map +1 -1
- package/dist/resume.d.ts.map +1 -1
- package/dist/resume.js +6 -2
- package/dist/resume.js.map +1 -1
- package/package.json +4 -4
package/dist/manager.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
3
|
+
import { connect, credsAuthenticator } from "@nats-io/transport-node";
|
|
3
4
|
import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
|
|
4
5
|
import { join, dirname, resolve } from "node:path";
|
|
5
|
-
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_TTL_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, connectorServers, deprovisionAgent, firstFreeName, idFromCreds, loadAgentFile, loadCotalConfig, mintCreds, mkSecretDir, newIdentity, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile,
|
|
6
|
-
import { agentAuthState, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, findCotalRoot, loadMeshes, loadSpaceAuth, manifestExtensionNames, materializeFromManifest, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
6
|
+
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_TTL_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, connectorServers, deprovisionAgent, firstFreeName, idFromCreds, loadAgentFile, loadCotalConfig, mintCreds, mintLifecycleUid, mkSecretDir, newIdentity, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, CONTROL_AUTH_ADMIN, controlServiceSubject, } from "@cotal-ai/core";
|
|
7
|
+
import { agentActorTokenKey, agentAuthState, agentCredsDir, agentCredsKey, agentSecretFilePaths, agentSentinelCredsKey, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, findCotalRoot, loadMeshes, loadSpaceAuth, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
7
8
|
import { createRuntime, } from "./runtime/index.js";
|
|
8
9
|
import { AttachEndpoint } from "./attach-endpoint.js";
|
|
9
10
|
import { launchSpecForRun, materializePersona, launchAgentToStartOpts } from "./launch.js";
|
|
@@ -34,6 +35,13 @@ const DEPROVISION_TIMEOUT_MS = 15_000;
|
|
|
34
35
|
/** A hard preservation stop should settle quickly. The manager still waits and reports a partial
|
|
35
36
|
* cut rather than pretending a child is gone. Held in ManagerOptions so fake runtimes can shorten it. */
|
|
36
37
|
const PRESERVE_STOP_TIMEOUT_MS = 10_000;
|
|
38
|
+
/** The STABLE retirement opId for one lifecycle (#29 piece 3): deterministic from the uid, so a
|
|
39
|
+
* despawn retry, a same-name-spawn nudge, and the auth service's boot resume all drive the SAME
|
|
40
|
+
* operation (the rail's idempotence table needs exactly one op per retiring incarnation). 26 hex
|
|
41
|
+
* chars = in the lifecycle-token grammar `[a-z0-9]{26,32}`, collision-resistant. */
|
|
42
|
+
function retireOpId(lifecycleUid) {
|
|
43
|
+
return createHash("sha256").update(`retire:${lifecycleUid}`).digest("hex").slice(0, 26);
|
|
44
|
+
}
|
|
37
45
|
/** Sentinel owner-filter value that matches NO agent's `userOwner` (owner tokens never contain a
|
|
38
46
|
* dash) — what {@link Manager.psOwnerFilter} returns for an unparseable caller so a malformed
|
|
39
47
|
* principal fail-closes to an empty `ps` instead of an unbounded one. */
|
|
@@ -84,6 +92,29 @@ export class Manager {
|
|
|
84
92
|
/** Expiry stamps (`startedAt + MIN_LIFETIME`) for slots that freed while still young — a
|
|
85
93
|
* count-only, lazily-pruned recycle floor (P4c). Pruned + summed into the ceiling gate. */
|
|
86
94
|
cooling = [];
|
|
95
|
+
/** Names RESERVED PENDING RETIREMENT (#29 piece 3): a despawned agent's name stays held until
|
|
96
|
+
* the auth plane confirms its lifecycle's retirement TERMINAL over the auth-admin rail — the
|
|
97
|
+
* alias-reuse gate that closes the same-name despawn→respawn race at its root. An UNCERTAIN
|
|
98
|
+
* outcome (rail down, timeout) keeps the hold with the last attempt's copy; a same-name spawn
|
|
99
|
+
* refuses legibly AND re-fires the request. In-memory: across a manager restart the durable
|
|
100
|
+
* truth is the auth-side lifecycle head itself (an unretired head refuses issuance — the
|
|
101
|
+
* named residual this belt narrows, not replaces). */
|
|
102
|
+
retiring = new Map();
|
|
103
|
+
/** SINGLE-FLIGHT guard for {@link requestRetirement} (audit #1): one in-flight rail round-trip per
|
|
104
|
+
* (name, lifecycleUid). The detached `deprovision` call and every same-name-spawn nudge for THAT
|
|
105
|
+
* lifecycle JOIN the same promise instead of stacking independent requests that dual-enter the
|
|
106
|
+
* barrier; a fresh trigger after it settles re-drives. Keyed by (name, uid) — NOT name alone — so a
|
|
107
|
+
* same-name SUCCESSOR (which can spawn after the hold clears but before this flight's `nc.close`
|
|
108
|
+
* yield settles) never joins the predecessor's rail request and skips its own retirement. */
|
|
109
|
+
retiringFlight = new Map();
|
|
110
|
+
/** SINGLE-FLIGHT guard for {@link deprovision} (INT-2/C): one in-flight teardown per
|
|
111
|
+
* (name, lifecycleUid). The detached freeSlot teardown and every same-name-spawn nudge that
|
|
112
|
+
* re-drives it JOIN one promise instead of launching a SECOND, concurrent teardown. Without it,
|
|
113
|
+
* two teardowns race the NAME-KEYED ledger revoke: once the first frees the alias and a successor
|
|
114
|
+
* mints its own row, the second's delayed revoke (which carries no lifecycle coordinate) would
|
|
115
|
+
* delete the SUCCESSOR's standing authority. Keyed by (name, uid) so a later same-name lifecycle
|
|
116
|
+
* gets its own flight; a fresh trigger after settle re-drives only if the hold still stands. */
|
|
117
|
+
deprovisioningFlight = new Map();
|
|
87
118
|
attach;
|
|
88
119
|
ep;
|
|
89
120
|
/** Space trust material when the mesh runs in auth mode (`.cotal/auth` present);
|
|
@@ -191,6 +222,11 @@ export class Manager {
|
|
|
191
222
|
servers: this.servers,
|
|
192
223
|
channels: [],
|
|
193
224
|
creds,
|
|
225
|
+
// The supervisor registers on the roster, and an authed presence-registering endpoint is
|
|
226
|
+
// lifecycle-keyed (SPEC 13.1, fail-before-presence). The manager process is the top of its
|
|
227
|
+
// own launch chain (the operator command IS its launcher), so it mints its incarnation's
|
|
228
|
+
// uid here - one per supervisor process, never reused across restarts.
|
|
229
|
+
lifecycleUid: mintLifecycleUid(),
|
|
194
230
|
// The supervisor serves control + watches presence; it never consumes chat/dm/task
|
|
195
231
|
// (no message handler). consume:false avoids binding consumers it doesn't use — and
|
|
196
232
|
// under auth avoids trying to bind its own DM/task durables that nothing pre-created.
|
|
@@ -599,22 +635,20 @@ export class Manager {
|
|
|
599
635
|
: { owner: DEV_OWNER, actor: a.id };
|
|
600
636
|
if (!principal)
|
|
601
637
|
throw new Error(`managed agent ${a.name} has an invalid principal ${a.id}`);
|
|
602
|
-
const
|
|
603
|
-
const staticCredsPath = join(credsDir, `${a.name}.creds`);
|
|
604
|
-
const actorTokenPath = join(credsDir, `${a.name}.actor-token`);
|
|
605
|
-
const sentinelPath = join(credsDir, `${a.name}.sentinel.creds`);
|
|
638
|
+
const files = agentSecretFilePaths(this.workspaceRoot, a.name);
|
|
606
639
|
const identity = a.userOwner
|
|
607
640
|
? {
|
|
608
641
|
mode: "user",
|
|
609
642
|
owner: principal.owner,
|
|
610
643
|
actor: principal.actor,
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
644
|
+
lifecycleUid: a.lifecycleUid,
|
|
645
|
+
actorToken: { kind: "file", path: files.actorToken, sha256: this.fileDigestOrEmpty(files.actorToken) },
|
|
646
|
+
sentinelCredential: { kind: "file", path: files.sentinelCreds, sha256: this.fileDigestOrEmpty(files.sentinelCreds) },
|
|
647
|
+
health: { kind: "file", path: files.health },
|
|
614
648
|
}
|
|
615
649
|
: this.auth
|
|
616
|
-
? { mode: "static", id: principal.actor, credential: { kind: "file", path:
|
|
617
|
-
: { mode: "open", id: principal.actor };
|
|
650
|
+
? { mode: "static", id: principal.actor, lifecycleUid: a.lifecycleUid, credential: { kind: "file", path: files.creds, sha256: this.fileDigestOrEmpty(files.creds) } }
|
|
651
|
+
: { mode: "open", id: principal.actor, lifecycleUid: a.lifecycleUid };
|
|
618
652
|
const dependencies = [a.launch.source.configPath];
|
|
619
653
|
if (a.launch.source.kind === "manifest" && a.launch.source.runId)
|
|
620
654
|
dependencies.unshift(join(this.workspaceRoot, ".cotal", "run", `${a.launch.source.runId}.json`));
|
|
@@ -1038,6 +1072,13 @@ export class Manager {
|
|
|
1038
1072
|
inactive.push(`${entry.name} no longer holds retained principal ${expectedPrincipal}`);
|
|
1039
1073
|
continue;
|
|
1040
1074
|
}
|
|
1075
|
+
// The late paths (commit/finalize) must prove the SAME incarnation the incarnation-exact
|
|
1076
|
+
// readiness fence proved (§13.1): a principal-only match lets a wrong/absent-uid presence under
|
|
1077
|
+
// the reused alias satisfy commit/finalize after a readiness timeout, undoing the fence.
|
|
1078
|
+
if (managed.lifecycleUid !== entry.identity.lifecycleUid) {
|
|
1079
|
+
inactive.push(`${entry.name} manager metadata incarnation ${managed.lifecycleUid} drifted from the inventory's ${entry.identity.lifecycleUid}`);
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1041
1082
|
if (managed.handle.name !== entry.name || managed.handle.kind !== entry.launch.runtime) {
|
|
1042
1083
|
inactive.push(`${entry.name} is not attached to its exact retained ${entry.launch.runtime} handle`);
|
|
1043
1084
|
continue;
|
|
@@ -1052,8 +1093,9 @@ export class Manager {
|
|
|
1052
1093
|
inactive.push(`${entry.name} runtime status failed: ${e.message}`);
|
|
1053
1094
|
continue;
|
|
1054
1095
|
}
|
|
1055
|
-
if (!roster.some((presence) => presence.card.id === expectedPrincipal && presence.card.name === entry.name && presence.status !== "offline"
|
|
1056
|
-
|
|
1096
|
+
if (!roster.some((presence) => presence.card.id === expectedPrincipal && presence.card.name === entry.name && presence.status !== "offline" &&
|
|
1097
|
+
presence.lifecycleUid === entry.identity.lifecycleUid))
|
|
1098
|
+
inactive.push(`${entry.name} incarnation ${entry.identity.lifecycleUid} (principal ${expectedPrincipal}) is not exactly present`);
|
|
1057
1099
|
}
|
|
1058
1100
|
return inactive;
|
|
1059
1101
|
}
|
|
@@ -1168,20 +1210,19 @@ export class Manager {
|
|
|
1168
1210
|
// pass through too (a persona may hold delegable roles) — the ledger's envelope walk still
|
|
1169
1211
|
// attenuates every one of these against the spawner chain.
|
|
1170
1212
|
const scope = (opts.capabilities ?? []).filter((c) => c === "spawn" || c === "admin" || /^role:[A-Za-z0-9_-]+$/.test(c));
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1213
|
+
// LOCAL composition, hardcoded: until the manager's entry is store-threaded (the same
|
|
1214
|
+
// later slice as its renewal-owner store, with/after the membership-rw reader migration),
|
|
1215
|
+
// a pure-KMS hosted manager CANNOT read the callout material this grant needs — hosted
|
|
1216
|
+
// user-mode spawn via the manager is UNAVAILABLE, not silently degraded, until then.
|
|
1217
|
+
const secrets = workspaceSecretStore(this.workspaceRoot);
|
|
1218
|
+
const files = agentSecretFilePaths(this.workspaceRoot, name);
|
|
1219
|
+
const { actorToken: tokenPath, sentinelCreds: sentinelPath, health: healthPath } = files;
|
|
1175
1220
|
try {
|
|
1176
1221
|
// The GRANT first — it is the envelope-rule enforcement point (a delegation must sit within
|
|
1177
1222
|
// the spawner's own grant), so a refused delegation exits here having touched nothing beyond
|
|
1178
1223
|
// the ledger: no durables, no broker footprint, nothing for a corrected respawn to race.
|
|
1179
1224
|
const grant = await provider.grantAgent({
|
|
1180
|
-
|
|
1181
|
-
// later slice as its renewal-owner store, with/after the membership-rw reader migration),
|
|
1182
|
-
// a pure-KMS hosted manager CANNOT read the callout material this grant needs — hosted
|
|
1183
|
-
// user-mode spawn via the manager is UNAVAILABLE, not silently degraded, until then.
|
|
1184
|
-
store: workspaceSecretStore(this.workspaceRoot),
|
|
1225
|
+
store: secrets,
|
|
1185
1226
|
dir,
|
|
1186
1227
|
space: this.space,
|
|
1187
1228
|
owner,
|
|
@@ -1192,17 +1233,23 @@ export class Manager {
|
|
|
1192
1233
|
role: opts.role,
|
|
1193
1234
|
parent: spawnerPr ? opts.spawner : undefined,
|
|
1194
1235
|
label: opts.label,
|
|
1236
|
+
lifecycleUid: opts.lifecycleUid,
|
|
1195
1237
|
});
|
|
1196
|
-
// Durables + ACL row,
|
|
1197
|
-
// (a user agent's credential is its bearer, minted by the callout per connect
|
|
1198
|
-
|
|
1238
|
+
// Durables + ACL row, LIFECYCLE-keyed (SPEC 13.1) — the same onboarding as static agents minus
|
|
1239
|
+
// the mint (a user agent's credential is its bearer, minted by the callout per connect from the
|
|
1240
|
+
// ledger row's recorded lifecycleUid — the same value provisioned here).
|
|
1241
|
+
await this.withProvisioner((prov) => provisionAgentDurables(prov, { owner, actor: name, lifecycleUid: opts.lifecycleUid }, {
|
|
1199
1242
|
subscribe: opts.subscribe,
|
|
1200
1243
|
allowSubscribe: opts.allowSubscribe,
|
|
1201
1244
|
role: opts.role,
|
|
1202
1245
|
}));
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1246
|
+
// The store holds the source of truth; the bearer re-exec (`--token-file`) and the launch's
|
|
1247
|
+
// sentinel handoff read FILES, so materialize both at the canonical paths (under the local
|
|
1248
|
+
// FS composition, a byte-identical rewrite of the keys' own locations).
|
|
1249
|
+
await secrets.put(agentActorTokenKey(name), grant.actorToken);
|
|
1250
|
+
await secrets.put(agentSentinelCredsKey(name), grant.sentinelCreds);
|
|
1251
|
+
await materializeSecretToFile(secrets, agentActorTokenKey(name), tokenPath);
|
|
1252
|
+
await materializeSecretToFile(secrets, agentSentinelCredsKey(name), sentinelPath);
|
|
1206
1253
|
rmSync(healthPath, { force: true }); // a fresh start opens a fresh health window
|
|
1207
1254
|
const bearerCmd = [
|
|
1208
1255
|
// The manager's own invocation prefix (node + loader flags + the cotal entry) — the agent
|
|
@@ -1229,10 +1276,12 @@ export class Manager {
|
|
|
1229
1276
|
// may respawn the moment it reads the refusal, and a detached teardown would race (and
|
|
1230
1277
|
// delete) that fresh spawn's just-provisioned durables.
|
|
1231
1278
|
await provider.revokeAgent({ dir, owner, actor: name }).catch(() => { });
|
|
1279
|
+
await secrets.delete(agentActorTokenKey(name)).catch(() => { });
|
|
1280
|
+
await secrets.delete(agentSentinelCredsKey(name)).catch(() => { });
|
|
1232
1281
|
rmSync(tokenPath, { force: true });
|
|
1233
1282
|
rmSync(sentinelPath, { force: true });
|
|
1234
1283
|
rmSync(healthPath, { force: true });
|
|
1235
|
-
await this.deprovision({ id: principalKey(owner, name).key, name, userOwner: owner }).catch((err) => console.error(`rollback deprovision ${name}: ${err.message}`));
|
|
1284
|
+
await this.deprovision({ id: principalKey(owner, name).key, name, lifecycleUid: opts.lifecycleUid, userOwner: owner }).catch((err) => console.error(`rollback deprovision ${name}: ${err.message}`));
|
|
1236
1285
|
return { error: `agent auth preflight failed for "${name}": ${e.message}` };
|
|
1237
1286
|
}
|
|
1238
1287
|
}
|
|
@@ -1247,6 +1296,19 @@ export class Manager {
|
|
|
1247
1296
|
this.agents.delete(a.name);
|
|
1248
1297
|
if (floor && Date.now() - a.startedAt < MIN_LIFETIME)
|
|
1249
1298
|
this.cooling.push(a.startedAt + MIN_LIFETIME);
|
|
1299
|
+
// #29 piece 3: on a USER mesh the name is RESERVED PENDING RETIREMENT — despawn started this
|
|
1300
|
+
// lifecycle's FULL teardown (footprint + standing-authority revoke + the auth-side retirement),
|
|
1301
|
+
// and the alias frees only when all of it completes (not the retirement alone). The detached
|
|
1302
|
+
// deprovision below drives it; a failed revoke or an unreachable rail keeps the name held,
|
|
1303
|
+
// re-driven by a retry. Gate on userMode BY
|
|
1304
|
+
// CONSTRUCTION (NEW-1): a static-auth mint has no user-mode lifecycle head to retire, so the
|
|
1305
|
+
// reservation + rail request simply don't apply there (the incidental nkey-parse used to mask
|
|
1306
|
+
// this, but the intent is "user mode only", not "any principal-shaped id").
|
|
1307
|
+
if (this.userMode) {
|
|
1308
|
+
const p = parsePrincipalKey(a.id);
|
|
1309
|
+
if (p)
|
|
1310
|
+
this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: p.owner, actor: p.actor, agentId: a.id, userOwner: a.userOwner, startedAt: Date.now() });
|
|
1311
|
+
}
|
|
1250
1312
|
// Auth mode: tear down the departed agent's minted broker footprint + creds file (#159 B2). The
|
|
1251
1313
|
// process is already gone, so this must never block the slot free or throw into the caller — it runs
|
|
1252
1314
|
// detached, and a failure is logged loudly (never swallowed), not retried. The `agents` guard above
|
|
@@ -1269,34 +1331,184 @@ export class Manager {
|
|
|
1269
1331
|
async deprovision(a) {
|
|
1270
1332
|
if (!this.auth)
|
|
1271
1333
|
return; // open mesh mints no creds/durables — nothing to tear down
|
|
1334
|
+
// SINGLE-FLIGHT per (name, lifecycleUid) (INT-2/C): join an in-flight teardown for this exact
|
|
1335
|
+
// lifecycle rather than launching a second concurrent one whose delayed name-keyed revoke could
|
|
1336
|
+
// outlive the hold-clear and delete a successor's row. A fresh trigger after settle re-drives.
|
|
1337
|
+
const key = JSON.stringify([a.name, a.lifecycleUid]); // ASCII-safe, delimiter-collision-free
|
|
1338
|
+
const inflight = this.deprovisioningFlight.get(key);
|
|
1339
|
+
if (inflight)
|
|
1340
|
+
return inflight;
|
|
1341
|
+
const flight = this.driveDeprovision(a).finally(() => {
|
|
1342
|
+
if (this.deprovisioningFlight.get(key) === flight)
|
|
1343
|
+
this.deprovisioningFlight.delete(key);
|
|
1344
|
+
});
|
|
1345
|
+
this.deprovisioningFlight.set(key, flight);
|
|
1346
|
+
return flight;
|
|
1347
|
+
}
|
|
1348
|
+
/** The actual footprint teardown (wrapped by {@link deprovision}'s single-flight). */
|
|
1349
|
+
async driveDeprovision(a) {
|
|
1350
|
+
if (!this.auth)
|
|
1351
|
+
return; // guaranteed by deprovision; re-checked for the deprovisionBroker narrowing
|
|
1272
1352
|
// Drop the local creds file FIRST + unconditionally — it is a usable identity on disk, useless for a
|
|
1273
1353
|
// departed agent, so it must not survive even if the broker teardown below fails or times out. The
|
|
1274
1354
|
// teardown mints its OWN deprovisioner cred (not this file), so removing it early is independent.
|
|
1275
|
-
|
|
1355
|
+
// Migrated kinds: the store delete is the authoritative removal; the rmSync clears the FS
|
|
1356
|
+
// materialization (a byte-identical no-op under the local composition, real once the manager
|
|
1357
|
+
// is store-threaded onto a non-FS store).
|
|
1358
|
+
const secrets = workspaceSecretStore(this.workspaceRoot);
|
|
1359
|
+
const files = agentSecretFilePaths(this.workspaceRoot, a.name);
|
|
1360
|
+
await secrets.delete(agentCredsKey(a.name));
|
|
1361
|
+
rmSync(files.creds, { force: true });
|
|
1276
1362
|
if (a.userOwner) {
|
|
1277
1363
|
// USER MODE: this teardown IS revocation, not just footprint reduction — the ledger row is
|
|
1278
1364
|
// the agent's standing mint authority, so delete it (next exchange refused, next connect
|
|
1279
1365
|
// denied) and shred the secret/sentinel/health files. A copied actor token dies here; a
|
|
1280
1366
|
// still-LIVE connection ends at its bearer-bound JWT expiry (≤ the agent TTL).
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1367
|
+
await secrets.delete(agentActorTokenKey(a.name));
|
|
1368
|
+
await secrets.delete(agentSentinelCredsKey(a.name));
|
|
1369
|
+
for (const f of [files.actorToken, files.sentinelCreds, files.health])
|
|
1370
|
+
rmSync(f, { force: true });
|
|
1371
|
+
// The ledger row IS the agent's STANDING mint authority (a different store from the auth-plane
|
|
1372
|
+
// cred ledger the rail retirement covers): while it lives, a copied actor token can still mint a
|
|
1373
|
+
// fresh connect credential. So a FAILED revoke must NOT be swallowed into a clean terminal (INT-2):
|
|
1374
|
+
// mark the standing authority live on the hold so the retirement can never free the name (a freed
|
|
1375
|
+
// name says "this lifecycle is fully gone" - false while the mint authority stands), and carry a
|
|
1376
|
+
// legible operator copy. A retry re-drives this whole teardown (the same-name-spawn nudge routes
|
|
1377
|
+
// through deprovision, not the rail alone), so the revoke is re-attempted, not stranded.
|
|
1378
|
+
const holdRevoke = this.retiring.get(a.name);
|
|
1379
|
+
if (holdRevoke && holdRevoke.lifecycleUid === a.lifecycleUid)
|
|
1380
|
+
holdRevoke.standingAuthorityLive = true;
|
|
1284
1381
|
try {
|
|
1285
1382
|
await resolveAuthProvider().revokeAgent({
|
|
1286
1383
|
dir: userAuthStateDir(this.workspaceRoot, this.space),
|
|
1287
1384
|
owner: a.userOwner,
|
|
1288
1385
|
actor: a.name,
|
|
1289
1386
|
});
|
|
1387
|
+
const done = this.retiring.get(a.name);
|
|
1388
|
+
if (done && done.lifecycleUid === a.lifecycleUid)
|
|
1389
|
+
done.standingAuthorityLive = false;
|
|
1290
1390
|
}
|
|
1291
1391
|
catch (e) {
|
|
1392
|
+
const h = this.retiring.get(a.name);
|
|
1393
|
+
if (h && h.lifecycleUid === a.lifecycleUid)
|
|
1394
|
+
h.lastError = `the agent's standing mint authority could not be revoked (${e.message}); the name stays held so a copied actor token cannot mint fresh credentials. NEXT: a same-name spawn re-drives the full teardown (including the revoke), or recover the auth state.`;
|
|
1292
1395
|
console.error(`revoke agent grant ${a.name}: ${e.message}`);
|
|
1293
1396
|
}
|
|
1294
1397
|
}
|
|
1295
|
-
|
|
1398
|
+
await this.deprovisionBroker(a);
|
|
1399
|
+
// #29 piece 3: after the footprint teardown, ask the AUTH plane to RETIRE the lifecycle over
|
|
1400
|
+
// the auth-admin rail. The rail re-checks the space-manager lease at serve time; the terminal
|
|
1401
|
+
// (or an already-retired answer) clears the name reservation. Failures keep the hold with
|
|
1402
|
+
// their operator copy — legible, retryable, never a silent half-state.
|
|
1403
|
+
await this.requestRetirement(a);
|
|
1404
|
+
}
|
|
1405
|
+
/** Request the auth-side retirement of a departed agent's lifecycle (#29 piece 3): an ephemeral
|
|
1406
|
+
* `retirement-requester` credential (request + reply only), the generic `retireLifecycle` op,
|
|
1407
|
+
* a STABLE opId (derived from the lifecycleUid, so every retry re-drives the SAME operation),
|
|
1408
|
+
* and the four-outcome handling in operator vocabulary. */
|
|
1409
|
+
async requestRetirement(a) {
|
|
1410
|
+
if (!this.userMode)
|
|
1411
|
+
return; // NEW-1: lifecycle retirement is a user-mesh concept; a static mint has no head to retire
|
|
1412
|
+
// SINGLE-FLIGHT per (name, lifecycleUid) (audit #1): the detached deprovision call and every
|
|
1413
|
+
// same-name-spawn nudge for THIS lifecycle share ONE in-flight retirement, so concurrent triggers
|
|
1414
|
+
// never stack independent rail requests that dual-enter runAgentRetirementBarrier. Keyed by
|
|
1415
|
+
// (name, uid), NOT name alone: driveRetirement clears the hold on rail ok but then yields at
|
|
1416
|
+
// `nc.close()` with the flight still stored, so the alias can free and a SUCCESSOR (new uid) spawn.
|
|
1417
|
+
// A name-only key would let that successor's own teardown JOIN the predecessor's still-pending
|
|
1418
|
+
// flight and never send its OWN retirement — leaving the successor's lifecycle unretired. The uid in
|
|
1419
|
+
// the key gives the successor a disjoint flight (mirrors {@link deprovisioningFlight}). A fresh
|
|
1420
|
+
// trigger after settle re-drives (a still-present hold => retirement not yet confirmed).
|
|
1421
|
+
const key = JSON.stringify([a.name, a.lifecycleUid]);
|
|
1422
|
+
const inflight = this.retiringFlight.get(key);
|
|
1423
|
+
if (inflight)
|
|
1424
|
+
return inflight;
|
|
1425
|
+
const flight = this.driveRetirement(a).finally(() => {
|
|
1426
|
+
if (this.retiringFlight.get(key) === flight)
|
|
1427
|
+
this.retiringFlight.delete(key);
|
|
1428
|
+
});
|
|
1429
|
+
this.retiringFlight.set(key, flight);
|
|
1430
|
+
return flight;
|
|
1431
|
+
}
|
|
1432
|
+
/** The rail round-trip for one retirement (wrapped by {@link requestRetirement}'s single-flight). */
|
|
1433
|
+
async driveRetirement(a) {
|
|
1434
|
+
if (!this.auth)
|
|
1435
|
+
return; // guaranteed by requestRetirement; re-checked for the type narrowing below
|
|
1436
|
+
const held = this.retiring.get(a.name);
|
|
1437
|
+
const me = parsePrincipalKey(this.ep.ref().id);
|
|
1438
|
+
const target = parsePrincipalKey(a.id);
|
|
1439
|
+
if (!me || !target) {
|
|
1440
|
+
if (held)
|
|
1441
|
+
held.lastError = "the manager or target principal could not be derived; the retirement was not requested";
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
const uncertain = (why) => `the despawn stopped "${a.name}", but the retirement's completion could NOT be confirmed (${why}). The name stays held - not failed, not done - and a same-name spawn re-drives the same teardown; the auth service also finishes any started retirement on its next boot. NEXT: if the auth rail stays unreachable, recover the stack (\`cotal supervise\`), then re-attempt the same-name spawn.`;
|
|
1445
|
+
try {
|
|
1446
|
+
const creds = await mintCreds(this.auth, newIdentity(), "retirement-requester", { retirementRequester: { owner: me.owner, actor: me.actor } });
|
|
1447
|
+
const nc = await connect({ servers: this.servers ?? DEFAULT_SERVER, authenticator: credsAuthenticator(new TextEncoder().encode(creds)), maxReconnectAttempts: 0 });
|
|
1448
|
+
try {
|
|
1449
|
+
const subject = controlServiceSubject(this.space, CONTROL_AUTH_ADMIN, me.owner, me.actor);
|
|
1450
|
+
const m = await nc.request(subject, JSON.stringify({ op: "retireLifecycle", args: { owner: target.owner, actor: target.actor, lifecycleUid: a.lifecycleUid, opId: retireOpId(a.lifecycleUid) } }), { timeout: 20_000, noMux: true, reply: `${subject}.reply.${randomUUID()}` });
|
|
1451
|
+
const r = m.json();
|
|
1452
|
+
if (r.ok) {
|
|
1453
|
+
// CAS the hold clear (audit #1 ABA): free the alias ONLY if the current hold is still THIS
|
|
1454
|
+
// lifecycle's - a late reply for a retired predecessor must never clear a successor's newer hold.
|
|
1455
|
+
const cur = this.retiring.get(a.name);
|
|
1456
|
+
if (cur && cur.lifecycleUid === a.lifecycleUid) {
|
|
1457
|
+
if (cur.standingAuthorityLive) {
|
|
1458
|
+
// INT-2: the auth-plane lifecycle retired, but the manager-side STANDING mint authority is
|
|
1459
|
+
// not yet revoked (a failed revoke). Freeing the name here would be a false terminal (a
|
|
1460
|
+
// copied token could still mint), so keep the hold with its revoke-failure copy; a retry
|
|
1461
|
+
// re-drives the full teardown (revoke included).
|
|
1462
|
+
console.error(`despawn ${a.name}: the auth-plane lifecycle retired, but the standing mint authority is not yet revoked; the name stays held. ${cur.lastError ?? ""}`);
|
|
1463
|
+
}
|
|
1464
|
+
else {
|
|
1465
|
+
this.retiring.delete(a.name);
|
|
1466
|
+
console.error(`despawn ${a.name}: the agent's retirement completed; the name is free for reuse`);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
else {
|
|
1470
|
+
console.error(`despawn ${a.name}: retirement confirmed for a prior lifecycle of "${a.name}"; the current hold is left intact`);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
// The rail's refusal is already the operator copy (lease-loss/stale/foreign-op faces,
|
|
1475
|
+
// full-no-op statements included) - surface it INTACT, never flattened.
|
|
1476
|
+
if (held)
|
|
1477
|
+
held.lastError = r.error ?? "the auth service refused the retirement without a reason";
|
|
1478
|
+
console.error(`despawn ${a.name}: ${r.error ?? "the auth service refused the retirement without a reason"}`);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
finally {
|
|
1482
|
+
await nc.close().catch(() => { });
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
catch (e) {
|
|
1486
|
+
const copy = uncertain(e.message);
|
|
1487
|
+
if (held)
|
|
1488
|
+
held.lastError = copy;
|
|
1489
|
+
console.error(`despawn ${a.name}: ${copy}`);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
/** The teardown's ASYNC BROKER PHASE: mint the ephemeral target-pinned deprovisioner cred and
|
|
1493
|
+
* delete the agent's broker footprint (dm_/dlv_ durables + read-ACL row). Split from
|
|
1494
|
+
* {@link deprovision} because it runs LAST in the ordered teardown chain — after the creds/secret
|
|
1495
|
+
* shred and the awaited ledger revoke, which precede it in that same single-flighted chain (the
|
|
1496
|
+
* revoke is awaited and can be deliberately slow, so it is not merely a synchronous prefix). The name
|
|
1497
|
+
* is NOT freed while any teardown phase is still in flight — the hold clears only after the
|
|
1498
|
+
* standing-authority revoke AND the lifecycle retirement both confirm (see {@link driveRetirement}) —
|
|
1499
|
+
* but the deletes here are still lifecycle-uid-pinned so even a replayed/stale teardown can never
|
|
1500
|
+
* reach a same-name successor's footprint (its names embed a different uid). */
|
|
1501
|
+
async deprovisionBroker(a) {
|
|
1502
|
+
// LIFECYCLE-PINNED (SPEC 13.1): both the credential's exact-name grants and the delete names
|
|
1503
|
+
// carry a.lifecycleUid, so a stale/replayed teardown for this retired incarnation is broker-denied
|
|
1504
|
+
// against a same-name successor's footprint (its names embed a different uid).
|
|
1505
|
+
const creds = await mintCreds(this.auth, newIdentity(), "deprovisioner", {
|
|
1506
|
+
deprovisionTarget: { principal: a.id, lifecycleUid: a.lifecycleUid },
|
|
1507
|
+
});
|
|
1296
1508
|
// Bound the detached broker teardown so a wedged broker can't leave the deprovision promise pending
|
|
1297
1509
|
// forever with no log — the timeout rejects into freeSlot's fail-loud `.catch` (paired with the
|
|
1298
1510
|
// helper's own fail-fast connect). The durables/ACL row still fall to space teardown as a backstop.
|
|
1299
|
-
await withTimeout(deprovisionAgent({ servers: this.servers ?? DEFAULT_SERVER, space: this.space, targetId: a.id, creds }), DEPROVISION_TIMEOUT_MS, `deprovision ${a.name} (${a.id}): broker teardown timed out`);
|
|
1511
|
+
await withTimeout(deprovisionAgent({ servers: this.servers ?? DEFAULT_SERVER, space: this.space, targetId: a.id, lifecycleUid: a.lifecycleUid, creds }), DEPROVISION_TIMEOUT_MS, `deprovision ${a.name} (${a.id}): broker teardown timed out`);
|
|
1300
1512
|
}
|
|
1301
1513
|
/** Reap a parent's children on its exit (P4b). Every descendant remains managed until the runtime's
|
|
1302
1514
|
* authoritative wait proves exit; the wait participates in the lifecycle drain, so preservation can
|
|
@@ -1333,7 +1545,7 @@ export class Manager {
|
|
|
1333
1545
|
* in-flight (reserved) slots. Lets a colliding spawn auto-number instead of being rejected, so
|
|
1334
1546
|
* callers never have to invent a unique name. */
|
|
1335
1547
|
uniqueName(base) {
|
|
1336
|
-
return firstFreeName(base, (n) => this.agents.has(n) || this.reserved.has(n));
|
|
1548
|
+
return firstFreeName(base, (n) => this.agents.has(n) || this.reserved.has(n) || this.retiring.has(n));
|
|
1337
1549
|
}
|
|
1338
1550
|
/** Spawn a teammate by persona ref (`name` loads `.cotal/agents/<name>.md`; the peer presents
|
|
1339
1551
|
* under that file's own `name:`), as if a peer asked via the control plane. Used to pre-spawn the
|
|
@@ -1689,6 +1901,20 @@ export class Manager {
|
|
|
1689
1901
|
const idErr = this.nameError(identityName);
|
|
1690
1902
|
if (idErr)
|
|
1691
1903
|
return { ok: false, error: opts.resolved ? `launch agent: ${idErr}` : `persona ${configPath}: ${idErr}` };
|
|
1904
|
+
// The alias-reuse gate (#29 piece 3): a name whose previous agent is still retiring REFUSES
|
|
1905
|
+
// legibly (never a silent suffix), and the refusal re-drives the FULL durable teardown so
|
|
1906
|
+
// "retry the spawn" is also the nudge. It routes through `deprovision` (not `requestRetirement`
|
|
1907
|
+
// alone) so a retry re-drives the standing-authority revoke (INT-2) AND the broker cleanup before
|
|
1908
|
+
// any hold-clear (C): the alias must not free while the durable teardown or the revoke is still
|
|
1909
|
+
// outstanding. All the teardown ops are idempotent, and the rail request is single-flighted.
|
|
1910
|
+
const held = this.retiring.get(identityName);
|
|
1911
|
+
if (held !== undefined) {
|
|
1912
|
+
void this.deprovision({ id: held.agentId, name: identityName, lifecycleUid: held.lifecycleUid, userOwner: held.userOwner }).catch(() => { });
|
|
1913
|
+
return {
|
|
1914
|
+
ok: false,
|
|
1915
|
+
error: `the name "${identityName}" is reserved pending retirement: its previous agent's despawn started that lifecycle's teardown (footprint + standing-authority revoke + auth-side retirement), and the name frees only when all of it completes${held.lastError !== undefined ? ` (last attempt: ${held.lastError})` : ""}. NEXT: wait a moment and retry this spawn (retrying re-drives the whole teardown), or pick another name.`,
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1692
1918
|
if (variant && !connector.supportsModelVariant)
|
|
1693
1919
|
return { ok: false, error: `${agent} connector does not support model variants (variant)` };
|
|
1694
1920
|
const name = this.uniqueName(identityName);
|
|
@@ -1721,6 +1947,10 @@ export class Manager {
|
|
|
1721
1947
|
// A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
|
|
1722
1948
|
// COTAL_ID); the seed is retained to mint matching creds later.
|
|
1723
1949
|
const identity = newIdentity();
|
|
1950
|
+
// The incarnation's lifecycle UID (SPEC 13.1), minted ONCE per spawn: every lifecycle-keyed
|
|
1951
|
+
// broker resource (dm_/dlv_/chathist_ durables, ACL row, memberships) and the teardown
|
|
1952
|
+
// credential carry it, so a same-name successor's footprint is name-disjoint by construction.
|
|
1953
|
+
const lifecycleUid = mintLifecycleUid();
|
|
1724
1954
|
// In auth mode, mint the agent's creds from the space signing key and write them where the
|
|
1725
1955
|
// spawned session reads them (COTAL_CREDS path). Open mesh → no creds. Scope = the resolved
|
|
1726
1956
|
// subscribe/allowSubscribe (read) + allowPublish (post, default-deny).
|
|
@@ -1737,6 +1967,7 @@ export class Manager {
|
|
|
1737
1967
|
role,
|
|
1738
1968
|
capabilities,
|
|
1739
1969
|
label: ref,
|
|
1970
|
+
lifecycleUid,
|
|
1740
1971
|
});
|
|
1741
1972
|
if ("error" in prep) {
|
|
1742
1973
|
this.reserved.delete(name);
|
|
@@ -1744,7 +1975,7 @@ export class Manager {
|
|
|
1744
1975
|
}
|
|
1745
1976
|
userLaunch = prep.launch;
|
|
1746
1977
|
userOwner = prep.owner;
|
|
1747
|
-
provisioned = { id: principalKey(prep.owner, name).key, name, userOwner: prep.owner };
|
|
1978
|
+
provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner };
|
|
1748
1979
|
}
|
|
1749
1980
|
else if (this.auth) {
|
|
1750
1981
|
// Pre-create the agent's bind-only chat (+ DM + role TASK) durables and mint its scoped creds
|
|
@@ -1757,11 +1988,16 @@ export class Manager {
|
|
|
1757
1988
|
allowPublish,
|
|
1758
1989
|
role,
|
|
1759
1990
|
capabilities,
|
|
1991
|
+
lifecycleUid,
|
|
1760
1992
|
}));
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1993
|
+
// Store first (the source of truth), then materialize: `buildLaunch` hands the CHILD this
|
|
1994
|
+
// file path, so the cred must exist as a file regardless of the store behind the seam.
|
|
1995
|
+
// LOCAL composition, hardcoded, same posture as provisionUserAgent's grant store above.
|
|
1996
|
+
const secrets = workspaceSecretStore(this.workspaceRoot);
|
|
1997
|
+
credsPath = agentSecretFilePaths(this.workspaceRoot, name).creds;
|
|
1998
|
+
await secrets.put(agentCredsKey(name), creds);
|
|
1999
|
+
await materializeSecretToFile(secrets, agentCredsKey(name), credsPath);
|
|
2000
|
+
provisioned = { id: identity.id, name, lifecycleUid }; // footprint now exists — the finally rolls it back if the spawn throws
|
|
1765
2001
|
}
|
|
1766
2002
|
// Personal MCP servers the operator opted to share with manager-spawned agents of this type
|
|
1767
2003
|
// (cotal config; default none → isolated, the memory-safe default this guards), narrowed by
|
|
@@ -1785,6 +2021,10 @@ export class Manager {
|
|
|
1785
2021
|
id: userLaunch ? undefined : identity.id,
|
|
1786
2022
|
creds: credsPath,
|
|
1787
2023
|
userAuth: userLaunch,
|
|
2024
|
+
// The incarnation's lifecycle UID: the agent endpoint binds its lifecycle-keyed dm/dlv/
|
|
2025
|
+
// chathist durables by this exact value (its creds pin the same names, so a mismatch fails
|
|
2026
|
+
// at the broker, never silently).
|
|
2027
|
+
lifecycleUid,
|
|
1788
2028
|
servers: this.servers,
|
|
1789
2029
|
configPath,
|
|
1790
2030
|
model,
|
|
@@ -1816,6 +2056,7 @@ export class Manager {
|
|
|
1816
2056
|
role,
|
|
1817
2057
|
agent,
|
|
1818
2058
|
id: userLaunch ? principalKey(userLaunch.owner, name).key : identity.id,
|
|
2059
|
+
lifecycleUid,
|
|
1819
2060
|
...(userLaunch ? { userOwner } : { seed: identity.seed }),
|
|
1820
2061
|
spawner: spawner ?? this.ep.ref().id,
|
|
1821
2062
|
authorityParent: userLaunch && spawner && parsePrincipalKey(spawner) ? spawner : undefined,
|
|
@@ -1981,15 +2222,21 @@ export class Manager {
|
|
|
1981
2222
|
if (entry.identity.mode === "static") {
|
|
1982
2223
|
if (!this.auth || this.userMode)
|
|
1983
2224
|
throw new Error(`retained agent ${entry.name} is static-auth but the current manager is not`);
|
|
1984
|
-
const expected = resolve(
|
|
2225
|
+
const expected = resolve(agentSecretFilePaths(this.workspaceRoot, entry.name).creds);
|
|
1985
2226
|
if (resolve(entry.identity.credential.path) !== expected)
|
|
1986
2227
|
throw new Error(`retained credential reference for ${entry.name} is not the manager-owned path ${expected}`);
|
|
1987
2228
|
let credentialText;
|
|
1988
2229
|
try {
|
|
2230
|
+
// The lstat guards the FS MATERIALIZATION the child will read at launch; the identity check
|
|
2231
|
+
// runs on the store's value — the source of truth (byte-identical here, the local FS
|
|
2232
|
+
// composition resolves the key to this same path).
|
|
1989
2233
|
const st = lstatSync(expected);
|
|
1990
2234
|
if (!st.isFile() || st.isSymbolicLink())
|
|
1991
2235
|
throw new Error("not a regular non-symlink file");
|
|
1992
|
-
|
|
2236
|
+
const stored = await workspaceSecretStore(this.workspaceRoot).get(agentCredsKey(entry.name));
|
|
2237
|
+
if (stored === undefined)
|
|
2238
|
+
throw new Error("the credential is not in the secret store");
|
|
2239
|
+
credentialText = stored;
|
|
1993
2240
|
const actual = idFromCreds(credentialText);
|
|
1994
2241
|
if (actual !== entry.identity.id)
|
|
1995
2242
|
throw new Error(`retained credential identity ${actual} does not match inventory principal ${entry.identity.id}`);
|
|
@@ -2006,10 +2253,20 @@ export class Manager {
|
|
|
2006
2253
|
throw new Error(`retained agent ${entry.name} is user-auth but the current manager is not`);
|
|
2007
2254
|
try {
|
|
2008
2255
|
const provider = resolveAuthProvider();
|
|
2009
|
-
|
|
2010
|
-
|
|
2256
|
+
// Mirror the static branch's expected-path equality: the store reads below are keyed by
|
|
2257
|
+
// NAME, so a retained record aimed at a foreign path would otherwise pass its digest checks
|
|
2258
|
+
// there while a different secret gets validated here. Canonical paths only.
|
|
2259
|
+
const files = agentSecretFilePaths(this.workspaceRoot, entry.name);
|
|
2260
|
+
if (resolve(entry.identity.actorToken.path) !== resolve(files.actorToken) ||
|
|
2261
|
+
resolve(entry.identity.sentinelCredential.path) !== resolve(files.sentinelCreds))
|
|
2262
|
+
throw new Error(`retained identity references are not the manager-owned paths under ${agentCredsDir(this.workspaceRoot)}`);
|
|
2263
|
+
const secrets = workspaceSecretStore(this.workspaceRoot);
|
|
2264
|
+
const actorToken = await secrets.get(agentActorTokenKey(entry.name));
|
|
2265
|
+
const sentinelCreds = await secrets.get(agentSentinelCredsKey(entry.name));
|
|
2266
|
+
if (actorToken === undefined || sentinelCreds === undefined)
|
|
2267
|
+
throw new Error("the retained actor token / sentinel credential is not in the secret store");
|
|
2011
2268
|
const adopted = await provider.validateRetainedAgent({
|
|
2012
|
-
store:
|
|
2269
|
+
store: secrets,
|
|
2013
2270
|
dir: userAuthStateDir(this.workspaceRoot, this.space),
|
|
2014
2271
|
space: this.space,
|
|
2015
2272
|
owner: entry.identity.owner,
|
|
@@ -2019,6 +2276,11 @@ export class Manager {
|
|
|
2019
2276
|
});
|
|
2020
2277
|
if (adopted.owner !== entry.identity.owner || adopted.actor !== entry.identity.actor)
|
|
2021
2278
|
throw new Error(`auth provider returned a replacement principal; expected ${entry.identity.owner}.${entry.identity.actor}`);
|
|
2279
|
+
// Bind the inventory's uid to the CURRENT authority row BEFORE any spawn: a corrupt or
|
|
2280
|
+
// admin-supplied inventory naming a different incarnation is refused at pre-effect validation,
|
|
2281
|
+
// never left to broker-fail after the child is already running (SPEC §13.1).
|
|
2282
|
+
if (adopted.lifecycleUid !== entry.identity.lifecycleUid)
|
|
2283
|
+
throw new Error(`retained user authority for ${entry.identity.owner}.${entry.identity.actor} is incarnation ${adopted.lifecycleUid}, not the inventory's ${entry.identity.lifecycleUid}; a resume binds the exact recovered uid before any spawn (SPEC 13.1)`);
|
|
2022
2284
|
if (!sameStrings(adopted.allowSubscribe, entry.launch.allowSubscribe) ||
|
|
2023
2285
|
!sameStrings(adopted.allowPublish, entry.launch.allowPublish) ||
|
|
2024
2286
|
!sameStrings(adopted.scope, entry.launch.capabilities) ||
|
|
@@ -2140,6 +2402,12 @@ export class Manager {
|
|
|
2140
2402
|
id: authority.id,
|
|
2141
2403
|
creds: authority.creds,
|
|
2142
2404
|
userAuth: authority.userAuth,
|
|
2405
|
+
// Recover the ORIGINAL incarnation uid (never a fresh mint on resume): the child endpoint
|
|
2406
|
+
// binds its lifecycle-keyed dm/dlv/chathist durables by this exact value, and its creds pin
|
|
2407
|
+
// the same names. Omitting it here (as the pre-fix resume path did) leaves the resumed child
|
|
2408
|
+
// with no COTAL_LIFECYCLE_UID: static/user fail the connector auth gate and open self-mints a
|
|
2409
|
+
// fresh uid that orphans the preserved durables and never matches the readiness fence.
|
|
2410
|
+
lifecycleUid: entry.identity.lifecycleUid,
|
|
2143
2411
|
servers: this.servers,
|
|
2144
2412
|
configPath: entry.launch.source.configPath,
|
|
2145
2413
|
model: entry.launch.model,
|
|
@@ -2178,6 +2446,8 @@ export class Manager {
|
|
|
2178
2446
|
role: entry.role,
|
|
2179
2447
|
agent: entry.launch.connector,
|
|
2180
2448
|
id: entry.identity.mode === "user" ? principalKey(entry.identity.owner, entry.identity.actor).key : entry.identity.id,
|
|
2449
|
+
// Recover the ORIGINAL incarnation uid the durables are keyed by (never a fresh mint on resume).
|
|
2450
|
+
lifecycleUid: entry.identity.lifecycleUid,
|
|
2181
2451
|
userOwner: entry.identity.mode === "user" ? entry.identity.owner : undefined,
|
|
2182
2452
|
spawner: entry.spawner,
|
|
2183
2453
|
authorityParent: entry.authorityParent,
|
|
@@ -2231,7 +2501,9 @@ export class Manager {
|
|
|
2231
2501
|
probeStaticCredential(creds) {
|
|
2232
2502
|
return probeConnect(this.servers ?? DEFAULT_SERVER, { creds, timeoutMs: 5_000 });
|
|
2233
2503
|
}
|
|
2234
|
-
/** An uncertain resume remains non-destructive until exact-principal
|
|
2504
|
+
/** An uncertain resume remains non-destructive until exact-principal AND exact-incarnation presence
|
|
2505
|
+
* arrives later. Same predicate as the readiness fence: a principal-only match would let a
|
|
2506
|
+
* wrong/absent-uid presence under the reused alias clear cleanup suppression on another incarnation. */
|
|
2235
2507
|
watchResumeAdoption(a) {
|
|
2236
2508
|
const wanted = this.managedPrincipal(a);
|
|
2237
2509
|
const onPresence = () => {
|
|
@@ -2239,7 +2511,7 @@ export class Manager {
|
|
|
2239
2511
|
this.ep.off("presence", onPresence);
|
|
2240
2512
|
return;
|
|
2241
2513
|
}
|
|
2242
|
-
if (!this.ep.getRoster().some((p) => p.card.id === wanted && p.status !== "offline"))
|
|
2514
|
+
if (!this.ep.getRoster().some((p) => p.card.id === wanted && p.status !== "offline" && p.lifecycleUid === a.lifecycleUid))
|
|
2243
2515
|
return;
|
|
2244
2516
|
if (!this.resumeRequired)
|
|
2245
2517
|
a.suppressCleanup = false;
|
|
@@ -2274,7 +2546,16 @@ export class Manager {
|
|
|
2274
2546
|
// through managedPrincipal or a static launch can never be seen joining (every static spawn would
|
|
2275
2547
|
// resolve "uncertain"; caught by the lifecycle e2e).
|
|
2276
2548
|
const wanted = this.managedPrincipal(a);
|
|
2277
|
-
|
|
2549
|
+
// READINESS LIFECYCLE FENCE (SPEC 13.1): match the exact principal AND the exact lifecycle uid
|
|
2550
|
+
// the manager minted for THIS spawn (presence carries it, §6/:315). The endpoint's own
|
|
2551
|
+
// register-only broker proof is gated on the CLIENT-authored `card.kind`, which a managed child
|
|
2552
|
+
// holding a valid agent credential could set to "endpoint" to skip - so it is defense-in-depth,
|
|
2553
|
+
// NOT the authority boundary. This equality is: the manager (not the child) owns the expected
|
|
2554
|
+
// uid, so a ghost that advertises a wrong/absent uid never reports STARTED, whatever kind it
|
|
2555
|
+
// claims. The manager threads the uid into EVERY mode's launch (open included), so the child
|
|
2556
|
+
// adopts it over a self-mint and publishes it in presence; the uid is absent only from a peer
|
|
2557
|
+
// the manager never launched (a pure operator/daemon connection that never registers).
|
|
2558
|
+
const joined = () => this.ep.getRoster().some((p) => p.card.id === wanted && p.status !== "offline" && p.lifecycleUid === a.lifecycleUid);
|
|
2278
2559
|
return await new Promise((resolve) => {
|
|
2279
2560
|
let done = false;
|
|
2280
2561
|
let timer;
|
|
@@ -2508,7 +2789,7 @@ export class Manager {
|
|
|
2508
2789
|
// FAIL-CLOSED: a failed record is the failure + repair sentence; a missing/malformed or
|
|
2509
2790
|
// stale record on a live agent is auth-unknown/auth-stale, NEVER silently healthy.
|
|
2510
2791
|
const health = a.userOwner
|
|
2511
|
-
? agentAuthState(
|
|
2792
|
+
? agentAuthState(agentSecretFilePaths(this.workspaceRoot, a.name).health)
|
|
2512
2793
|
: undefined;
|
|
2513
2794
|
return {
|
|
2514
2795
|
name: a.name,
|