@cotal-ai/manager 0.69.0 → 0.70.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,10 +1,10 @@
1
- import { execFile } from "node:child_process";
2
1
  import { createHash, randomUUID, randomBytes } from "node:crypto";
3
2
  import { hostname } from "node:os";
4
3
  import { credsAuthenticator } from "@nats-io/transport-node";
5
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
6
5
  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, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, bearerCommandFailure, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
6
+ import Headless from "@xterm/headless";
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, runAgentBearer, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
8
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";
@@ -30,7 +30,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
30
30
  // P2 item 1 (1a-serve): the manager as an ordinary v0.4 `service` endpoint — the §13.1
31
31
  // endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
32
32
  // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
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";
33
+ import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServingInstance, deregisterServiceInstance, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, requestGoalCancel, goalAlreadyTerminal, 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
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)
@@ -147,19 +147,6 @@ async function attemptCleanup(failed, what, step) {
147
147
  failed.push(`${what}: ${rejectionText(err)}`);
148
148
  }
149
149
  }
150
- /** Run the agent's bearer argv once, pre-launch — the end-to-end auth preflight (state dir, daemon,
151
- * ledger row, secret). Its stderr is the provider command's operator-exact sentence; surface it
152
- * verbatim as the spawn refusal. */
153
- function execBearerPreflight(argv) {
154
- const timeout = 30_000;
155
- return new Promise((res, rej) => {
156
- execFile(argv[0], argv.slice(1), { timeout, maxBuffer: 64 * 1024 }, (err, _stdout, stderr) => {
157
- if (err)
158
- return rej(bearerCommandFailure(err, stderr, timeout));
159
- res();
160
- });
161
- });
162
- }
163
150
  /** Reject `p` with `Error(msg)` if it hasn't settled within `ms`; clears the timer when `p` settles so it
164
151
  * never keeps the loop alive. Used to bound the detached deprovision so its fail-loud log is guaranteed. */
165
152
  function withTimeout(p, ms, msg) {
@@ -251,6 +238,20 @@ function foreignEventChannels(channels, owner, actor) {
251
238
  return p !== null && !(p.owner === owner && p.actor === actor);
252
239
  });
253
240
  }
