@cotal-ai/manager 0.124.3 → 0.125.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
@@ -3,6 +3,7 @@ import { hostname } from "node:os";
3
3
  import { credsAuthenticator } from "@nats-io/transport-node";
4
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
5
5
  import { isAbsolute, join, dirname, resolve } from "node:path";
6
+ import { parse as parseYaml } from "yaml";
6
7
  import Headless from "@xterm/headless";
7
8
  import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, fenceLifecycleAcl, DeprovisionError, addDeprovisionAccounting, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, bindIssuedStores, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, oneLine, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, runAgentBearer, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, } from "@cotal-ai/core";
8
9
  import { loadLaunchPersonaFile, PersonaSourceError } from "@cotal-ai/workspace";
@@ -3306,6 +3307,12 @@ export class Manager {
3306
3307
  cancel: (ctx) => this.serveGated(ctx, () => this.serveTurnCancel(ctx)),
3307
3308
  stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
3308
3309
  definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
3310
+ removePersona: (ctx) => this.serveGated(ctx, () => {
3311
+ const r = this.opRemovePersona(args(ctx), callerOf(ctx), false);
3312
+ if (!r.ok)
3313
+ throw new EpEnvelopeError((r.code ?? "failed-precondition"), r.error ?? "the operation failed", r.details, "not-executed");
3314
+ return r.data;
3315
+ }),
3309
3316
  // Reach is the broker's: only operator instruments carry these rows. The serve gate is the
3310
3317
  // maintenance fence, so neither runs while a shutdown or resume holds control.
3311
3318
  chatSessionOpen: (ctx) => this.serveGated(ctx, () => this.openStaticChatSession(args(ctx))),
@@ -4443,10 +4450,14 @@ export class Manager {
4443
4450
  throw new Error(`cannot read connector session state ${path}: ${rejectionText(error)}`);
4444
4451
  }
4445
4452
  const state = parsed;
4446
- if (state.version !== 1 || typeof state.sessionId !== "string" || !state.sessionId.trim() || state.sessionId.length > 4096 ||
4447
- (state.status !== "running" && state.status !== "quit"))
4448
- throw new Error(`connector session state ${path} is malformed`);
4449
- return { sessionId: state.sessionId, status: state.status };
4453
+ if (state.version === 1 && typeof state.sessionId === "string" && state.sessionId.trim() && state.sessionId.length <= 4096 &&
4454
+ (state.status === "running" || state.status === "quit"))
4455
+ return { sessionId: state.sessionId, status: state.status };
4456
+ if (state.version === 2 && state.status === "prompt" && typeof state.connector === "string" && /^[a-z0-9-]{1,64}$/.test(state.connector) &&
4457
+ typeof state.prompt === "string" && state.prompt.trim() && state.prompt.length <= 1024 &&
4458
+ typeof state.cwd === "string" && state.cwd.trim() && state.cwd.length <= 4096)
4459
+ return { connector: state.connector, status: "prompt", prompt: state.prompt, cwd: state.cwd };
4460
+ throw new Error(`connector session state ${path} is malformed`);
4450
4461
  }
