@cotal-ai/manager 0.70.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,11 +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
6
  import Headless from "@xterm/headless";
8
- 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";
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";
9
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";
10
9
  import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
11
10
  import { AttachEndpoint } from "./attach-endpoint.js";
@@ -31,7 +30,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
31
30
  // P2 item 1 (1a-serve): the manager as an ordinary v0.4 `service` endpoint — the §13.1
32
31
  // endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
33
32
  // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
34
- 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";
35
34
  import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
36
35
  import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, ownedBySibling, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
37
36
  /** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
@@ -148,19 +147,6 @@ async function attemptCleanup(failed, what, step) {
148
147
  failed.push(`${what}: ${rejectionText(err)}`);
149
148
  }
150
149
  }
151
- /** Run the agent's bearer argv once, pre-launch — the end-to-end auth preflight (state dir, daemon,
152
- * ledger row, secret). Its stderr is the provider command's operator-exact sentence; surface it
153
- * verbatim as the spawn refusal. */
154
- function execBearerPreflight(argv) {
155
- const timeout = 30_000;
156
- return new Promise((res, rej) => {
157
- execFile(argv[0], argv.slice(1), { timeout, maxBuffer: 64 * 1024 }, (err, _stdout, stderr) => {
158
- if (err)
159
- return rej(bearerCommandFailure(err, stderr, timeout));
160
- res();
161
- });
162
- });
163
- }
164
150
  /** Reject `p` with `Error(msg)` if it hasn't settled within `ms`; clears the timer when `p` settles so it
165
151
  * never keeps the loop alive. Used to bound the detached deprovision so its fail-loud log is guaranteed. */
166
152
  function withTimeout(p, ms, msg) {
@@ -252,6 +238,20 @@ function foreignEventChannels(channels, owner, actor) {
252
238
  return p !== null && !(p.owner === owner && p.actor === actor);
253
239
  });
254
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
+ }
255
255
  function injectedManagerStoreIdentity(store) {
256
256
  if (store.identity !== undefined)
257
257
  return parseSecretStoreIdentity(store.identity);
@@ -2875,6 +2875,9 @@ export class Manager {
2875
2875
  // turns addressed to ITS incarnation and yields them; no reach beyond itself exists here.
2876
2876
  turnPending: (ctx) => this.serveGated(ctx, () => this.turnPendingFor(ctx.subject.caller)),
2877
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)),
2878
2881
  stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
2879
2882
  definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
2880
2883
  listPersonas: (ctx) => this.serveGated(ctx, () => unwrap(this.opListPersonas(callerOf(ctx), false))),
@@ -3248,7 +3251,9 @@ export class Manager {
3248
3251
  "--token-file", tokenPath,
3249
3252
  "--health-file", healthPath,
3250
3253
  ];
3251
- 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);
3252
3257
  return { owner, lifecycleUid: opts.lifecycleUid, files, launch: { owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd } };
3253
3258
  }
3254
3259
  catch (e) {
@@ -3350,7 +3355,7 @@ export class Manager {
3350
3355
  "--token-file", files.actorToken,
3351
3356
  "--health-file", files.health,
3352
3357
  ];
3353
- await execBearerPreflight(bearerCmd);
3358
+ await runAgentBearer(bearerCmd);
3354
3359
  return {
3355
3360
  owner: material.owner,
3356
3361
  lifecycleUid: material.lifecycleUid,
@@ -4496,19 +4501,9 @@ export class Manager {
4496
4501
  else {
4497
4502
  declared = registry.all("connector").map((connector) => ({ name: connector.name, requires: connector.requires ?? [] }));
4498
4503
  }
4499
- for (const connector of declared) {
4500
- const name = connector.name;
4501
- const binaries = {};
4502
- const missing = [];
4503
- for (const bin of connector.requires) {
4504
- const path = resolveOnPath(bin);
4505
- if (path)
4506
- binaries[bin] = path;
4507
- else
4508
- missing.push(bin);
4509
- }
4510
- if (missing.length) {
4511
- 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) {
4512
4507
  rows.push({ agent: name, state: "unavailable", binaries, reason });
4513
4508
  console.error(`! manager boot: connector ${name} unavailable - ${reason}`);
4514
4509
  }
@@ -4540,18 +4535,17 @@ export class Manager {
4540
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)`;
4541
4536
  }
4542
4537
  /** The harness binaries a managed launch runs, as boot resolved them, or the refusal naming what is
4543
- * missing. Spawn and resume both take it, so a seat launches from the same files whichever path
4544
- * 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. */
4545
4541
  launchHarness(connector) {
4546
4542
  const row = this.connectorStatuses.find((status) => status.agent === connector.name);
4547
4543
  if (row?.state === "unavailable")
4548
4544
  return { refusal: row.reason ?? `${connector.name} harness is unavailable` };
4549
4545
  if (row)
4550
4546
  return { binaries: row.binaries };
4551
- const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
4552
- if (missing.length)
4553
- return { refusal: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found` };
4554
- return {};
4547
+ const { reason } = resolveHarness(connector.name, connector.requires ?? []);
4548
+ return reason ? { refusal: reason } : {};
4555
4549
  }
4556
4550
  /** The refusal for a launch choice the connector cannot honour. Spawn and resume both ask before
4557
4551
  * any reserve or mint, so a rule changed here holds on both paths. */
@@ -4573,14 +4567,9 @@ export class Manager {
4573
4567
  const one = async (connector) => {
4574
4568
  if (!connector.listModels)
4575
4569
  return { agent: connector.name, supported: false, models: [] };
4576
- const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
4577
- if (missing.length)
4578
- return {
4579
- agent: connector.name,
4580
- supported: true,
4581
- models: [],
4582
- error: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found`,
4583
- };
4570
+ const harness = this.launchHarness(connector);
4571
+ if ("refusal" in harness)
4572
+ return { agent: connector.name, supported: true, models: [], error: harness.refusal };
4584
4573
  try {
4585
4574
  const catalog = await connector.listModels({ refresh });
4586
4575
  return { agent: connector.name, supported: true, ...catalog };
@@ -8176,6 +8165,58 @@ export class Manager {
8176
8165
  this.maybeStopTurnSweep();
8177
8166
  return { goalId, state: fact.state };
8178
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
+ }
8179
8220
  /** Commit the deadline terminal for one pending turn whose hold settled EXPIRED: the deny's
8180
8221
  * predicate is that recorded settle, verified by core against the spec's goal binding. The map
8181
8222
  * delete is the idempotency latch (yield-loss and sweep race here); a commit failure after it
@@ -8537,11 +8578,17 @@ export class Manager {
8537
8578
  console.error(`static retirement ${a.name}: retired ${retired} issuance(s) bound to uid ${a.lifecycleUid}`);
8538
8579
  const secrets = this.secrets;
8539
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 = [];
8540
8584
  if (files.creds) {
8541
- await secrets.delete(agentSecretKeyForFile(files.creds, this.space));
8542
- 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 }));
8543
8588
  }
8544
- 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("; ")}`);
8545
8592
  };
8546
8593
  // The process goes before its footprint: reap the orphan seat by reference, then tear down.
8547
8594
  const reapThenCleanup = async () => {