@cotal-ai/manager 0.12.0 → 0.13.1

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.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, writeSecretFile, subjectMatches, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, } from "@cotal-ai/core";
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 credsDir = join(authDir(this.workspaceRoot), "creds");
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
- actorToken: { kind: "file", path: actorTokenPath, sha256: this.fileDigestOrEmpty(actorTokenPath) },
612
- sentinelCredential: { kind: "file", path: sentinelPath, sha256: this.fileDigestOrEmpty(sentinelPath) },
613
- health: { kind: "file", path: join(credsDir, `${a.name}.auth-health.json`) },
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: staticCredsPath, sha256: this.fileDigestOrEmpty(staticCredsPath) } }
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
- inactive.push(`${entry.name} principal ${expectedPrincipal} is not exactly present`);
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
- const credsDir = join(authDir(this.workspaceRoot), "creds");
1172
- const tokenPath = join(credsDir, `${name}.actor-token`);
1173
- const sentinelPath = join(credsDir, `${name}.sentinel.creds`);
1174
- const healthPath = join(credsDir, `${name}.auth-health.json`);
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
- // LOCAL composition, hardcoded: until the manager's entry is store-threaded (the same
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, principal-keyed — the same onboarding as static agents minus the mint
1197
- // (a user agent's credential is its bearer, minted by the callout per connect).
1198
- await this.withProvisioner((prov) => provisionAgentDurables(prov, { owner, actor: name }, {
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
- mkSecretDir(credsDir);
1204
- writeSecretFile(tokenPath, grant.actorToken);
1205
- writeSecretFile(sentinelPath, grant.sentinelCreds);
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
- rmSync(join(authDir(this.workspaceRoot), "creds", `${a.name}.creds`), { force: true });
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
- const credsDir = join(authDir(this.workspaceRoot), "creds");
1282
- for (const f of [`${a.name}.actor-token`, `${a.name}.sentinel.creds`, `${a.name}.auth-health.json`])
1283
- rmSync(join(credsDir, f), { force: true });
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
- const creds = await mintCreds(this.auth, newIdentity(), "deprovisioner", { deprovisionTarget: a.id });
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
- credsPath = join(authDir(this.workspaceRoot), "creds", `${name}.creds`);
1762
- mkSecretDir(dirname(credsPath)); // harden the creds dir before the cred lands
1763
- writeSecretFile(credsPath, creds);
1764
- provisioned = { id: identity.id, name }; // footprint now exists — the finally rolls it back if the spawn throws
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(authDir(this.workspaceRoot), "creds", `${entry.name}.creds`);
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
- credentialText = readFileSync(expected, "utf8");
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
- const actorToken = readFileSync(entry.identity.actorToken.path, "utf8");
2010
- const sentinelCreds = readFileSync(entry.identity.sentinelCredential.path, "utf8");
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: workspaceSecretStore(this.workspaceRoot),
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 presence arrives later. */
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
- const joined = () => this.ep.getRoster().some((p) => p.card.id === wanted && p.status !== "offline");
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(join(authDir(this.workspaceRoot), "creds", `${a.name}.auth-health.json`))
2792
+ ? agentAuthState(agentSecretFilePaths(this.workspaceRoot, a.name).health)
2512
2793
  : undefined;
2513
2794
  return {
2514
2795
  name: a.name,