4451
4462
  readManagedSessionState(a) {
4452
4463
  const path = a.restart?.sessionStatePath;
@@ -4463,7 +4474,10 @@ export class Manager {
4463
4474
  return undefined;
4464
4475
  const path = join(this.workspaceRoot, ".cotal", "pi-sessions", `${entry.name}-${entry.identity.lifecycleUid}.json`);
4465
4476
  try {
4466
- return this.readSessionStatePath(path).sessionId;
4477
+ const state = this.readSessionStatePath(path);
4478
+ if (state.status === "prompt")
4479
+ throw new Error(`${state.connector} is waiting at prompt ${JSON.stringify(state.prompt)} for directory ${JSON.stringify(state.cwd)}`);
4480
+ return state.sessionId;
4467
4481
  }
4468
4482
  catch (error) {
4469
4483
  throw new Error(`retained ${connector.name} agent ${entry.name} has no sessionId in its older inventory and no usable upgrade state at ${path} ` +
@@ -4535,7 +4549,10 @@ export class Manager {
4535
4549
  this.freeSlot(a, true, "carried-fork-refused");
4536
4550
  }
4537
4551
  readManagedSession(a) {
4538
- return this.readManagedSessionState(a).sessionId;
4552
+ const state = this.readManagedSessionState(a);
4553
+ if (state.status === "prompt")
4554
+ throw new Error(`${state.connector} is waiting at prompt ${JSON.stringify(state.prompt)} for directory ${JSON.stringify(state.cwd)}`);
4555
+ return state.sessionId;
4539
4556
  }
4540
4557
  /** Re-record an exact seat's assignment at a proven session, so crash recovery, a preserved resume
4541
4558
  * and the next manifest launch never pick different sessions. */
@@ -4558,18 +4575,26 @@ export class Manager {
4558
4575
  async awaitManagedSessionState(a) {
4559
4576
  const deadline = Date.now() + 15_000;
4560
4577
  let last = "session state not written yet";
4578
+ let prompt;
4579
+ const promptFailure = () => prompt
4580
+ ? `${prompt.connector} is waiting at prompt ${JSON.stringify(prompt.prompt)} for directory ${JSON.stringify(prompt.cwd)}`
4581
+ : undefined;
4561
4582
  while (Date.now() < deadline) {
4562
- if (a.handle.status() === "exited")
4563
- throw new Error("process exited before writing session state");
4564
4583
  try {
4565
- return this.readManagedSessionState(a);
4584
+ const state = this.readManagedSessionState(a);
4585
+ if (state.status === "prompt")
4586
+ prompt = state;
4587
+ else
4588
+ return state;
4566
4589
  }
4567
4590
  catch (error) {
4568
4591
  last = rejectionText(error);
4569
4592
  }
4593
+ if (a.handle.status() === "exited")
4594
+ throw new Error(promptFailure() ?? "process exited before writing session state");
4570
4595
  await new Promise((resolve) => setTimeout(resolve, 100));
4571
4596
  }
4572
- throw new Error(`connector did not write session state (${last})`);
4597
+ throw new Error(promptFailure() ?? `connector did not write session state (${last})`);
4573
4598
  }
4574
4599
  /** Bind a continuation-capable connector only after its process is ready. The file proves the
4575
4600
  * latest in-process session (including Pi /resume); the authenticated socket proves this process
@@ -10594,6 +10619,62 @@ export class Manager {
10594
10619
  }
10595
10620
  return { ok: true, data: { name, path } };
10596
10621
  }
10622
+ /** Remove one machine-local persona definition without touching any running lifecycle that was
10623
+ * launched from it. Removal accepts the catalog's safe filename alphabet without applying the
10624
+ * current spawn-name length or user-mode actor rules, so an obsolete invalid-name entry remains
10625
+ * repairable. Ownership matches redefine: the recorded owner, or admin for an ownerless file. */
10626
+ opRemovePersona(args, caller, admin) {
10627
+ if (this.execution === "none")
10628
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
10629
+ const name = String(args.name ?? "").trim();
10630
+ if (!name)
10631
+ return { ok: false, code: "bad-request", error: "name required" };
10632
+ if (!/^[A-Za-z0-9_-]+$/.test(name))
10633
+ return { ok: false, code: "bad-request", error: `unsafe persona name ${JSON.stringify(name)} (allowed: letters, digits, _ -)` };
10634
+ const path = agentFilePath(this.workspaceRoot, name);
10635
+ if (!existsSync(path))
10636
+ return { ok: false, code: "not-found", error: `no persona "${name}"` };
10637
+ let owner;
10638
+ try {
10639
+ owner = loadAgentFile(path).owner;
10640
+ }
10641
+ catch {
10642
+ // A catalog entry may be invalid for launch solely because an older release admitted a name
10643
+ // above today's identity limit. Read only its frontmatter owner without asking the launch
10644
+ // parser to accept the identity. This does not make the file spawnable or readable.
10645
+ try {
10646
+ const source = readFileSync(path, "utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
10647
+ const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(source);
10648
+ if (!match)
10649
+ throw new Error("missing frontmatter");
10650
+ const frontmatter = parseYaml(match[1]);
10651
+ if (!frontmatter || typeof frontmatter !== "object" || Array.isArray(frontmatter))
10652
+ throw new Error("frontmatter is not a mapping");
10653
+ const fields = frontmatter;
10654
+ if (String(fields.name ?? "") !== name)
10655
+ throw new Error("frontmatter name differs from the catalog entry");
10656
+ if (fields.owner !== undefined && typeof fields.owner !== "string")
10657
+ throw new Error("owner is not a string");
10658
+ owner = fields.owner;
10659
+ }
10660
+ catch {
10661
+ return { ok: false, code: "not-found", error: `no persona "${name}"` };
10662
+ }
10663
+ }
10664
+ if (!admin && owner !== caller) {
10665
+ const heldBy = owner ? `owned by ${owner}` : "operator-owned (legacy file - no agent owner)";
10666
+ return { ok: false, code: "permission-denied", error: `not authorized to remove ${name}: ${heldBy}; only its owner or an operator can` };
10667
+ }
10668
+ try {
10669
+ rmSync(path);
10670
+ }
10671
+ catch (e) {
10672
+ if (!existsSync(path))
10673
+ return { ok: false, code: "not-found", error: `no persona "${name}"` };
10674
+ return { ok: false, error: rejectionText(e) };
10675
+ }
10676
+ return { ok: true, data: { name } };
10677
+ }
10597
10678
  /** The owner a fresh wire-defined persona records. A user-auth caller's principal is stable, and
10598
10679
  * so is one of this manager's own seats for its life. Any other static caller, such as the CLI's
10599
10680
  * operator instrument minted for one call, would leave an owner no later call matches, so it