@cotal-ai/manager 0.67.0 → 0.69.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.
Files changed (40) hide show
  1. package/dist/commands.js +11 -41
  2. package/dist/commands.js.map +1 -1
  3. package/dist/endpoint-evict.d.ts +10 -1
  4. package/dist/endpoint-evict.d.ts.map +1 -1
  5. package/dist/endpoint-evict.js +31 -51
  6. package/dist/endpoint-evict.js.map +1 -1
  7. package/dist/holder-liveness.d.ts.map +1 -1
  8. package/dist/holder-liveness.js +11 -48
  9. package/dist/holder-liveness.js.map +1 -1
  10. package/dist/manager-service-contract.d.ts +5 -1
  11. package/dist/manager-service-contract.d.ts.map +1 -1
  12. package/dist/manager-service-contract.js +7 -3
  13. package/dist/manager-service-contract.js.map +1 -1
  14. package/dist/manager.d.ts +19 -11
  15. package/dist/manager.d.ts.map +1 -1
  16. package/dist/manager.js +185 -151
  17. package/dist/manager.js.map +1 -1
  18. package/dist/remote-authority.d.ts +14 -1
  19. package/dist/remote-authority.d.ts.map +1 -1
  20. package/dist/remote-authority.js +50 -12
  21. package/dist/remote-authority.js.map +1 -1
  22. package/dist/remote-register.d.ts +1 -1
  23. package/dist/remote-register.d.ts.map +1 -1
  24. package/dist/remote-register.js +1 -9
  25. package/dist/remote-register.js.map +1 -1
  26. package/dist/resume.d.ts.map +1 -1
  27. package/dist/resume.js +24 -7
  28. package/dist/resume.js.map +1 -1
  29. package/dist/roster.d.ts.map +1 -1
  30. package/dist/roster.js +3 -9
  31. package/dist/roster.js.map +1 -1
  32. package/dist/run-hosting.d.ts +2 -1
  33. package/dist/run-hosting.d.ts.map +1 -1
  34. package/dist/run-hosting.js +8 -6
  35. package/dist/run-hosting.js.map +1 -1
  36. package/dist/static-lifecycle.d.ts +9 -5
  37. package/dist/static-lifecycle.d.ts.map +1 -1
  38. package/dist/static-lifecycle.js +18 -13
  39. package/dist/static-lifecycle.js.map +1 -1
  40. package/package.json +12 -12
package/dist/manager.js CHANGED
@@ -4,8 +4,8 @@ import { hostname } from "node:os";
4
4
  import { credsAuthenticator } from "@nats-io/transport-node";
5
5
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
6
6
  import { isAbsolute, join, dirname, resolve } from "node:path";