241
+ /** One connector's declared harness binaries resolved on PATH now. The refusal sentence is built only
242
+ * here, so the boot row and the check for a connector registered after boot can never word it apart. */
243
+ function resolveHarness(name, requires) {
244
+ const binaries = {};
245
+ const missing = [];
246
+ for (const bin of requires) {
247
+ const path = resolveOnPath(bin);
248
+ if (path)
249
+ binaries[bin] = path;
250
+ else
251
+ missing.push(bin);
252
+ }
253
+ return missing.length ? { binaries, reason: `${name} harness needs ${missing.join(", ")} on PATH - not found` } : { binaries };
254
+ }
254
255
  function injectedManagerStoreIdentity(store) {
255
256
  if (store.identity !== undefined)
256
257
  return parseSecretStoreIdentity(store.identity);
@@ -380,7 +381,7 @@ export class Manager {
380
381
  * standing connection DISJOINT from the serve credential (Q2), scoped to exactly this endpoint's
381
382
  * goal bind/terminal facts + goal-record writes ({@link goalWriterGrants}). Auth mode mints the
382
383
  * `goal-writer` cred; an open mesh uses a bare connection (no credential system to mint from).
383
- * `gate` (auth mode) is the own-issuance-gate READER for the must-5 (a) currency belt — the
384
+ * `gate` (auth mode) is the own-issuance-gate READER for the own-gate currency belt — the
384
385
  * manager reads its OWN `epgate.<e>.<iid>` epoch over this connection before a terminal commit
385
386
  * and skips a superseded commit (the fast-fail belt paired with the (b) barrier-revoke fence). */
386
387
  goalWriter;
@@ -388,7 +389,7 @@ export class Manager {
388
389
  * per-run credential and connection. Absent under a remote authority, which mints no driver
389
390
  * credentials, so the `run-*` family refuses there rather than connecting on a weaker identity. */
390
391
  runHosting;
391
- /** P2 item 2 must-5 (b): the STABLE goal-writer identity (auth mode) — minted once at
392
+ /** The STABLE goal-writer identity (auth mode) — minted once at
392
393
  * registration alongside the serve identity; a renewal re-mints the SAME nkey with a fresh
393
394
  * bounded exp and re-stages its distinct credId into the §13.1 revocation family. The current
394
395
  * goal-writer credential is minted INSIDE {@link registerManagerService}'s run block (fence
@@ -419,10 +420,10 @@ export class Manager {
419
420
  sessionLedgerCreds;
420
421
  /** P2 item 2: the acceptance replied for each in-flight goalId this incarnation accepted, so an
421
422
  * idempotent same-goalId retry serves the IDENTICAL acceptance (same allocated name/triple) without
422
- * a second spawn. Durable cross-incarnation reconstruction rides the must-5 goal-index; here the
423
+ * a second spawn. Durable cross-incarnation reconstruction rides the goal index; here the
423
424
  * live map covers same-incarnation retries, with the committed result fact as the fallback. */
424
425
  goalAcceptances = new Map();
425
- /** P2 item 2 must-5 Q-B: the boot reconcile of the durable goal index runs ONCE at start (a
426
+ /** The boot reconcile of the durable goal index runs ONCE at start (a
426
427
  * fresh incarnation inherits the endpoint's accepted-but-unterminal goals from any predecessor).
427
428
  * Spawn-as-action REFUSES to accept until it completes, so the sweep never races a live goal's
428
429
  * acceptance (settling one mid-flight would steal its real terminal). */
@@ -801,7 +802,7 @@ export class Manager {
801
802
  // First mint on a fresh root is exclusive create (#1263). Two processes that both observe no
802
803
  // file must not keep different in-memory ids: of N concurrent starts exactly one creates the
803
804
  // file (`link` / O_EXCL) and the others adopt that identity before taking a lease, or refuse
804
- // with manager-instance-identity-create-lost. Atomic rename is not enough, because the loser
805
+ // with identity-record-create-lost. Atomic rename is not enough, because the loser
805
806
  // of a replace would still serve under the id it minted.
806
807
  {
807
808
  if (this.remoteAuthority) {
@@ -1018,7 +1019,7 @@ export class Manager {
1018
1019
  // rides the serve grant's epoch + the family-staged session-ledger cred), on its own standing
1019
1020
  // connection disjoint from both the serve and goal-writer creds.
1020
1021
  await this.startSessionPlane();
1021
- // P2 item 2 must-5 Q-B: reconcile any accepted-but-unterminal goals inherited from a predecessor
1022
+ // Reconcile any accepted-but-unterminal goals inherited from a predecessor
1022
1023
  // BEFORE spawn-as-action begins accepting (the goalReconcileDone gate) — a fresh incarnation
1023
1024
  // never drops a goal a dead predecessor accepted. Never fatal; the gate opens either way.
1024
1025
  await this.reconcileGoalIndex();
@@ -1410,7 +1411,7 @@ export class Manager {
1410
1411
  console.error(`! endpoint-serve renewal: ${e.message} - the manager's service endpoint dies loud at this cred's expiry unless it is re-registered`);
1411
1412
  }
1412
1413
  }
1413
- // P2 item 2 must-5 (b): the manager is also the goal-writer's renewal owner — re-mint the SAME
1414
+ // The manager is also the goal-writer's renewal owner — re-mint the SAME
1414
1415
  // goal-writer nkey with a fresh bounded exp AND re-stage its new credId into the §13.1 family,
1415
1416
  // through the scoped executor (never the standing seed). Without this the standing goal-writer
1416
1417
  // connection dies at its TTL and spawn-as-action stops accepting until a restart. The
@@ -2874,6 +2875,9 @@ export class Manager {
2874
2875
  // turns addressed to ITS incarnation and yields them; no reach beyond itself exists here.
2875
2876
  turnPending: (ctx) => this.serveGated(ctx, () => this.turnPendingFor(ctx.subject.caller)),
2876
2877
  turnYield: (ctx) => this.serveGated(ctx, () => this.serveTurnYield(ctx.subject.caller, args(ctx))),
2878
+ // The reserved goal cancel (SPEC 13.6 item 4), served for the relay: the goal ref is the
2879
+ // authenticated caller's own, so a run withdraws only the turns it submitted.
2880
+ cancel: (ctx) => this.serveGated(ctx, () => this.serveTurnCancel(ctx)),
2877
2881
  stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
2878
2882
  definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
2879
2883
  listPersonas: (ctx) => this.serveGated(ctx, () => unwrap(this.opListPersonas(callerOf(ctx), false))),
@@ -3247,7 +3251,9 @@ export class Manager {
3247
3251
  "--token-file", tokenPath,
3248
3252
  "--health-file", healthPath,
3249
3253
  ];
3250
- await execBearerPreflight(bearerCmd);
3254
+ // One bearer run before the launch is the end-to-end auth preflight (state dir, daemon, ledger
3255
+ // row, secret); its failure sentence is the spawn refusal.
3256
+ await runAgentBearer(bearerCmd);
3251
3257
  return { owner, lifecycleUid: opts.lifecycleUid, files, launch: { owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd } };
3252
3258
  }
3253
3259
  catch (e) {
@@ -3349,7 +3355,7 @@ export class Manager {
3349
3355
  "--token-file", files.actorToken,
3350
3356
  "--health-file", files.health,
3351
3357
  ];
3352
- await execBearerPreflight(bearerCmd);
3358
+ await runAgentBearer(bearerCmd);
3353
3359
  return {
3354
3360
  owner: material.owner,
3355
3361
  lifecycleUid: material.lifecycleUid,
@@ -3365,16 +3371,22 @@ export class Manager {
3365
3371
  // A failed removal joins the refusal instead of throwing, so every removal is attempted and
3366
3372
  // the refusal still reaches the caller.
3367
3373
  const unshredded = [];
3368
- for (const family of files.actorToken === staged.actorToken ? [staged] : [staged, files]) {
3369
- for (const path of [family.actorToken, family.sentinelCreds])
3370
- await attemptCleanup(unshredded, `secret ${path}`, () => secrets.delete(agentSecretKeyForFile(path, this.space)));
3371
- for (const path of [family.actorToken, family.sentinelCreds, family.health])
3372
- await attemptCleanup(unshredded, `file ${path}`, () => rmSync(path, { force: true }));
3373
- }
3374
+ for (const family of files.actorToken === staged.actorToken ? [staged] : [staged, files])
3375
+ await this.shredUserSecrets(unshredded, family);
3374
3376
  const leftover = unshredded.length ? `; cleanup failed: ${unshredded.join("; ")}` : "";
3375
3377
  return { error: `agent auth preflight failed for "${name}": ${rejectionText(e)}${leftover}` };
3376
3378
  }
3377
3379
  }
3380
+ /** Shred a user agent's secret family from the store and from disk. Every removal is attempted,
3381
+ * and each failure joins `unshredded`, so one fault cannot leave the rest of the family behind. */
3382
+ async shredUserSecrets(unshredded, family) {
3383
+ for (const path of [family.actorToken, family.sentinelCreds])
3384
+ if (path)
3385
+ await attemptCleanup(unshredded, `secret ${path}`, () => this.secrets.delete(agentSecretKeyForFile(path, this.space)));
3386
+ for (const path of [family.actorToken, family.sentinelCreds, family.health])
3387
+ if (path)
3388
+ await attemptCleanup(unshredded, `file ${path}`, () => rmSync(path, { force: true }));
3389
+ }
3378
3390
  /** This manager's registry record for its space, which pins the broker and IdP a delegated seat's
3379
3391
  * handoff carries. It must pin the exchange the host enrolls against, or the child would register
3380
3392
  * a mesh whose bearers come from somewhere else. */
@@ -3635,12 +3647,7 @@ export class Manager {
3635
3647
  // the agent's standing mint authority, so delete it (next exchange refused, next connect
3636
3648
  // denied) and shred the secret/sentinel/health files. A copied actor token dies here; a
3637
3649
  // still-LIVE connection ends at its bearer-bound JWT expiry (≤ the agent TTL).
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 }));
3650
+ await this.shredUserSecrets(unshredded, files);
3644
3651
  // The ledger row IS the agent's STANDING mint authority (a different store from the auth-plane
3645
3652
  // cred ledger the rail retirement covers): while it lives, a copied actor token can still mint a
3646
3653
  // fresh connect credential. So a FAILED revoke must NOT be swallowed into a clean terminal (INT-2):
@@ -4200,7 +4207,7 @@ export class Manager {
4200
4207
  restart.armed = false;
4201
4208
  let tail = "";
4202
4209
  try {
4203
- tail = this.tail(await (replacement ?? a.handle).attach().backlog());
4210
+ tail = await this.tail((replacement ?? a.handle).attach());
4204
4211
  }
4205
4212
  catch { /* runtime has no readable tail */ }
4206
4213
  console.error(`! ${a.name}: ${supervised ? "supervised restart" : "Pi session recovery"} failed: ${error.message}${tail ? ` - last output: ${tail}` : ""} - retiring the managed seat`);
@@ -4494,19 +4501,9 @@ export class Manager {
4494
4501
  else {
4495
4502
  declared = registry.all("connector").map((connector) => ({ name: connector.name, requires: connector.requires ?? [] }));
4496
4503
  }
4497
- for (const connector of declared) {
4498
- const name = connector.name;
4499
- const binaries = {};
4500
- const missing = [];
4501
- for (const bin of connector.requires) {
4502
- const path = resolveOnPath(bin);
4503
- if (path)
4504
- binaries[bin] = path;
4505
- else
4506
- missing.push(bin);
4507
- }
4508
- if (missing.length) {
4509
- const reason = `${name} harness needs ${missing.join(", ")} on PATH - not found`;
4504
+ for (const { name, requires } of declared) {
4505
+ const { binaries, reason } = resolveHarness(name, requires);
4506
+ if (reason) {
4510
4507
  rows.push({ agent: name, state: "unavailable", binaries, reason });
4511
4508
  console.error(`! manager boot: connector ${name} unavailable - ${reason}`);
4512
4509
  }
@@ -4538,18 +4535,17 @@ export class Manager {
4538
4535
  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)`;
4539
4536
  }
4540
4537
  /** 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. */
4538
+ * missing. Spawn, resume and the model catalog all take it, so a seat launches from the same files
4539
+ * whichever path starts it and the catalog never disagrees with a launch about whether the harness
4540
+ * is there. A connector registered after boot has no inventory row and is checked on PATH now. */
4543
4541
  launchHarness(connector) {
4544
4542
  const row = this.connectorStatuses.find((status) => status.agent === connector.name);
4545
4543
  if (row?.state === "unavailable")
4546
4544
  return { refusal: row.reason ?? `${connector.name} harness is unavailable` };
4547
4545
  if (row)
4548
4546
  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 {};
4547
+ const { reason } = resolveHarness(connector.name, connector.requires ?? []);
4548
+ return reason ? { refusal: reason } : {};
4553
4549
  }
4554
4550
  /** The refusal for a launch choice the connector cannot honour. Spawn and resume both ask before
4555
4551
  * any reserve or mint, so a rule changed here holds on both paths. */
@@ -4571,14 +4567,9 @@ export class Manager {
4571
4567
  const one = async (connector) => {
4572
4568
  if (!connector.listModels)
4573
4569
  return { agent: connector.name, supported: false, models: [] };
4574
- const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
4575
- if (missing.length)
4576
- return {
4577
- agent: connector.name,
4578
- supported: true,
4579
- models: [],
4580
- error: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found`,
4581
- };
4570
+ const harness = this.launchHarness(connector);
4571
+ if ("refusal" in harness)
4572
+ return { agent: connector.name, supported: true, models: [], error: harness.refusal };
4582
4573
  try {
4583
4574
  const catalog = await connector.listModels({ refresh });
4584
4575
  return { agent: connector.name, supported: true, ...catalog };
@@ -5127,7 +5118,8 @@ export class Manager {
5127
5118
  // been provisioned yet — the action serve path binds the goal + replies the acceptance HERE. A
5128
5119
  // throw (bind conflict / duplicate goalId) aborts the spawn before provisioning: the catch below
5129
5120
  // returns the failure and the finally releases the reserve, so a refused accept leaves zero
5130
- // footprint (pin 1). Blocking callers (roster boot) pass no hooks and this is a no-op.
5121
+ // footprint (pin 1). The hosted enrollment arm is the exception, below. Blocking callers
5122
+ // (roster boot) pass no hooks and this is a no-op.
5131
5123
  // The ALLOCATED agent's addressing triple (the acceptance floor names what was actually
5132
5124
  // allocated, never the requested-but-unallocated name). Static/open key on DEV_OWNER + the
5133
5125
  // freshly-minted nkey; user mode keys on the derived owner (opts.owner, else a u_-owner spawner)
@@ -5180,7 +5172,11 @@ export class Manager {
5180
5172
  if (events)
5181
5173
  allowPublish = [...(allowPublish ?? []), connector.eventChannel({ owner: agentTriple.owner, actor: agentTriple.actor })];
5182
5174
  await hooks?.onReadinessWindow?.(readinessTimeoutMs);
5183
- await hooks?.onAccepted?.({ name, identity, lifecycleUid, agentTriple });
5175
+ // The hosted enrollment arm accepts only once the host has answered: the floor names the uid
5176
+ // the agent runs at, and before the host picks it there is no such uid to name.
5177
+ const hostedEnrollment = this.userMode && (opts.delegatedIntent !== undefined || this.remoteAuthority?.enrollManagedAgent !== undefined);
5178
+ if (!hostedEnrollment)
5179
+ await hooks?.onAccepted?.({ name, identity, lifecycleUid, agentTriple });
5184
5180
  // In auth mode, mint the agent's creds from the space signing key and write them where the
5185
5181
  // spawned session reads them (COTAL_CREDS path). Open mesh → no creds. Scope = the resolved
5186
5182
  // subscribe/allowSubscribe (read) + allowPublish (post, default-deny).
@@ -5222,6 +5218,20 @@ export class Manager {
5222
5218
  userOwner = prep.owner;
5223
5219
  enrolled = prep.enrolled;
5224
5220
  provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner, ...(opts.delegatedIntent ? { delegated: true } : {}), secretPaths: prep.files, ...(custody ? { runtime: custody } : {}) };
5221
+ if (hostedEnrollment) {
5222
+ try {
5223
+ await hooks?.onAccepted?.({ name, identity, lifecycleUid, agentTriple: { owner: prep.owner, actor: name, uid: lifecycleUid } });
5224
+ }
5225
+ catch (e) {
5226
+ // A refused accept rolls the enrollment back: the host's grant through `provisioned`, and
5227
+ // this participant's secret family here, because the hosted retirement leaves it on disk.
5228
+ const unshredded = [];
5229
+ await this.shredUserSecrets(unshredded, prep.files);
5230
+ if (unshredded.length)
5231
+ throw new Error(`${rejectionText(e)}; cleanup failed: ${unshredded.join("; ")}`, { cause: e });
5232
+ throw e;
5233
+ }
5234
+ }
5225
5235
  }
5226
5236
  else if (this.auth) {
5227
5237
  // Unit B (§13.1): reserve + activate this incarnation's DURABLE identity BEFORE any
@@ -6144,19 +6154,7 @@ export class Manager {
6144
6154
  await this.recordSlotRuntime(managed);
6145
6155
  }
6146
6156
  catch (error) {
6147
- const detail = `${managed.name} resumed, but ${error.message}`;
6148
- this.stopHandle(managed, false);
6149
- try {
6150
- await this.awaitHandleExit(managed.handle);
6151
- }
6152
- catch (exit) {
6153
- // A resumed seat keeps its retained credentials, so freeing it runs no deprovision and
6154
- // nothing would reap it: it stays managed until it exits or the manager stops it.
6155
- this.watchExit(managed);
6156
- return { ok: false, error: `${detail}; it stays managed because its stop is unproven: ${exit.message}` };
6157
- }
6158
- this.freeSlot(managed, true, "resume-custody-unrecorded", true);
6159
- return { ok: false, error: detail };
6157
+ return await this.stopFailedResume(managed, "resume-custody-unrecorded", `${managed.name} resumed, but ${error.message}`);
6160
6158
  }
6161
6159
  const readiness = await this.awaitReadiness(managed, readinessTimeoutMs);
6162
6160
  if (!readiness.ok && !readiness.uncertain)
@@ -6183,9 +6181,7 @@ export class Manager {
6183
6181
  }
6184
6182
  }
6185
6183
  catch (error) {
6186
- this.stopHandle(managed, false);
6187
- this.freeSlot(managed, true, "resume-session-rebind-failed", true);
6188
- return { ok: false, error: `${managed.name} resumed, but its exact host session could not be rebound: ${error.message}` };
6184
+ return await this.stopFailedResume(managed, "resume-session-rebind-failed", `${managed.name} resumed, but its exact host session could not be rebound: ${error.message}`);
6189
6185
  }
6190
6186
  }
6191
6187
  if (!this.resumeAttemptId)
@@ -6208,6 +6204,21 @@ export class Manager {
6208
6204
  }
6209
6205
  }
6210
6206
  }
6207
+ /** A resumed seat keeps its retained credentials, so freeing it runs no deprovision and nothing
6208
+ * would reap it: it is freed only once its exit is proved, and otherwise stays managed until it
6209
+ * exits or the manager stops it. */
6210
+ async stopFailedResume(managed, cause, detail) {
6211
+ this.stopHandle(managed, false);
6212
+ try {
6213
+ await this.awaitHandleExit(managed.handle);
6214
+ }
6215
+ catch (exit) {
6216
+ this.watchExit(managed);
6217
+ return { ok: false, error: `${detail}; it stays managed because its stop is unproven: ${exit.message}` };
6218
+ }
6219
+ this.freeSlot(managed, true, cause, true);
6220
+ return { ok: false, error: detail };
6221
+ }
6211
6222
  probeStaticCredential(creds) {
6212
6223
  return probeConnect(this.servers ?? DEFAULT_SERVER, { creds, timeoutMs: 5_000 });
6213
6224
  }
@@ -6331,7 +6342,7 @@ export class Manager {
6331
6342
  // waitForExit may close the attach stream before this snapshot
6332
6343
  let tail = "";
6333
6344
  try {
6334
- tail = this.tail(await s.backlog());
6345
+ tail = await this.tail(s);
6335
6346
  }
6336
6347
  catch { }
6337
6348
  if (opts.reapOnExit !== false)
@@ -6367,19 +6378,34 @@ export class Manager {
6367
6378
  onPresence();
6368
6379
  });
6369
6380
  }
6370
- /** Last non-empty line of terminal output as a single trimmed, control-char-stripped snippet
6371
- * (≤160 chars) — a readable one-line cause for an early-exit diagnostic, never the raw ANSI
6372
- * scrollback. */
6373
- tail(buf) {
6374
- const text = buf
6375
- .toString("utf8")
6376
- .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "") // strip CSI escape sequences
6377
- .replace(/[^\x20-\x7e\n]/g, "") // drop other control / non-printable bytes
6378
- .split("\n")
6379
- .map((l) => l.trim())
6380
- .filter(Boolean)
6381
- .pop() ?? "";
6382
- return text.length > 160 ? `…${text.slice(-160)}` : text;
6381
+ /** Last non-empty line of the session's rendered backlog as a single trimmed snippet (≤160 chars)
6382
+ * — a readable one-line cause for an early-exit diagnostic. The backlog repaints cells a TUI
6383
+ * skipped with cursor moves, so it is rendered and the line is read off the terminal; stripping
6384
+ * its escapes would glue the words those moves separate. */
6385
+ async tail(session) {
6386
+ const backlog = await session.backlog();
6387
+ // Sized after the snapshot: a custodial seat's snapshot carries the geometry it was taken at.
6388
+ // The headless build exposes `buffer` only as proposed API.
6389
+ const term = new Headless.Terminal({ cols: session.cols, rows: session.rows, allowProposedApi: true });
6390
+ await new Promise((done) => term.write(backlog, done));
6391
+ const screen = term.buffer.active;
6392
+ let text = "";
6393
+ for (let y = screen.length - 1; y >= 0; y--) {
6394
+ const row = screen.getLine(y);
6395
+ // A line wider than the screen continues across wrapped rows, so it is read whole.
6396
+ text = row.translateToString(text === "") + text;
6397
+ if (row.isWrapped)
6398
+ continue;
6399
+ // Blanks a program painted survive the right trim, so a line of only blanks is skipped.
6400
+ if (text.trim())
6401
+ break;
6402
+ text = "";
6403
+ }
6404
+ term.dispose();
6405
+ text = text.trim();
6406
+ // Cut on code points: half a surrogate pair is not I-JSON, and a launch terminal carrying it is refused.
6407
+ const chars = [...text];
6408
+ return chars.length > 160 ? `…${chars.slice(-160).join("")}` : text;
6383
6409
  }
6384
6410
  /** Subscribe to a managed agent's process-exit so a self-driven exit frees its slot and reaps
6385
6411
  * its children (P4b/P4c). Only pty streams exit (via the attach session's `onExit`); external runtimes'
@@ -6930,7 +6956,7 @@ export class Manager {
6930
6956
  const servePrincipal = principalKey(DEV_OWNER, serveIdentity.id).key;
6931
6957
  // An open mesh mints no credentials, so it has no family for a fresh pair to grow.
6932
6958
  const siblings = auth ? claimManagerSiblingIdentities(this.workspaceRoot, this.space) : { goalWriter: newIdentity(), sessionLedger: newIdentity() };
6933
- // must-5 (b): the STABLE goal-writer identity — a SIBLING credential in the same §13.1 family
6959
+ // The STABLE goal-writer identity — a SIBLING credential in the same §13.1 family
6934
6960
  // (not the gate's bound serving principal), so the run block can family-stage it.
6935
6961
  this.goalWriterIdentity = siblings.goalWriter;
6936
6962
  // P2 item 6: the STABLE session-LEDGER identity — another SIBLING in the SAME §13.1 family, so
@@ -7001,7 +7027,7 @@ export class Manager {
7001
7027
  // Open mesh: NO mint - the §13.1 fence is issuance-only and nothing is ever issued, so the
7002
7028
  // gate keeps an empty `epcred` family; the serve connection below stays bare.
7003
7029
  const creds = auth ? await mintCreds(auth, serveIdentity, "endpoint-serve", { serveIssuance: fence, endpointServe: grant }) : undefined;
7004
- // must-5 (b): mint + family-stage the goal-writer credential HERE, over this executor's
7030
+ // Mint + family-stage the goal-writer credential HERE, over this executor's
7005
7031
  // authKv (the fence is live), so its credId lands in `epcred.<e>.<iid>` and the takeover
7006
7032
  // barrier revokes it. Open mesh: no mint (no credential system; the goal-writer conn is bare).
7007
7033
  const goalWriterCreds = auth ? await this.mintAndStageGoalWriter(authKv) : undefined;
@@ -7056,7 +7082,7 @@ export class Manager {
7056
7082
  if (state.creds !== undefined)
7057
7083
  this.scheduleServeRenewal(state.creds);
7058
7084
  }
7059
- /** P2 item 2 must-5 (b): mint the standing `goal-writer` credential and STAGE it into this
7085
+ /** Mint the standing `goal-writer` credential and STAGE it into this
7060
7086
  * instance's §13.1 revocation family (`epcred.<e>.<iid>`), over the passed executor's `authKv`
7061
7087
  * (the scoped `endpoint-serve-executor`, which holds the epcred write grant). The GRANT profile
7062
7088
  * stays goal-writer-only (Q2 — disjoint from the serve credential); only the FAMILY membership
@@ -7494,7 +7520,7 @@ export class Manager {
7494
7520
  }
7495
7521
  }
7496
7522
  }
7497
- /** P2 item 2 must-5 Q-B — the boot reconcile: a fresh incarnation (a manager restart takes a NEW
7523
+ /** The boot reconcile: a fresh incarnation (a manager restart takes a NEW
7498
7524
  * instanceId, so the in-memory acceptance map starts empty) inherits the endpoint's accepted-but-
7499
7525
  * unterminal goals from any predecessor. The local signer enumerates with an ephemeral PROVISIONER.
7500
7526
  * A remote manager calls the authenticated host-owned scan, which keeps the sealed consumer and its
@@ -7655,19 +7681,20 @@ export class Manager {
7655
7681
  if (observed === null)
7656
7682
  throw new EpEnvelopeError("expired", `the manager's issuance gate for ${MANAGER_ENDPOINT}/${this.managerInstanceId} is gone; a retired incarnation never commits a goal terminal (SPEC 13.1/13.6)`);
7657
7683
  if (observed.processEpoch !== epoch)
7658
- throw new EpEnvelopeError("expired", `the manager's issuance gate epoch is ${observed.processEpoch} but this goal was accepted under epoch ${epoch}; a superseded incarnation never commits a goal terminal (must-5 (a) own-gate belt, SPEC 13.6)`);
7684
+ throw new EpEnvelopeError("expired", `the manager's issuance gate epoch is ${observed.processEpoch} but this goal was accepted under epoch ${epoch}; a superseded incarnation never commits a goal terminal (SPEC 13.6)`);
7659
7685
  }
7660
7686
  /** Serve `spawn`/`launch` as an ACTION (P2 item 2). Authz already ran in {@link serveGated}. The
7661
7687
  * accept path runs INLINE on the handler ({@link startAgent} with hooks): the goal binds + the
7662
- * acceptance replies the moment the identity is minted, BEFORE any provision (pin 1); progress and
7663
- * the terminal are driven OFF-handler, so the ~30s readiness wait no longer blocks the reply.
7688
+ * acceptance replies the moment the identity is minted, BEFORE any provision (pin 1), or on the
7689
+ * hosted enrollment arm once the host has enrolled the agent; progress and the terminal are driven
7690
+ * OFF-handler, so the ~30s readiness wait no longer blocks the reply.
7664
7691
  * Returns the acceptance floor payload {name, owner, actor, uid, goalId, fingerprint, executor}
7665
7692
  * (the ALLOCATED identity). goalId = the request id (env.id, Q3). */
7666
7693
  async serveSpawnGoal(ctx, run) {
7667
7694
  const gw = this.goalWriter;
7668
7695
  if (!gw)
7669
7696
  throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing; spawn-as-action cannot accept (SPEC 13.6)");
7670
- // must-5 Q-B: refuse to accept until the boot reconcile of inherited goals completes, so a fresh
7697
+ // Refuse to accept until the boot reconcile of inherited goals completes, so a fresh
7671
7698
  // acceptance never races the sweep (settling a live goal mid-flight would steal its real terminal).
7672
7699
  if (!this.goalReconcileDone)
7673
7700
  throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; retry shortly (SPEC 13.6)");
@@ -7712,7 +7739,7 @@ export class Manager {
7712
7739
  // message as the caller-visible outcome.
7713
7740
  let ownsGoal = false;
7714
7741
  // The terminal commits OFF-handler on the goal-writer connection (manager-only authority; a
7715
- // caller cannot publish it). TWO COMPOSED FENCES (defense in depth): must-5 (a) reads THIS
7742
+ // caller cannot publish it). TWO COMPOSED FENCES (defense in depth): (a) reads THIS
7716
7743
  // incarnation's OWN gate epoch and REFUSES a superseded commit (the currency belt), and (b)
7717
7744
  // barrier-revoke evicts this connection on takeover. The terminal lands on the ONE subject
7718
7745
  // SPEC §13.2 (reserved subjects) reserves; first-terminal-fact-wins is global, so a committed outcome is visible
@@ -7730,7 +7757,7 @@ export class Manager {
7730
7757
  return;
7731
7758
  terminalEntered = true; // entered, not succeeded — see the catch below
7732
7759
  try {
7733
- await this.assertGoalWriterEpochCurrent(epoch); // must-5 (a): a superseded corpse never commits
7760
+ await this.assertGoalWriterEpochCurrent(epoch); // a superseded corpse never commits
7734
7761
  let fact;
7735
7762
  if (o.kind === "succeeded") {
7736
7763
  this.emitGoalProgress(ref, epoch, { phase: "presence" });
@@ -7746,7 +7773,7 @@ export class Manager {
7746
7773
  ({ fact } = await settleGoalUncertain(gw.ctx, { ref, now: Date.now(), committer: { instanceId: this.managerInstanceId, epoch }, ...(typeof why === "string" && why.length > 0 ? { reason: why } : {}) }));
7747
7774
  }
7748
7775
  this.emitGoalProgress(ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
7749
- await clearGoalIndex(gw.ctx, ref); // must-5 Q-B: terminal reached - the successor never reconciles it
7776
+ await clearGoalIndex(gw.ctx, ref); // terminal reached - the successor never reconciles it
7750
7777
  if (acceptance)
7751
7778
  this.agentGoals.delete(acceptance.name); // goal terminal - no cancel path left
7752
7779
  }
@@ -7763,7 +7790,7 @@ export class Manager {
7763
7790
  };
7764
7791
  const bg = run({
7765
7792
  onAccepted: async ({ name, agentTriple }) => {
7766
- // must-5 Q-B: record the goal in the reconcile index BEFORE the bind (index-CAS-before-bind),
7793
+ // Record the goal in the reconcile index BEFORE the bind (index-CAS-before-bind),
7767
7794
  // so a successor incarnation finds + settles this goal if we crash before its terminal. A
7768
7795
  // crash between this write and the bind leaves an index entry whose goal status is absent —
7769
7796
  // the sweep clears it as a no-goal; a crash before it leaves no entry (never durable). A
@@ -8138,6 +8165,58 @@ export class Manager {
8138
8165
  this.maybeStopTurnSweep();
8139
8166
  return { goalId, state: fact.state };
8140
8167
  }
8168
+ /** The caller withdraws a turn it submitted (the reserved `cancel`, SPEC 13.6 item 4), so the
8169
+ * seat is not shown it again and a late yield is answered from the cancelled terminal. Only a
8170
+ * relay this incarnation holds is moved to `cancelling`, because only its holder can commit the
8171
+ * terminal; an ended goal is refused with its cached outcome, which keeps a repeated cancel
8172
+ * idempotent. The pending entry is the latch the yield and the deadline sweep race on, taken
8173
+ * before the first await so a concurrent cancel finds nothing to take. A failed cancel puts it
8174
+ * back so the relay still ends at its deadline, unless something ended the relay while it was
8175
+ * held: a yield that read the entry first, or this commit with only its reply lost. An entry
8176
+ * whose outcome cannot be read back stays out and converges through the goal index at the
8177
+ * next boot, as a failed deadline commit does. */
8178
+ async serveTurnCancel(ctx) {
8179
+ if (!this.goalReconcileDone)
8180
+ throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; the pending-turn index is not rebuilt yet, retry (SPEC 13.6)");
8181
+ const gw = this.goalWriter;
8182
+ if (!gw)
8183
+ throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing (SPEC 13.6)");
8184
+ const raw = (ctx.request.args ?? {});
8185
+ const goalId = String(raw.goalId);
8186
+ const ref = goalRefOf(ctx.subject, goalId);
8187
+ const p = this.pendingTurns.get(goalId);
8188
+ const c = p?.ref.caller;
8189
+ if (p === undefined || c?.owner !== ref.caller.owner || c.actor !== ref.caller.actor || c.uid !== ref.caller.uid) {
8190
+ const ended = await readGoalResult(gw.ctx, ref);
8191
+ if (ended !== undefined)
8192
+ throw goalAlreadyTerminal(goalId, ended);
8193
+ throw new EpEnvelopeError("failed-precondition", `no turn "${goalId}" of this caller is pending on this manager; cancel withdraws a relayed turn (SPEC 13.6)`);
8194
+ }
8195
+ this.pendingTurns.delete(goalId);
8196
+ const epoch = this.serviceServe?.grant.epoch ?? 0;
8197
+ let fact;
8198
+ try {
8199
+ await requestGoalCancel(gw.ctx, { request: ctx.subject, goalId, mode: (raw.mode ?? "graceful") });
8200
+ await this.assertGoalWriterEpochCurrent(epoch);
8201
+ ({ fact } = await commitGoalResult(gw.ctx, { ref, now: Date.now(), cause: "cancel", data: { cancelledBy: "caller" }, committer: { instanceId: this.managerInstanceId, epoch } }));
8202
+ }
8203
+ catch (e) {
8204
+ // The remembered answer is read after the durable one, with no await before the put back:
8205
+ // a yield that has dropped the entry remembered its answer, and one still committing drops
8206
+ // the entry it finds. An ended relay is stamped so its acceptance still ages off.
8207
+ if ((await readGoalResult(gw.ctx, ref)) !== undefined)
8208
+ this.markTurnLatchDropped(goalId);
8209
+ else if (this.turnAcceptances.get(goalId)?.settled === undefined)
8210
+ this.pendingTurns.set(goalId, p);
8211
+ throw e;
8212
+ }
8213
+ this.emitGoalProgress(ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
8214
+ this.markTurnLatchDropped(goalId);
8215
+ this.rememberSettledTurn(goalId, fact.state, Date.now());
8216
+ await clearGoalIndex(gw.ctx, ref);
8217
+ this.maybeStopTurnSweep();
8218
+ return { goalId, state: fact.state };
8219
+ }
8141
8220
  /** Commit the deadline terminal for one pending turn whose hold settled EXPIRED: the deny's
8142
8221
  * predicate is that recorded settle, verified by core against the spec's goal binding. The map
8143
8222
  * delete is the idempotency latch (yield-loss and sweep race here); a commit failure after it
@@ -8356,11 +8435,11 @@ export class Manager {
8356
8435
  this.agentGoals.delete(name);
8357
8436
  const epoch = this.serviceServe?.grant.epoch ?? 0;
8358
8437
  try {
8359
- await this.assertGoalWriterEpochCurrent(epoch); // must-5 (a): a superseded corpse never commits a cancel terminal either
8438
+ await this.assertGoalWriterEpochCurrent(epoch); // a superseded corpse never commits a cancel terminal either
8360
8439
  await transitionGoal(gw.ctx, ref, "cancelling", { fields: { cancelMode: mode } });
8361
8440
  const r = await commitGoalResult(gw.ctx, { ref, now: Date.now(), cause: "cancel", data: { cancelledBy: "despawn" }, committer: { instanceId: this.managerInstanceId, epoch } });
8362
8441
  this.emitGoalProgress(ref, epoch, { phase: "terminal", state: r.fact.state, ...(r.fact.data !== undefined ? { data: r.fact.data } : {}) });
8363
- await clearGoalIndex(gw.ctx, ref); // must-5 Q-B: terminal reached - the successor never reconciles it
8442
+ await clearGoalIndex(gw.ctx, ref); // terminal reached - the successor never reconciles it
8364
8443
  }
8365
8444
  catch {
8366
8445
  // the goal already terminalized (the readiness outcome won the settle race) - nothing to cancel.
@@ -8499,11 +8578,17 @@ export class Manager {
8499
8578
  console.error(`static retirement ${a.name}: retired ${retired} issuance(s) bound to uid ${a.lifecycleUid}`);
8500
8579
  const secrets = this.secrets;
8501
8580
  const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid);
8581
+ // The broker teardown mints its own credential, so a creds file that cannot be removed must not
8582
+ // skip it. The failures are thrown together, so the terminal still stops at an incomplete cleanup.
8583
+ const failed = [];
8502
8584
  if (files.creds) {
8503
- await secrets.delete(agentSecretKeyForFile(files.creds, this.space));
8504
- rmSync(files.creds, { force: true });
8585
+ const creds = files.creds;
8586
+ await attemptCleanup(failed, `secret ${creds}`, () => secrets.delete(agentSecretKeyForFile(creds, this.space)));
8587
+ await attemptCleanup(failed, `file ${creds}`, () => rmSync(creds, { force: true }));
8505
8588
  }
8506
- await this.deprovisionBroker(a);
8589
+ await attemptCleanup(failed, "broker teardown", () => this.deprovisionBroker(a));
8590
+ if (failed.length)
8591
+ throw new Error(`cleanup failed: ${failed.join("; ")}`);
8507
8592
  };
8508
8593
  // The process goes before its footprint: reap the orphan seat by reference, then tear down.
8509
8594
  const reapThenCleanup = async () => {