@cotal-ai/manager 0.52.1 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/manager.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, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
6
6
  import { isAbsolute, join, dirname, resolve } from "node:path";
7
- import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, divergentSecretStoreNotice, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, 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, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
8
- import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, 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, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, 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, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
8
+ import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, 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";
9
9
  import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
10
10
  import { AttachEndpoint } from "./attach-endpoint.js";
11
11
  import { makeManagerEndpointEvictionEvidence, makeManagerEndpointEvictor } from "./endpoint-evict.js";
@@ -536,6 +536,7 @@ export class Manager {
536
536
  resumeDurableCommitToken;
537
537
  resumedAgentNames = new Set();
538
538
  remoteAuthority;
539
+ remoteExecutorCreds;
539
540
  /** Every broker dial this manager makes, through ONE transport decision.
540
541
  *
541
542
  * `this.servers` is whatever the mesh record held (`cotal supervise` reads it from there), so it
@@ -555,6 +556,7 @@ export class Manager {
555
556
  this.eventsRequired = opts.eventsRequired === true;
556
557
  this.maxSessions = opts.maxSessions;
557
558
  this.remoteAuthority = opts.remoteAuthority;
559
+ this.remoteExecutorCreds = opts.remoteAuthority?.executorCreds;
558
560
  if (opts.remoteAuthority)
559
561
  this.managerLifecycleUid = opts.remoteAuthority.lifecycleUid;
560
562
  this.secrets = opts.secretStore ?? workspaceSecretStore(this.workspaceRoot);
@@ -663,9 +665,24 @@ export class Manager {
663
665
  // through the SecretStore seam (`this.secrets` — the injected `ManagerOptions.secretStore`, or
664
666
  // the local `.cotal/auth/auth.json` FS default), so a HOSTED composition mints from its KMS/Vault
665
667
  // and no signing seed is ever read from the hosted disk. `this.space` cross-checks the bundle.
666
- this.auth = await getSpaceAuth(this.secrets, this.space);
667
- if (this.remoteAuthority && this.auth)
668
- throw new Error("remote manager-service authority cannot be combined with local space signing trust - choose one authority path, never a fallback");
668
+ //
669
+ // REMOTE AUTHORITY (#1944): a manager holding host-issued remote authority mints nothing from a
670
+ // local signer, so it never composes or validates local trust. It asks ONE question of the
671
+ // store — does THIS space's own local signing trust exist (`localTrustOfSpace`) — because that
672
+ // alone is the conflict of authorities a supervisor refuses. The stock behaviour this replaces
673
+ // read the whole root's trust chain (`getSpaceAuth`): with no records for this space it fell
674
+ // back to the legacy monolith of an UNRELATED static tenant and reported that bundle as
675
+ // "corrupt or mislabeled". Other tenants' records — split (their account + the shared
676
+ // per-root broker record) or monolith — are not this space's trust and never refuse; an
677
+ // unreadable record on a key that is read refuses loud with the store's own parse message.
678
+ if (this.remoteAuthority) {
679
+ if ((await localTrustOfSpace(this.secrets, this.space)).present)
680
+ throw new Error("remote manager-service authority cannot be combined with local space signing trust - choose one authority path, never a fallback");
681
+ this.auth = undefined;
682
+ }
683
+ else {
684
+ this.auth = await getSpaceAuth(this.secrets, this.space);
685
+ }
669
686
  // USER-MODE detection is FAIL-CLOSED on the on-disk marker (the space-scoped state dir), never
670
687
  // on the mutable mesh registry alone — registry drift/tamper must not let a user-auth space
671
688
  // take the static self-mint branch. A marker/registry disagreement is a refused start with the
@@ -818,6 +835,11 @@ export class Manager {
818
835
  this.credRenewTimer = setInterval(() => { void this.renewDaemonCreds(); }, credRenewIntervalMs(STANDING_RENEWABLE_TTL_SEC));
819
836
  this.credRenewTimer.unref?.();
820
837
  }
838
+ else if (this.remoteAuthority) {
839
+ await this.renewRemoteExecutor();
840
+ this.credRenewTimer = setInterval(() => { void this.renewRemoteExecutor(); }, credRenewIntervalMs(5 * 60));
841
+ this.credRenewTimer.unref?.();
842
+ }
821
843
  // stop() fences before it waits for an accepted startup reconciliation terminal. Once that
822
844
  // terminal drains, start() and stop() resume from the same await boundary; start must observe
823
845
  // the fence before it can publish a fresh service registration after shutdown.
@@ -868,13 +890,27 @@ export class Manager {
868
890
  // manager records each agent's read ACL at spawn (`commitAcl`, in provisionAgent) so the daemon can
869
891
  // re-authorize it; that is the only Plane-3 state the manager touches, and it rides minting.
870
892
  }
893
+ /** The delivery shard whose lease names the process that reloads the standing credentials.
894
+ * Delivery is single-shard today and the endpoint's own responder reads row 0; naming it here
895
+ * keeps the manager's binding check and that reply arguing about the same row. */
896
+ static DELIVERY_SHARD = 0;
871
897
  /** Proof that this manager remints into the store the delivery daemon reloads from.
872
898
  * Fingerprint-only `reloadCreds` is safe only after this. Two named, different stores
873
899
  * refuse the pass. A daemon that is not bound yet is not a named store: start and
874
900
  * remint still proceed (tests, delayed delivery), writing this manager's store. The
875
901
  * challenge runs again before every remint, so a later daemon on a foreign store is
876
902
  * refused then rather than certified by an earlier absence. A request timeout is not
877
- * absence: a hung rail would skip the proof, so it fails closed. */
903
+ * absence: a hung rail would skip the proof, so it fails closed.
904
+ *
905
+ * ABSENCE IS READ OFF THE LEASE ROW, never off the rail's no-responder shape alone (#1694).
906
+ * The rail is queue-grouped, so "no responder answered" is itself something the rail reports,
907
+ * and a responder that answers in a shape the client turns into that error makes the pass read
908
+ * "absent" while a holder is live on the row — certifying every later remint with no proof at
909
+ * all. So a no-responder outcome is decided by the row: a row that names a holder makes the
910
+ * pass refuse (undetermined — a live daemon went unanswered, which must not read as "no
911
+ * daemon"), and only a row that is absent or holderless settles "absent", the state start()
912
+ * treats as not-yet-bound. An unreadable row (a failed read, or a key that exists but does not
913
+ * parse) is a genuine unknown and refuses fail-closed, like a hung rail. */
878
914
  async daemonStoreRelation() {
879
915
  let reply;
880
916
  try {
@@ -883,24 +919,77 @@ export class Manager {
883
919
  catch (e) {
884
920
  const msg = e.message;
885
921
  if (isAbsentDeliveryAdmin(msg))
886
- return "absent";
922
+ return this.absentByLeaseRow();
887
923
  throw new Error(`could not challenge the delivery daemon's SecretStore: ${msg}`);
888
924
  }
889
925
  if (!reply.ok)
890
926
  throw new Error(reply.error ?? "delivery daemon refused to name the SecretStore it reloads from");
891
- let daemon;
927
+ let answer;
892
928
  try {
893
- daemon = parseSecretStoreIdentity(reply.data);
929
+ answer = parseDaemonStoreAnswer(reply.data);
894
930
  }
895
931
  catch (e) {
896
- throw new Error(`delivery daemon named an unreadable SecretStore: ${e.message}`);
897
- }
932
+ throw new Error(`delivery daemon named an unreadable SecretStore: ${e.message}. A daemon older ` +
933
+ `than #1694 replies with a bare store identity and no answerer binding; this manager ` +
934
+ `cannot establish which process answered, so nothing reminted - upgrade the delivery daemon`);
935
+ }
936
+ // The answerer's own claim first, so an honest non-holder is refused by its own admission and
937
+ // the operator-facing reason names that. This is the CHEAP half of the test and NOT the binding
938
+ // one: a responder that does not hold the lease can assert `true` here just as easily.
939
+ if (!answer.holdsDeliveryLease)
940
+ throw new Error("the delivery-admin rail was answered by a responder that does not hold this space's " +
941
+ "delivery lease, so its SecretStore is not the store the daemon reloads from - nothing reminted");
942
+ // THE BINDING TEST, measured by this manager rather than asserted by the answerer. The lease
943
+ // row's holder is read here under this manager's own credential; the answerer must BE that
944
+ // principal. A reply asserting the claim while some other process holds the shard fails here,
945
+ // which is the case a bare store identity could not see. An unreadable row is an unknown and
946
+ // refuses too: this test may never be satisfied by failing to run.
947
+ const holder = await this.ep.deliveryLeaseHolder(Manager.DELIVERY_SHARD);
948
+ if (holder === undefined)
949
+ throw new Error("this manager could not read the delivery lease row, so it cannot verify that the answering " +
950
+ "responder is the process that reloads the standing credentials - nothing reminted");
951
+ if (holder !== answer.responder)
952
+ throw new Error(`the delivery-admin rail was answered by ${answer.responder}, which is not the holder of ` +
953
+ `this space's delivery lease (${holder}), so its SecretStore is not the store the daemon ` +
954
+ `reloads from - nothing reminted`);
955
+ const daemon = answer.identity;
898
956
  if (!sameSecretStoreIdentity(this.secretStoreIdentity, daemon)) {
899
957
  console.error(`! ${divergentSecretStoreNotice(this.secretStoreIdentity, daemon)}`);
900
958
  return "divergent";
901
959
  }
902
960
  return "shared";
903
961
  }
962
+ /** Settle a no-responder challenge outcome from the lease row, never from the rail's own
963
+ * reporting shape (#1694). The three cases:
964
+ *
965
+ * 1. The row READS but names a holder: a live daemon holds the shard and the rail went
966
+ * unanswered. That is undetermined, not "no daemon" — treating it as absent would let this
967
+ * manager remint with no proof while a reloading daemon runs, which is exactly what the
968
+ * challenge exists to prevent. Refuse.
969
+ * 2. The row is unreadable — a read that fails (a denied or erroring bucket) OR a key that
970
+ * exists but does not parse as a lease record (`readDeliveryLeaseEntry` throws for both):
971
+ * undetermined, and it refuses the same fail-closed way a hung rail does. Never a silent
972
+ * "absent" over a row the manager could not read.
973
+ * 3. The row is absent or holderless (no key, a DEL/PURGE tombstone, or a row that names no
974
+ * holder): today's "absent", the state start() treats as a daemon that is not bound yet.
975
+ *
976
+ * `deliveryLeaseHolder` swallows its read failures into `undefined`, which would fold case 2
977
+ * into case 3 — so this reads the entry directly and lets the read's own failure refuse. */
978
+ async absentByLeaseRow() {
979
+ let entry;
980
+ try {
981
+ entry = await this.ep.readDeliveryLeaseEntry(Manager.DELIVERY_SHARD);
982
+ }
983
+ catch (e) {
984
+ throw new Error(`could not read the delivery lease row to settle whether the delivery daemon is absent ` +
985
+ `(${e.message}) - absence is undetermined, nothing reminted`);
986
+ }
987
+ const holder = entry?.info.holder;
988
+ if (holder !== undefined && holder !== "")
989
+ throw new Error(`the delivery-admin rail reported no responder while the delivery lease names a holder ` +
990
+ `(${holder}) - absence cannot be read off the rail alone, and nothing reminted`);
991
+ return "absent";
992
+ }
904
993
  /** Keep this manager's renewal lease alive between credential passes (#1634). The manager bucket
905
994
  * expires keys at MANAGER_LEASE_TTL_MS, far below the credential pass's cadence, so the lease
906
995
  * needs its own heartbeat like the per-instance liveness lease. A non-owner calls this too: the
@@ -933,7 +1022,23 @@ export class Manager {
933
1022
  *
934
1023
  * A manager failing either test serves the space normally and skips only the daemon remint. */
935
1024
  async claimDaemonRenewalOwnership() {
936
- if ((await this.daemonStoreRelation()) === "divergent") {
1025
+ // EVERY refusal outcome drops a held lease, not only "divergent". The refusal paths THROW
1026
+ // (#1694: a non-holder answer, an unbindable answerer, an unreadable or holder-naming row on a
1027
+ // no-responder rail), and a thrown refusal caught by renewDaemonCreds would otherwise leave a
1028
+ // manager that once owned the renewal still holding the lease behind its own heartbeat — the
1029
+ // pass refuses, the log says so, and the space keeps a renewal owner that must not remint.
1030
+ // Deciding here, inside the claim, is what keeps the release on the refusal path itself.
1031
+ let relation;
1032
+ try {
1033
+ relation = await this.daemonStoreRelation();
1034
+ }
1035
+ catch (e) {
1036
+ if (this.daemonRenewalOwner)
1037
+ await this.ep.releaseDaemonRenewalLease().catch(() => { });
1038
+ this.daemonRenewalOwner = false;
1039
+ throw e;
1040
+ }
1041
+ if (relation === "divergent") {
937
1042
  // Not our daemon's store: drop any lease we hold rather than sit on it, so the manager that
938
1043
  // CAN remint is not blocked behind us.
939
1044
  if (this.daemonRenewalOwner)
@@ -1106,6 +1211,23 @@ export class Manager {
1106
1211
  release();
1107
1212
  }
1108
1213
  }
1214
+ /** Renew the remote registration executor through the typed host protocol. A healthy credential
1215
+ * is retained; near-expiry and expired credentials are replaced for the same nkey. */
1216
+ async renewRemoteExecutor(force = false) {
1217
+ if (!this.remoteAuthority)
1218
+ return;
1219
+ const current = this.remoteExecutorCreds;
1220
+ if (!force && current && inspectCredHealth(current).state === "healthy")
1221
+ return;
1222
+ try {
1223
+ this.remoteExecutorCreds = await this.remoteAuthority.renewExecutor();
1224
+ }
1225
+ catch (e) {
1226
+ console.error(`! remote manager executor renewal: ${e.message} - registration maintenance and clean deregistration remain unavailable until renewal succeeds`);
1227
+ if (force)
1228
+ throw e;
1229
+ }
1230
+ }
1109
1231
  /** Warn, on every renewal pass, when a $SYS credential is at or past its renewal point.
1110
1232
  *
1111
1233
  * The manager is the renewal owner for every credential it CAN re-sign, and these two are the ones
@@ -2193,6 +2315,16 @@ export class Manager {
2193
2315
  const data = unwrap(await this.opModels(args(ctx)));
2194
2316
  return { catalogs: Array.isArray(data) ? data : [data] };
2195
2317
  }),
2318
+ goalResult: (ctx) => this.serveGated(ctx, async () => {
2319
+ const gw = this.goalWriter;
2320
+ if (!gw || gw.nc.isClosed())
2321
+ throw new EpEnvelopeError("unavailable", "the manager goal-reader connection is not standing; the accepted goal is unaffected");
2322
+ const goalId = args(ctx).goalId;
2323
+ // Only the broker-authenticated caller's full incarnation can name this read. The
2324
+ // trusted goal-writer keeps the raw EPF authority; serveEndpoint derives the reply rail.
2325
+ const result = await readGoalResult(gw.ctx, goalRefOf(ctx.subject, goalId));
2326
+ return { goalId, ...(result === undefined ? {} : { result }) };
2327
+ }),
2196
2328
  resolveCwd: (ctx) => this.serveGated(ctx, () => this.resolveSpawnCwd(args(ctx).cwd)),
2197
2329
  // P2 item 2: `spawn` is an ACTION - accept a goal + reply the acceptance floor payload, drive
2198
2330
  // progress + terminal off-handler (no ~30s block). The blocking reply path is gone (pin 8).
@@ -3122,6 +3254,7 @@ export class Manager {
3122
3254
  ? { ...restart.opts, resume: undefined, prompt: undefined, continueSession }
3123
3255
  : { ...restart.opts, resume: undefined, prompt: undefined };
3124
3256
  const spec = connector.buildLaunch(opts);
3257
+ spec.env = { ...spec.env, COTAL_MANAGER_INSTANCE: this.managerInstanceId };
3125
3258
  const wanted = this.managedPrincipal(a);
3126
3259
  const joinedAfter = this.ep.getRoster()
3127
3260
  .filter((p) => p.card.id === wanted && p.lifecycleUid === a.lifecycleUid)
@@ -4122,6 +4255,7 @@ export class Manager {
4122
4255
  workspaceRoot: this.workspaceRoot,
4123
4256
  };
4124
4257
  const spec = connector.buildLaunch(launchOpts);
4258
+ spec.env = { ...spec.env, COTAL_MANAGER_INSTANCE: this.managerInstanceId };
4125
4259
  const handle = await this.spawnCustodied(name, spec, cwd, custody);
4126
4260
  hooks?.onLaunched?.(); // P2 item 2: the "launched" progress edge (process spawned, pre-presence)
4127
4261
  const managed = {
@@ -4681,6 +4815,7 @@ export class Manager {
4681
4815
  workspaceRoot: this.workspaceRoot,
4682
4816
  };
4683
4817
  const spec = connector.buildLaunch(launchOpts);
4818
+ spec.env = { ...spec.env, COTAL_MANAGER_INSTANCE: this.managerInstanceId };
4684
4819
  const value = { spec, launchOpts, ...authority };
4685
4820
  prepared?.set(entry.name, value);
4686
4821
  if (preflightOnly)
@@ -5108,7 +5243,9 @@ export class Manager {
5108
5243
  * This is NEVER the manager's standing seed/supervisor connection. */
5109
5244
  async withEndpointServeExecutor(fn) {
5110
5245
  const identity = this.remoteAuthority?.identities.executor ?? newIdentity();
5111
- const creds = this.remoteAuthority?.executorCreds ?? (this.auth
5246
+ if (this.remoteAuthority && (!this.remoteExecutorCreds || inspectCredHealth(this.remoteExecutorCreds).state !== "healthy"))
5247
+ await this.renewRemoteExecutor(true);
5248
+ const creds = this.remoteExecutorCreds ?? (this.auth
5112
5249
  ? await mintCreds(this.auth, identity, "endpoint-serve-executor", {
5113
5250
  endpointServeExecutor: { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId },
5114
5251
  ...(this.endpointServeExecutorExpiresInSeconds !== undefined