7
- import { CotalEndpoint, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
8
- import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimManagerSiblingIdentities, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
7
+ import { CotalEndpoint, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, bearerCommandFailure, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
8
+ import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimManagerSiblingIdentities, connectorInstallHint, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveAgentType, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
9
9
  import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
10
10
  import { AttachEndpoint } from "./attach-endpoint.js";
11
11
  import { DELIVERY_ADMIN_BOOT_WAIT_MS, isUnansweredDeliveryAdmin, makeManagerEndpointEvictionEvidence, makeManagerEndpointHolderEvictor, untilDeliveryAdminAnswers } from "./endpoint-evict.js";
@@ -32,7 +32,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
32
32
  // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
33
33
  import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServingInstance, deregisterServiceInstance, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
34
34
  import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
35
- import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
35
+ import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, ownedBySibling, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
36
36
  /** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
37
37
  * before living this long leaves a cooling stamp that still counts toward the ceiling until it
38
38
  * expires — so churn (spawn↔despawn or spawn↔fast-exit) can't outrun the concurrency bound. */
@@ -137,14 +137,25 @@ function rejectionText(e) {
137
137
  return "an unreadable rejection";
138
138
  }
139
139
  }
140
+ /** Run one cleanup step, recording its failure in `failed` as `<what>: <reason>`. A step that threw
141
+ * would otherwise skip every step after it, and a swallowed one would leave nothing to report. */
142
+ async function attemptCleanup(failed, what, step) {
143
+ try {
144
+ await step();
145
+ }
146
+ catch (err) {
147
+ failed.push(`${what}: ${rejectionText(err)}`);
148
+ }
149
+ }
140
150
  /** Run the agent's bearer argv once, pre-launch — the end-to-end auth preflight (state dir, daemon,
141
151
  * ledger row, secret). Its stderr is the provider command's operator-exact sentence; surface it
142
152
  * verbatim as the spawn refusal. */
143
153
  function execBearerPreflight(argv) {
154
+ const timeout = 30_000;
144
155
  return new Promise((res, rej) => {
145
- execFile(argv[0], argv.slice(1), { timeout: 30_000, maxBuffer: 64 * 1024 }, (err, _stdout, stderr) => {
156
+ execFile(argv[0], argv.slice(1), { timeout, maxBuffer: 64 * 1024 }, (err, _stdout, stderr) => {
146
157
  if (err)
147
- return rej(new Error(stderr.trim() || err.message));
158
+ return rej(bearerCommandFailure(err, stderr, timeout));
148
159
  res();
149
160
  });
150
161
  });
@@ -282,7 +293,8 @@ export class Manager {
282
293
  /** The ONE secret store for every kind this manager touches (daemon-cred remint + agent kinds).
283
294
  * See {@link ManagerOptions.secretStore}. */
284
295
  secrets;
285
- /** Named identity of {@link secrets}: the same coordinate the delivery daemon must reload from. */
296
+ /** Named identity of {@link secrets}: the same coordinate the delivery daemon must reload from.
297
+ * Set at construction for an injected store; the workspace store is named on first challenge. */
286
298
  secretStoreIdentity;
287
299
  /** See {@link ManagerOptions.installedExtensions}. */
288
300
  installedExtensions;
@@ -593,9 +605,8 @@ export class Manager {
593
605
  if (opts.remoteAuthority)
594
606
  this.managerLifecycleUid = opts.remoteAuthority.lifecycleUid;
595
607
  this.secrets = opts.secretStore ?? workspaceSecretStore(this.workspaceRoot);
596
- this.secretStoreIdentity = opts.secretStore
597
- ? injectedManagerStoreIdentity(opts.secretStore)
598
- : { kind: "fs", root: resolve(this.workspaceRoot) };
608
+ if (opts.secretStore)
609
+ this.secretStoreIdentity = injectedManagerStoreIdentity(opts.secretStore);
599
610
  this.installedExtensions = opts.installedExtensions ?? false;
600
611
  this.runtime = createRuntime(opts.runtime ?? "auto", `cotal-${this.space}`);
601
612
  if (opts.pooled && isCustodialRuntime(this.runtime))
@@ -1129,6 +1140,9 @@ export class Manager {
1129
1140
  `this space's delivery lease (${holder}), so its SecretStore is not the store the daemon ` +
1130
1141
  `reloads from - nothing reminted`);
1131
1142
  const daemon = answer.identity;
1143
+ // The workspace store records its id the first time it is named, so it is named here, where
1144
+ // only an auth manager reaches, rather than in a constructor every open-mode manager runs.
1145
+ this.secretStoreIdentity ??= parseSecretStoreIdentity(this.secrets.identity);
1132
1146
  if (!sameSecretStoreIdentity(this.secretStoreIdentity, daemon)) {
1133
1147
  console.error(`! ${divergentSecretStoreNotice(this.secretStoreIdentity, daemon)}`);
1134
1148
  return "divergent";
@@ -1676,7 +1690,7 @@ export class Manager {
1676
1690
  // actually saw.
1677
1691
  const backfillFloor = await this.chatFrontierForPreservation();
1678
1692
  const inventory = this.preservationInventory ?? {
1679
- version: "cotal-manager-resume/v1",
1693
+ version: "cotal-manager-resume/v2",
1680
1694
  space: this.space,
1681
1695
  createdAt: new Date().toISOString(),
1682
1696
  agents: [...this.agents.values()].map((a) => this.resumeEntry(a, backfillFloor)),
@@ -2139,17 +2153,17 @@ export class Manager {
2139
2153
  await step("release renewal lease", () => this.ep.releaseDaemonRenewalLease());
2140
2154
  }
2141
2155
  // Capture BEFORE the serve loop is torn down: `stopServiceServe` clears the state, and what is
2142
- // being asked here is "did this process register a service instance", which only the state
2156
+ // being asked here is "which registration did this process write", which only the state
2143
2157
  // before teardown can answer. Deregistration runs AFTER the serve loop has drained, so no
2144
2158
  // in-flight command can write a status back onto the record it just removed.
2145
- const registered = this.serviceServe !== undefined;
2159
+ const registrationRevision = this.serviceServe?.grant.registrationRevision;
2146
2160
  await step("stop service serve", () => this.stopServiceServe());
2147
2161
  // The drives AFTER the serve loop (no new `run-start` can land) and BEFORE deregistration: a
2148
2162
  // released run's status write is the last thing this incarnation says about it.
2149
2163
  await step("stop run hosting", () => this.runHosting?.stop());
2150
2164
  this.runHosting = undefined;
2151
- if (registered && !seatFailure)
2152
- await step("deregister service", () => this.deregisterServiceOnStop());
2165
+ if (registrationRevision !== undefined && !seatFailure)
2166
+ await step("deregister service", () => this.deregisterServiceOnStop(registrationRevision));
2153
2167
  await step("stop goal writer", () => this.stopGoalWriter());
2154
2168
  await step("stop session plane", () => this.stopSessionPlane());
2155
2169
  await step("stop endpoint", () => this.ep.stop());
@@ -2195,8 +2209,8 @@ export class Manager {
2195
2209
  * deadline — on every `cotal ps`, `stop` and `attach` in that space, for good. A manager that is
2196
2210
  * shutting down is the one participant that KNOWS it is going away, so it says so.
2197
2211
  *
2198
- * The delete is revision-pinned inside {@link deregisterServiceInstance}, so a successor that
2199
- * persists the SAME instanceId and has already re-registered loses nothing to a slow stop here.
2212
+ * The delete is pinned to the `registrationRevision` this process registered at, so a successor
2213
+ * that persists the SAME instanceId and has already re-registered loses nothing to a slow stop here.
2200
2214
  *
2201
2215
  * Best-effort and LOUD, matching every other teardown step: a broker that is already gone must not
2202
2216
  * turn a stop into a failure, but a registration that survives a stop is the exact defect this
@@ -2204,11 +2218,12 @@ export class Manager {
2204
2218
  * rather than swallowed. It is not fatal because a crash leaves the same state and the operator
2205
2219
  * verb handles both.
2206
2220
  */
2207
- async deregisterServiceOnStop() {
2221
+ async deregisterServiceOnStop(registrationRevision) {
2208
2222
  const iid = this.managerInstanceId;
2209
2223
  const dereg = ({ recordsKv, authKv }) => deregisterServiceInstance(recordsKv, {
2210
2224
  endpoint: MANAGER_ENDPOINT,
2211
2225
  instanceId: iid,
2226
+ registrationRevision,
2212
2227
  observeGeneration: async () => {
2213
2228
  const key = epgateKey(MANAGER_ENDPOINT, iid);
2214
2229
  const entry = await authKv.get(key);
@@ -2224,7 +2239,7 @@ export class Manager {
2224
2239
  if (outcome.removed)
2225
2240
  console.error(`✓ deregistered manager instance ${iid} from the ${MANAGER_ENDPOINT} service registry (spec revision ${outcome.specRevision})`);
2226
2241
  else if (outcome.reason === "superseded")
2227
- console.error(`! manager instance ${iid} was not deregistered: its registration moved while this stop ran, so another incarnation owns it now - leaving it alone`);
2242
+ console.error(`! manager instance ${iid} was not deregistered: another incarnation has registered it since this process did - leaving it alone`);
2228
2243
  else if (outcome.reason === "registration-in-flight")
2229
2244
  console.error(`! manager instance ${iid} was not deregistered: a registration is still in flight (governance slot held at the live gate generation) - leaving it alone`);
2230
2245
  // `absent` is silent: there was nothing registered to remove, which is not news at shutdown.
@@ -3239,15 +3254,14 @@ export class Manager {
3239
3254
  // Roll back everything this attempt materialized — a refused spawn must leave no standing
3240
3255
  // secret, no ledger row, no durable footprint — and AWAIT the broker teardown: the caller
3241
3256
  // may respawn the moment it reads the refusal, and a detached teardown would race (and
3242
- // delete) that fresh spawn's just-provisioned durables.
3243
- await provider.revokeAgent({ dir, owner, actor: name }).catch(() => { });
3244
- await secrets.delete(agentSecretKeyForFile(tokenPath, this.space)).catch(() => { });
3245
- await secrets.delete(agentSecretKeyForFile(sentinelPath, this.space)).catch(() => { });
3246
- rmSync(tokenPath, { force: true });
3247
- rmSync(sentinelPath, { force: true });
3248
- rmSync(healthPath, { force: true });
3249
- await this.deprovision({ id: principalKey(owner, name).key, name, lifecycleUid: opts.lifecycleUid, userOwner: owner, secretPaths: files }).catch((err) => console.error(`rollback deprovision ${name}: ${err.message}`));
3250
- return { error: `agent auth preflight failed for "${name}": ${e.message}` };
3257
+ // delete) that fresh spawn's just-provisioned durables. deprovision shreds the secret family
3258
+ // and revokes the grant, but only logs a failed revoke, so the revoke is attempted here first.
3259
+ // Each failure joins the refusal, so the caller learns what this spawn left behind.
3260
+ const failed = [];
3261
+ await attemptCleanup(failed, "revoke agent grant", () => provider.revokeAgent({ dir, owner, actor: name }));
3262
+ await attemptCleanup(failed, "deprovision", () => this.deprovision({ id: principalKey(owner, name).key, name, lifecycleUid: opts.lifecycleUid, userOwner: owner, secretPaths: files }));
3263
+ const leftover = failed.length ? `; cleanup failed: ${failed.join("; ")}` : "";
3264
+ return { error: `agent auth preflight failed for "${name}": ${rejectionText(e)}${leftover}` };
3251
3265
  }
3252
3266
  }
3253
3267
  /**
@@ -3316,7 +3330,7 @@ export class Manager {
3316
3330
  if (files.actorToken !== staged.actorToken) {
3317
3331
  await secrets.put(agentSecretKeyForFile(files.actorToken, this.space), actorToken);
3318
3332
  await materializeSecretToFile(secrets, agentSecretKeyForFile(files.actorToken, this.space), files.actorToken);
3319
- await secrets.delete(agentSecretKeyForFile(staged.actorToken, this.space)).catch(() => { });
3333
+ await secrets.delete(agentSecretKeyForFile(staged.actorToken, this.space));
3320
3334
  rmSync(staged.actorToken, { force: true });
3321
3335
  }
3322
3336
  await secrets.put(agentSecretKeyForFile(files.sentinelCreds, this.space), material.sentinelCreds);
@@ -3351,19 +3365,11 @@ export class Manager {
3351
3365
  // A failed removal joins the refusal instead of throwing, so every removal is attempted and
3352
3366
  // the refusal still reaches the caller.
3353
3367
  const unshredded = [];
3354
- const shred = async (what, remove) => {
3355
- try {
3356
- await remove();
3357
- }
3358
- catch (err) {
3359
- unshredded.push(`${what}: ${rejectionText(err)}`);
3360
- }
3361
- };
3362
3368
  for (const family of files.actorToken === staged.actorToken ? [staged] : [staged, files]) {
3363
3369
  for (const path of [family.actorToken, family.sentinelCreds])
3364
- await shred(`secret ${path}`, () => secrets.delete(agentSecretKeyForFile(path, this.space)));
3370
+ await attemptCleanup(unshredded, `secret ${path}`, () => secrets.delete(agentSecretKeyForFile(path, this.space)));
3365
3371
  for (const path of [family.actorToken, family.sentinelCreds, family.health])
3366
- await shred(`file ${path}`, () => rmSync(path, { force: true }));
3372
+ await attemptCleanup(unshredded, `file ${path}`, () => rmSync(path, { force: true }));
3367
3373
  }
3368
3374
  const leftover = unshredded.length ? `; cleanup failed: ${unshredded.join("; ")}` : "";
3369
3375
  return { error: `agent auth preflight failed for "${name}": ${rejectionText(e)}${leftover}` };
@@ -3616,22 +3622,25 @@ export class Manager {
3616
3622
  // unowned same-name file is the exact successor-clobber this ownership discipline removes.
3617
3623
  const secrets = this.secrets;
3618
3624
  const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid);
3625
+ // Every removal is attempted, and a failed one is thrown only after the broker teardown below,
3626
+ // so a file fault cannot strand the agent's durables and ACL row on the broker.
3627
+ const unshredded = [];
3619
3628
  if (files.creds) {
3620
- await secrets.delete(agentSecretKeyForFile(files.creds, this.space));
3621
- rmSync(files.creds, { force: true });
3629
+ const creds = files.creds;
3630
+ await attemptCleanup(unshredded, `secret ${creds}`, () => secrets.delete(agentSecretKeyForFile(creds, this.space)));
3631
+ await attemptCleanup(unshredded, `file ${creds}`, () => rmSync(creds, { force: true }));
3622
3632
  }
3623
3633
  if (a.userOwner) {
3624
3634
  // USER MODE: this teardown IS revocation, not just footprint reduction — the ledger row is
3625
3635
  // the agent's standing mint authority, so delete it (next exchange refused, next connect
3626
3636
  // denied) and shred the secret/sentinel/health files. A copied actor token dies here; a
3627
3637
  // still-LIVE connection ends at its bearer-bound JWT expiry (≤ the agent TTL).
3628
- if (files.actorToken)
3629
- await secrets.delete(agentSecretKeyForFile(files.actorToken, this.space));
3630
- if (files.sentinelCreds)
3631
- await secrets.delete(agentSecretKeyForFile(files.sentinelCreds, this.space));
3632
- for (const f of [files.actorToken, files.sentinelCreds, files.health])
3633
- if (f)
3634
- rmSync(f, { force: true });
3638
+ for (const path of [files.actorToken, files.sentinelCreds])
3639
+ if (path)
3640
+ await attemptCleanup(unshredded, `secret ${path}`, () => secrets.delete(agentSecretKeyForFile(path, this.space)));
3641
+ for (const path of [files.actorToken, files.sentinelCreds, files.health])
3642
+ if (path)
3643
+ await attemptCleanup(unshredded, `file ${path}`, () => rmSync(path, { force: true }));
3635
3644
  // The ledger row IS the agent's STANDING mint authority (a different store from the auth-plane
3636
3645
  // cred ledger the rail retirement covers): while it lives, a copied actor token can still mint a
3637
3646
  // fresh connect credential. So a FAILED revoke must NOT be swallowed into a clean terminal (INT-2):
@@ -3659,6 +3668,7 @@ export class Manager {
3659
3668
  console.error(`revoke agent grant ${a.name}: ${e.message}`);
3660
3669
  }
3661
3670
  }
3671
+ const shredFailure = unshredded.length ? `could not shred "${a.name}": ${unshredded.join("; ")}` : undefined;
3662
3672
  try {
3663
3673
  const res = await this.deprovisionBroker(a);
3664
3674
  const h = this.retiring.get(a.name);
@@ -3672,8 +3682,12 @@ export class Manager {
3672
3682
  if (e instanceof DeprovisionError)
3673
3683
  h.lastResources = e.accounting;
3674
3684
  }
3685
+ if (shredFailure)
3686
+ throw new Error(`${rejectionText(e)}; ${shredFailure}`, { cause: e });
3675
3687
  throw e;
3676
3688
  }
3689
+ if (shredFailure)
3690
+ throw new Error(shredFailure);
3677
3691
  // #29 piece 3: after the footprint teardown, ask the AUTH plane to RETIRE the lifecycle over
3678
3692
  // the auth endpoint rail. The rail re-checks the SERVE-ISSUANCE GATE at serve time (not the
3679
3693
  // space-manager lease - that check was replaced in 02794b2f) and refuses unless the registration
@@ -4265,7 +4279,7 @@ export class Manager {
4265
4279
  nameInUse(name, live = this.liveRosterNames()) {
4266
4280
  return this.agents.has(name) || this.reserved.has(name) || this.retiring.has(name) || live.has(name);
4267
4281
  }
4268
- /** First free name in the series `base`, `base-2`, `base-3`, … — checked against live slots,
4282
+ /** First free name in the series `base`, `base_2`, `base_3`, … — checked against live slots,
4269
4283
  * in-flight (reserved) slots, names held pending retirement, AND the live mesh roster. The
4270
4284
  * roster check covers occupants this manager does not manage (a foreground `cotal spawn`, a
4271
4285
  * connector session, another manager's agent): allocating their name would mint a sibling the
@@ -4346,11 +4360,12 @@ export class Manager {
4346
4360
  throw new Error(`${flag}: expected an array of strings`);
4347
4361
  return v;
4348
4362
  };
4349
- let subscribe, allowSubscribe, allowPublish;
4363
+ let subscribe, allowSubscribe, allowPublish, shareTools;
4350
4364
  try {
4351
4365
  subscribe = strList(args.subscribe, "subscribe");
4352
4366
  allowSubscribe = strList(args.allowSubscribe, "allowSubscribe");
4353
4367
  allowPublish = strList(args.allowPublish, "allowPublish");
4368
+ shareTools = strList(args.shareTools, "shareTools");
4354
4369
  }
4355
4370
  catch (e) {
4356
4371
  return Promise.resolve({ ok: false, error: e.message });
@@ -4400,7 +4415,7 @@ export class Manager {
4400
4415
  subscribe,
4401
4416
  allowSubscribe,
4402
4417
  allowPublish,
4403
- shareTools: args.shareTools !== undefined ? String(args.shareTools) : undefined,
4418
+ shareTools,
4404
4419
  ...(supervise !== undefined ? { supervise } : {}),
4405
4420
  route,
4406
4421
  }, caller, hooks);
@@ -4522,6 +4537,31 @@ export class Manager {
4522
4537
  return reason;
4523
4538
  return `${reason}. This manager cannot launch that harness. The space may have other managers; pin one with --on <instance> (the whole id, as ps prints it)`;
4524
4539
  }
4540
+ /** The harness binaries a managed launch runs, as boot resolved them, or the refusal naming what is
4541
+ * missing. Spawn and resume both take it, so a seat launches from the same files whichever path
4542
+ * starts it. A connector registered after boot has no inventory row and is checked on PATH now. */
4543
+ launchHarness(connector) {
4544
+ const row = this.connectorStatuses.find((status) => status.agent === connector.name);
4545
+ if (row?.state === "unavailable")
4546
+ return { refusal: row.reason ?? `${connector.name} harness is unavailable` };
4547
+ if (row)
4548
+ return { binaries: row.binaries };
4549
+ const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
4550
+ if (missing.length)
4551
+ return { refusal: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found` };
4552
+ return {};
4553
+ }
4554
+ /** The refusal for a launch choice the connector cannot honour. Spawn and resume both ask before
4555
+ * any reserve or mint, so a rule changed here holds on both paths. */
4556
+ capabilityRefusal(connector, launch) {
4557
+ if (launch.variant && !connector.supportsModelVariant)
4558
+ return `${connector.name} connector does not support model variants (variant)`;
4559
+ if (launch.prompt !== undefined && !connector.supportsPrompt)
4560
+ return `${connector.name} connector does not support an initial prompt (prompt)`;
4561
+ if (launch.exact && (!connector.supportsSessionReopen || !connector.supportsSessionContinuation))
4562
+ return `${connector.name} connector does not support exact session continuity (continuity: exact)`;
4563
+ return undefined;
4564
+ }
4525
4565
  /** Return connector-provided model catalogs for selector UIs. Optional by connector: a host with no
4526
4566
  * local model-list API reports `supported:false` rather than blocking the manager. A connector that
4527
4567
  * fails to import shows an `error:` row (from manifest enumeration) and never blocks the others. */
@@ -4687,6 +4727,9 @@ export class Manager {
4687
4727
  }
4688
4728
  }
4689
4729
  async startAgentActive(opts, spawner, hooks) {
4730
+ // The caller keeps its array, so the selection is copied before the first await: a later write
4731
+ // to it cannot change what this launch shares or what preservation retains.
4732
+ const shareTools = opts.shareTools && [...opts.shareTools];
4690
4733
  if (opts.delegatedIntent) {
4691
4734
  if (!this.remoteAuthority?.executeDelegatedUserIntent)
4692
4735
  return { ok: false, error: `"${opts.name}" names a delegated user intent, and this manager has no host execution for one` };
@@ -4743,10 +4786,7 @@ export class Manager {
4743
4786
  return { ok: false, error: e.message };
4744
4787
  }
4745
4788
  }
4746
- // Harness precedence (#869): --agent flag > persona file `agent:` > detached caller default >
4747
- // this manager's COTAL_DEFAULT_AGENT > DEFAULT_CONNECTOR. Both environment values are defaults,
4748
- // never overrides: neither may beat a deliberate per-persona pin.
4749
- const agent = opts.agent ?? def?.agent ?? opts.defaultAgent ?? defaultAgentType(DEFAULT_CONNECTOR);
4789
+ const agent = resolveAgentType({ flag: opts.agent, pin: def?.agent, callerDefault: opts.defaultAgent });
4750
4790
  // Materialize the requested connector up front — the ONE async step in the spawn path (a lazy
4751
4791
  // `cotal ext` manifest import on the published binary). It runs BEFORE the capacity/reserve span
4752
4792
  // below so that span stays fully SYNCHRONOUS and atomic. On the manifest binary this also honors a
@@ -4772,17 +4812,9 @@ export class Manager {
4772
4812
  // Harness preflight before reserving a slot or minting — a missing `claude`/`opencode` binary
4773
4813
  // fails here with a clear name, not obscurely at process spawn. No fallback. All synchronous, so
4774
4814
  // the reserve gate stays atomic. (The connector itself was resolved up top, before the capacity gate.)
4775
- const bootStatus = this.connectorStatuses.find((row) => row.agent === agent);
4776
- const route = opts.route ?? "inst";
4777
- if (bootStatus?.state === "unavailable")
4778
- return { ok: false, error: this.harnessUnavailableError(bootStatus.reason ?? `${agent} harness is unavailable`, route) };
4779
- // A connector registered after boot has no inventory row. Keep the existing pre-mint backstop
4780
- // for that dynamic library-composition case; ordinary installed connectors were checked at boot.
4781
- if (!bootStatus) {
4782
- const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
4783
- if (missing.length)
4784
- return { ok: false, error: this.harnessUnavailableError(`${agent} harness needs ${missing.join(", ")} on PATH - not found`, route) };
4785
- }
4815
+ const harness = this.launchHarness(connector);
4816
+ if ("refusal" in harness)
4817
+ return { ok: false, error: this.harnessUnavailableError(harness.refusal, opts.route ?? "inst") };
4786
4818
  // Resume is a connector capability: reject an unsupported resume HERE, before the reserve/mint, so
4787
4819
  // it can never provision creds + durables and then throw at buildLaunch (mint-then-orphan). Same
4788
4820
  // reject-before-side-effects window as the harness preflight above; buildLaunch stays the backstop.
@@ -4835,7 +4867,7 @@ export class Manager {
4835
4867
  return { ok: false, error: `${delegatedBy}, so it cannot reopen a session held on this host (continuity: exact)` };
4836
4868
  if (typeof opts.cwd === "string" && opts.cwd !== "")
4837
4869
  return { ok: false, error: `${delegatedBy}, so it cannot run in a directory on this host's filesystem (cwd); the seat runs in its runtime resource's own directory` };
4838
- const shared = Object.keys(connectorServers(loadCotalConfig(this.workspaceRoot), agent, parseShareSelection(opts.shareTools)));
4870
+ const shared = Object.keys(connectorServers(loadCotalConfig(this.workspaceRoot), agent, shareTools));
4839
4871
  if (shared.length)
4840
4872
  return { ok: false, error: `${delegatedBy}, so it cannot share MCP servers that run on this host (${shared.join(", ")}); pass --share-tools none` };
4841
4873
  }
@@ -4856,7 +4888,7 @@ export class Manager {
4856
4888
  // Resolve the launch profile: IDENTITY (free-form `name:`) + role + read/post ACL + capabilities
4857
4889
  // + model/variant. Either from a fully-resolved manifest launch object (`opts.resolved`, whose `config`
4858
4890
  // is a materialized transient persona — the file is NOT the access authority), or from the
4859
- // persona file. The number rides the IDENTITY (socrates → socrates-2), not the file ref — a
4891
+ // persona file. The number rides the IDENTITY (socrates → socrates_2), not the file ref — a
4860
4892
  // redelivered identical spawn yields a fresh numbered agent (MAX_AGENTS bounds the blast radius).
4861
4893
  let identityName;
4862
4894
  let role;
@@ -4871,13 +4903,13 @@ export class Manager {
4871
4903
  if (opts.resolved) {
4872
4904
  // A manifest launch is the access + identity authority: imperative overrides arriving
4873
4905
  // alongside `resolved` are a caller contract error, not something to merge (no fallbacks).
4874
- if (opts.subscribe || opts.allowSubscribe || opts.allowPublish || opts.prompt || opts.shareTools || opts.identity)
4906
+ if (opts.subscribe || opts.allowSubscribe || opts.allowPublish || opts.prompt || shareTools || opts.identity)
4875
4907
  return { ok: false, error: "a manifest launch (resolved) rejects imperative overrides (identity/subscribe/allow*/prompt/shareTools)" };
4876
4908
  const r = opts.resolved;
4877
4909
  identityName = r.name;
4878
4910
  role = opts.role ?? r.role;
4879
4911
  subscribe = r.subscribe;
4880
- allowSubscribe = r.allowSubscribe?.length ? r.allowSubscribe : r.subscribe;
4912
+ allowSubscribe = r.allowSubscribe;
4881
4913
  allowPublish = r.allowPublish;
4882
4914
  capabilities = r.capabilities;
4883
4915
  model = opts.model ?? r.model;
@@ -4899,9 +4931,7 @@ export class Manager {
4899
4931
  // paths of the merged grammar can't diverge. One source feeds BOTH the minted creds and the
4900
4932
  // connector env below.
4901
4933
  subscribe = opts.subscribe ?? def.subscribe;
4902
- // Defaulted the same way the loader/provisioner do — minted into the creds (the broker
4903
- // boundary); runtime durable joins are re-authorized against the committed ACL by the daemon.
4904
- allowSubscribe = opts.allowSubscribe ?? def.allowSubscribe ?? subscribe ?? [];
4934
+ allowSubscribe = opts.allowSubscribe ?? def.allowSubscribe ?? [];
4905
4935
  allowPublish = opts.allowPublish ?? def.allowPublish;
4906
4936
  capabilities = def.capabilities;
4907
4937
  // #651: fold the persona's model into the launch record, mirroring the variant line below
@@ -4912,6 +4942,14 @@ export class Manager {
4912
4942
  variant = opts.variant ?? def.variant;
4913
4943
  launchOptions = mergeLaunchOptions(def.launchOptions, opts.launchOptions);
4914
4944
  }
4945
+ // Resolved once for both branches, because this one list is granted, minted, recorded on the
4946
+ // launch and forwarded to the session, and each of those must read the list the provisioner does.
4947
+ try {
4948
+ allowSubscribe = resolveReadAcl(subscribe ?? [], allowSubscribe);
4949
+ }
4950
+ catch (e) {
4951
+ return { ok: false, error: opts.resolved ? `launch agent: ${e.message}` : `persona ${configPath}: ${e.message}` };
4952
+ }
4915
4953
  // #651: an empty or whitespace-only model string is not a pin. Coerce it to undefined here, at
4916
4954
  // the single point every path (persona, manifest, imperative) has resolved `model`, so it
4917
4955
  // serializes ABSENT rather than present-but-empty (`"model": ""`), which a key-presence consumer
@@ -4979,18 +5017,15 @@ export class Manager {
4979
5017
  const err = lifecycleBlocked("failed-precondition", `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.`, { blockedOp: "retirement", opId: held.opId, remedy: "retry" });
4980
5018
  return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
4981
5019
  }
4982
- if (variant && !connector.supportsModelVariant)
4983
- return { ok: false, error: `${agent} connector does not support model variants (variant)` };
4984
- if (prompt !== undefined && !connector.supportsPrompt)
4985
- return { ok: false, error: `${agent} connector does not support an initial prompt (prompt)` };
4986
5020
  // A manifest `continuity: exact` agent reopens the session this manager last bound to its
4987
5021
  // declared name. Refused before any reserve or mint when the connector cannot reopen an exact
4988
5022
  // session or the recorded assignment does not match this declaration.
4989
5023
  const exact = opts.resolved?.continuity === "exact";
5024
+ const unsupported = this.capabilityRefusal(connector, { variant, prompt, exact });
5025
+ if (unsupported)
5026
+ return { ok: false, error: unsupported };
4990
5027
  let reopenSession;
4991
5028
  if (exact) {
4992
- if (!connector.supportsSessionReopen || !connector.supportsSessionContinuation)
4993
- return { ok: false, error: `${agent} connector does not support exact session continuity (continuity: exact)` };
4994
5029
  try {
4995
5030
  reopenSession = readContinuityAssignment(this.workspaceRoot, { space: this.space, name: identityName, connector: agent, cwd: resolvedCwd ?? this.workspaceRoot });
4996
5031
  }
@@ -5002,6 +5037,30 @@ export class Manager {
5002
5037
  const unportable = this.runtime.spawnDelegated ? Object.keys(launchOptions ?? {}).filter((k) => typeof launchOptions[k] !== "string") : [];
5003
5038
  if (unportable.length)
5004
5039
  return { ok: false, error: `runtime "${this.runtime.kind}" starts seats outside this host, so it cannot pass non-string launch options (${unportable.join(", ")})` };
5040
+ // The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
5041
+ // anything is minted: a connector that cannot
5042
+ // emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
5043
+ // The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
5044
+ // the principal's owner is resolved further down, so deriving it from anything in scope here
5045
+ // would mean guessing at the identity the child will actually connect as. It is added at the
5046
+ // accept seam below, where the allocated triple exists.
5047
+ if (this.eventsRequired && opts.events === false)
5048
+ return { ok: false, error: `space "${this.space}" requires the event plane by registration policy; --no-events (events: false on the start op) is not allowed` };
5049
+ const events = this.eventsRequired || opts.events !== false;
5050
+ if (events && !connector.eventChannel)
5051
+ return { ok: false, error: this.eventsRequired
5052
+ ? `space "${this.space}" requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
5053
+ : `connector "${connector.name}" does not publish an AG-UI event plane; pass --no-events (events: false on the start op) to launch it without one` };
5054
+ // F2 (Unit B): a STATIC managed spawn REFUSES endpoint capabilities, fail-closed IN CODE (not
5055
+ // a doc note): the static terminal has no obligation-drain/frontier steps yet, so an accepted-
5056
+ // but-uncompleted endpoint obligation could execute AFTER its uid is declared retired. The
5057
+ // refusal sits at spawn-accept, before any provisioning, over the same records a persona or
5058
+ // manifest self-claim would ride in on — capabilities cannot slip past it into the grant path.
5059
+ if (this.auth && !this.userMode) {
5060
+ const claims = [opts, (opts.resolved ?? {})];
5061
+ if (claims.some((c) => c.endpointCapabilities !== undefined))
5062
+ return { ok: false, error: "a static managed spawn refuses endpointCapabilities (Unit B F2): the static lifecycle terminal carries no obligation-drain/frontier steps, so endpoint-rail grants are not containable in static mode" };
5063
+ }
5005
5064
  // #4 A4 (panel): the roster the allocation consults must reflect the initial presence snapshot,
5006
5065
  // or a spawn immediately after manager boot races an already-live unmanaged peer and re-opens the
5007
5066
  // very collision black-hole this closes. Await the snapshot (bounded internally, fail-safe on an
@@ -5011,10 +5070,10 @@ export class Manager {
5011
5070
  await this.ep.waitForPresenceSnapshot();
5012
5071
  // M6 (P2 item 2 spawn-as-action): a HARD-PINNED name — an imperative `--name`/identity override
5013
5072
  // or a manifest-declared name (opts.resolved) — that collides with a LIVE/provisioning/reserved
5014
- // incarnation REFUSES loud at accept, BEFORE any reserve/mint/bind (pin 1), never a silent `-2`
5073
+ // incarnation REFUSES loud at accept, BEFORE any reserve/mint/bind (pin 1), never a silent `_2`
5015
5074
  // suffix (so an address-by-triple caller's pinned name can't be re-pointed). A PERSONA-DERIVED
5016
5075
  // base name (no pin) keeps uniqueName's collision numbering, so multi-peer `spawn reviewer` twice
5017
- // still yields reviewer + reviewer-2. The retiring-hold refuse (~2472) is orthogonal and already fired.
5076
+ // still yields reviewer + reviewer_2. The retiring-hold refuse (~2472) is orthogonal and already fired.
5018
5077
  const hardPinned = opts.identity !== undefined || opts.resolved !== undefined;
5019
5078
  let name;
5020
5079
  if (hardPinned) {
@@ -5030,42 +5089,6 @@ export class Manager {
5030
5089
  else {
5031
5090
  name = this.uniqueName(identityName);
5032
5091
  }
5033
- this.reserved.add(name);
5034
- // The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
5035
- // anything is minted: a connector that cannot
5036
- // emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
5037
- // The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
5038
- // the principal's owner is resolved further down, so deriving it from anything in scope here
5039
- // would mean guessing at the identity the child will actually connect as. It is added at the
5040
- // accept seam below, where the allocated triple exists.
5041
- if (this.eventsRequired && opts.events === false) {
5042
- this.reserved.delete(name);
5043
- return { ok: false, error: `space "${this.space}" requires the event plane by registration policy; --no-events (events: false on the start op) is not allowed` };
5044
- }
5045
- const events = this.eventsRequired || opts.events !== false;
5046
- if (events && !connector.eventChannel) {
5047
- // Release the just-reserved name on this fail-fast path. A leaked reserve is silent: it costs
5048
- // the next spawn of this persona its un-suffixed name and nothing reports why.
5049
- this.reserved.delete(name);
5050
- return { ok: false, error: this.eventsRequired
5051
- ? `space "${this.space}" requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
5052
- : `connector "${connector.name}" does not publish an AG-UI event plane; pass --no-events (events: false on the start op) to launch it without one` };
5053
- }
5054
- // F2 (Unit B): a STATIC managed spawn REFUSES endpoint capabilities, fail-closed IN CODE (not
5055
- // a doc note): the static terminal has no obligation-drain/frontier steps yet, so an accepted-
5056
- // but-uncompleted endpoint obligation could execute AFTER its uid is declared retired. The
5057
- // refusal sits at spawn-accept, before any provisioning, over the same records a persona or
5058
- // manifest self-claim would ride in on — capabilities cannot slip past it into the grant path.
5059
- if (this.auth && !this.userMode) {
5060
- const claims = [opts, (opts.resolved ?? {})];
5061
- if (claims.some((c) => c.endpointCapabilities !== undefined)) {
5062
- this.reserved.delete(name);
5063
- return { ok: false, error: "a static managed spawn refuses endpointCapabilities (Unit B F2): the static lifecycle terminal carries no obligation-drain/frontier steps, so endpoint-rail grants are not containable in static mode" };
5064
- }
5065
- }
5066
- // Set once the agent's creds + durables are minted; cleared the moment a live slot takes ownership
5067
- // (`agents.set`, after which freeSlot deprovisions on exit). If it survives to `finally`, the spawn
5068
- // threw AFTER minting (buildLaunch / runtime.spawn) — tear the orphan down so no footprint leaks (#159 B).
5069
5092
  // Set once the agent's footprint (durables + creds, or the user-mode grant + secret files)
5070
5093
  // exists; cleared when a live slot takes ownership. If it survives to `finally`, the spawn threw
5071
5094
  // AFTER provisioning (buildLaunch / runtime.spawn) — the orphan-rollback tears it down. Carries
@@ -5079,6 +5102,9 @@ export class Manager {
5079
5102
  let provisioned;
5080
5103
  // A carried resume's seat-private home, removed by the finally unless a live seat took it.
5081
5104
  let seatHome;
5105
+ // Taken right before the try so its finally is the only release; a refusal belongs above the
5106
+ // allocation, where there is no name to give back.
5107
+ this.reserved.add(name);
5082
5108
  try {
5083
5109
  // A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
5084
5110
  // COTAL_ID); the seed is retained to mint matching creds later.
@@ -5184,10 +5210,8 @@ export class Manager {
5184
5210
  }
5185
5211
  : undefined,
5186
5212
  });
5187
- if ("error" in prep) {
5188
- this.reserved.delete(name);
5213
+ if ("error" in prep)
5189
5214
  return { ok: false, error: prep.error };
5190
- }
5191
5215
  // THE HOST'S UID WINS (#1972). On the local arm this is the value passed in, unchanged. On
5192
5216
  // the hosted enrollment arm the host selected it, and from here every lifecycle-keyed thing
5193
5217
  // this spawn records — the slot, the launch, the managed row, the teardown credential — must
@@ -5249,7 +5273,7 @@ export class Manager {
5249
5273
  // (cotal config; default none → isolated, the memory-safe default this guards), narrowed by
5250
5274
  // an optional --share-tools selection (absent → all declared, the pre-merge behavior).
5251
5275
  const cotalConfig = loadCotalConfig(this.workspaceRoot);
5252
- const mcpServers = connectorServers(cotalConfig, agent, parseShareSelection(opts.shareTools));
5276
+ const mcpServers = connectorServers(cotalConfig, agent, shareTools);
5253
5277
  // The operator's spawn-env policy travels the same route: absent means no extras (the OS
5254
5278
  // allow-list + operator knobs + connector-declared inputs), present means those names too.
5255
5279
  // A connector never reads the config itself.
@@ -5304,10 +5328,11 @@ export class Manager {
5304
5328
  eventsRequired: this.eventsRequired,
5305
5329
  mcpServers,
5306
5330
  envAllow,
5307
- resolvedBinaries: bootStatus?.binaries,
5331
+ resolvedBinaries: harness.binaries,
5308
5332
  // So a connector that keeps per-agent local state can root it at the workspace, not the
5309
- // (possibly per-agent) launch cwd below. The cwd itself rides runtime.spawn, not the launch.
5333
+ // (possibly per-agent) launch cwd.
5310
5334
  workspaceRoot: this.workspaceRoot,
5335
+ cwd,
5311
5336
  };
5312
5337
  // A delegated seat gets no local launch at all: no launch-material file, control token or
5313
5338
  // manager path is produced for it, only the handoff its runtime writes into the child.
@@ -5375,7 +5400,7 @@ export class Manager {
5375
5400
  allowPublish,
5376
5401
  capabilities,
5377
5402
  events,
5378
- shareTools: opts.shareTools,
5403
+ shareTools,
5379
5404
  forkSource: opts.resume,
5380
5405
  ...(opts.resume !== undefined && spec?.resumeRecordPath ? { resumeRecordPath: spec.resumeRecordPath } : {}),
5381
5406
  ...(carried ? { carried: { transcriptSha256: carried.sha256, host: carried.sourceHost, transferredAt: carried.stagedAt } } : {}),
@@ -5508,10 +5533,17 @@ export class Manager {
5508
5533
  // Failure after reserve (provision / launch threw): the slot was never live, so no cold-start
5509
5534
  // was paid — the reserved rollback (finally) is enough, no cooling stamp.
5510
5535
  // A lifecycle-blocked envelope already named the barrier; keep its details on the ControlReply
5511
- // so follow/CLI/cotal_spawn do not collapse it to a generic string (#873).
5512
- if (e instanceof EpEnvelopeError)
5513
- return { ok: false, error: renderLifecycleBlocked(e.message, e), ...(e.details ? { details: e.details } : {}) };
5514
- return { ok: false, error: e.message };
5536
+ // so follow/CLI/cotal_spawn do not collapse it to a generic string (#873). An extension can throw
5537
+ // any value, and an envelope's `message` is only typed as a string, so the refusal text always
5538
+ // comes from rejectionText; telling an envelope apart is guarded because a thrown Proxy can
5539
+ // throw from `instanceof` or from its own fields.
5540
+ const error = rejectionText(e);
5541
+ try {
5542
+ if (e instanceof EpEnvelopeError)
5543
+ return { ok: false, error: renderLifecycleBlocked(error, e), ...(e.details ? { details: e.details } : {}) };
5544
+ }
5545
+ catch { /* not a readable envelope */ }
5546
+ return { ok: false, error };
5515
5547
  }
5516
5548
  finally {
5517
5549
  this.reserved.delete(name);
@@ -5541,19 +5573,22 @@ export class Manager {
5541
5573
  const batchReservations = [];
5542
5574
  const prepared = new Map();
5543
5575
  try {
5544
- if (inventory.version !== "cotal-manager-resume/v1")
5576
+ if (inventory.version !== "cotal-manager-resume/v2")
5545
5577
  return { ok: false, agents: [], error: `unsupported manager resume inventory version ${String(inventory.version)}` };
5546
5578
  if (inventory.space !== this.space)
5547
5579
  return { ok: false, agents: [], error: `resume inventory belongs to space "${inventory.space}", not "${this.space}"` };
5580
+ // The caller keeps its inventory, so each selection is copied before the first await: a later
5581
+ // write to it cannot change what a resumed seat shares or what the next preservation retains.
5582
+ const entries = inventory.agents.map((entry) => ({ ...entry, launch: { ...entry.launch, shareTools: entry.launch.shareTools && [...entry.launch.shareTools] } }));
5548
5583
  const seen = new Set();
5549
5584
  const principals = new Set();
5550
5585
  const orphans = [];
5551
5586
  await this.ep.waitForPresenceSnapshot();
5552
5587
  const liveRoster = this.ep.getRoster().filter((presence) => presence.status !== "offline");
5553
5588
  const occupancy = this.occupancy();
5554
- if (occupancy.used + inventory.agents.length > MAX_AGENTS)
5555
- return { ok: false, agents: [], error: `resume inventory of ${inventory.agents.length} would exceed manager capacity (${this.occupancyText(occupancy)})` };
5556
- for (const entry of inventory.agents) {
5589
+ if (occupancy.used + entries.length > MAX_AGENTS)
5590
+ return { ok: false, agents: [], error: `resume inventory of ${entries.length} would exceed manager capacity (${this.occupancyText(occupancy)})` };
5591
+ for (const entry of entries) {
5557
5592
  if (seen.has(entry.name))
5558
5593
  return { ok: false, agents: [], error: `resume inventory contains duplicate agent name "${entry.name}"` };
5559
5594
  seen.add(entry.name);
@@ -5610,12 +5645,12 @@ export class Manager {
5610
5645
  if (this.agents.has(entry.name) || this.reserved.has(entry.name))
5611
5646
  return { ok: false, agents: [], error: `retained agent "${entry.name}" is already managed or reserved` };
5612
5647
  }
5613
- for (const entry of inventory.agents) {
5648
+ for (const entry of entries) {
5614
5649
  this.reserved.add(entry.name);
5615
5650
  batchReservations.push(entry.name);
5616
5651
  }
5617
5652
  const preflight = [];
5618
- for (const entry of inventory.agents) {
5653
+ for (const entry of entries) {
5619
5654
  const reply = await this.resumePreservedAgent(entry, true, true, prepared);
5620
5655
  preflight.push({ name: entry.name, reply });
5621
5656
  }
@@ -5634,12 +5669,12 @@ export class Manager {
5634
5669
  return { ok: false, agents: [], error: refusal };
5635
5670
  }
5636
5671
  const agents = [];
5637
- for (let i = 0; i < inventory.agents.length; i++) {
5638
- const entry = inventory.agents[i];
5672
+ for (let i = 0; i < entries.length; i++) {
5673
+ const entry = entries[i];
5639
5674
  const reply = await this.resumePreservedAgent(entry, false, true, prepared);
5640
5675
  agents.push({ name: entry.name, reply });
5641
5676
  if (!reply.ok) {
5642
- for (const skipped of inventory.agents.slice(i + 1))
5677
+ for (const skipped of entries.slice(i + 1))
5643
5678
  agents.push({ name: skipped.name, reply: { ok: false, error: `not launched because ${entry.name} failed` } });
5644
5679
  return { ok: false, agents, error: reply.error };
5645
5680
  }
@@ -5849,7 +5884,7 @@ export class Manager {
5849
5884
  const slot = await this.readStaticReconcileSlot(entry.name);
5850
5885
  if (slot?.phase !== "active" || slot.actor !== entry.identity.id || slot.lifecycleUid !== entry.identity.lifecycleUid)
5851
5886
  return undefined;
5852
- if (slot.ownerInstanceId !== undefined && slot.ownerInstanceId !== this.managerInstanceId)
5887
+ if (ownedBySibling(slot, this.managerInstanceId))
5853
5888
  return undefined;
5854
5889
  return slot.runtime?.kind === this.runtime.kind ? slot.runtime : undefined;
5855
5890
  }
@@ -5924,13 +5959,9 @@ export class Manager {
5924
5959
  catch (e) {
5925
5960
  return { ok: false, error: e.message };
5926
5961
  }
5927
- const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
5928
- if (missing.length)
5929
- return { ok: false, error: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found` };
5930
- if (entry.launch.variant && !connector.supportsModelVariant)
5931
- return { ok: false, error: `${connector.name} connector does not support model variants (variant)` };
5932
- if (entry.launch.prompt !== undefined && !connector.supportsPrompt)
5933
- return { ok: false, error: `${connector.name} connector does not support an initial prompt (prompt)` };
5962
+ const harness = this.launchHarness(connector);
5963
+ if ("refusal" in harness)
5964
+ return { ok: false, error: harness.refusal };
5934
5965
  let retainedSession;
5935
5966
  try {
5936
5967
  retainedSession = this.retainedSessionId(entry, connector);
@@ -5962,10 +5993,6 @@ export class Manager {
5962
5993
  return { ok: false, error: `retained manifest agent ${launchSource.requested} is missing or its hash changed; refusing same-principal resume` };
5963
5994
  launchOptions = spec.launchOptions;
5964
5995
  exact = spec.continuity === "exact";
5965
- // An exact seat reopens its retained session or fails, like its manifest launch, so the
5966
- // connector must honor reopenSession before the batch starts any child.
5967
- if (exact && (!connector.supportsSessionReopen || !connector.supportsSessionContinuation))
5968
- return { ok: false, error: `${connector.name} connector does not support exact session continuity (continuity: exact)` };
5969
5996
  }
5970
5997
  else {
5971
5998
  try {
@@ -5975,6 +6002,11 @@ export class Manager {
5975
6002
  return { ok: false, error: e.message };
5976
6003
  }
5977
6004
  }
6005
+ // Asked once `exact` is known: an exact seat reopens its retained session or fails, like its
6006
+ // manifest launch, so the connector must honor reopenSession before the batch starts any child.
6007
+ const unsupported = this.capabilityRefusal(connector, { variant: entry.launch.variant, prompt: entry.launch.prompt, exact });
6008
+ if (unsupported)
6009
+ return { ok: false, error: unsupported };
5978
6010
  let authority;
5979
6011
  try {
5980
6012
  authority = await this.validateRetainedAuthority(entry);
@@ -5984,7 +6016,7 @@ export class Manager {
5984
6016
  }
5985
6017
  try {
5986
6018
  const resumeConfig = loadCotalConfig(this.workspaceRoot);
5987
- const mcpServers = connectorServers(resumeConfig, entry.launch.connector, parseShareSelection(entry.launch.shareTools));
6019
+ const mcpServers = connectorServers(resumeConfig, entry.launch.connector, entry.launch.shareTools);
5988
6020
  const envAllow = spawnEnvAllow(resumeConfig);
5989
6021
  const launchOpts = {
5990
6022
  space: this.space,
@@ -6016,7 +6048,9 @@ export class Manager {
6016
6048
  events: entry.launch.events,
6017
6049
  mcpServers,
6018
6050
  envAllow,
6051
+ resolvedBinaries: harness.binaries,
6019
6052
  workspaceRoot: this.workspaceRoot,
6053
+ cwd: entry.launch.cwd,
6020
6054
  };
6021
6055
  const spec = connector.buildLaunch(launchOpts);
6022
6056
  spec.env = { ...spec.env, COTAL_MANAGER_INSTANCE: this.managerInstanceId };
@@ -8674,7 +8708,7 @@ export class Manager {
8674
8708
  console.error(`✓ static reconcile recovered alias=${item.alias} phase=retired uid=${item.lifecycleUid} attempts=${item.attempts}/${item.maxAttempts}`);
8675
8709
  return;
8676
8710
  }
8677
- if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId) {
8711
+ if (ownedBySibling(row, this.managerInstanceId)) {
8678
8712
  item.disposition = "refused";
8679
8713
  item.remedy = "the owning manager must reconcile or an operator must perform an explicit CAS takeover";
8680
8714
  item.lastError = `the durable slot belongs to manager instance ${row.ownerInstanceId}, not ${this.managerInstanceId}`;
@@ -8867,7 +8901,7 @@ export class Manager {
8867
8901
  // hazard, now cross-instance). A legacy row (pre-3b-2, no owner recorded) predates multi-manager,
8868
8902
  // so this manager is its legitimate single-manager-past successor and reconciles it. An orphaned
8869
8903
  // sibling row is reclaimed only by an explicit operator CAS takeover (ruling 1), never here.
8870
- if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId) {
8904
+ if (ownedBySibling(row, this.managerInstanceId)) {
8871
8905
  preservedForeign++;
8872
8906
  continue;
8873
8907
  }