@cotal-ai/cli 0.61.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/command.d.ts.map +1 -1
  2. package/dist/command.js +4 -0
  3. package/dist/command.js.map +1 -1
  4. package/dist/commands/describe.d.ts.map +1 -1
  5. package/dist/commands/describe.js +19 -11
  6. package/dist/commands/describe.js.map +1 -1
  7. package/dist/commands/down.d.ts +3 -0
  8. package/dist/commands/down.d.ts.map +1 -1
  9. package/dist/commands/down.js +24 -18
  10. package/dist/commands/down.js.map +1 -1
  11. package/dist/commands/meshes.d.ts +4 -0
  12. package/dist/commands/meshes.d.ts.map +1 -1
  13. package/dist/commands/meshes.js +45 -9
  14. package/dist/commands/meshes.js.map +1 -1
  15. package/dist/commands/setup.d.ts +1 -1
  16. package/dist/commands/setup.d.ts.map +1 -1
  17. package/dist/commands/setup.js +9 -2
  18. package/dist/commands/setup.js.map +1 -1
  19. package/dist/commands/spawn.d.ts +16 -1
  20. package/dist/commands/spawn.d.ts.map +1 -1
  21. package/dist/commands/spawn.js +206 -44
  22. package/dist/commands/spawn.js.map +1 -1
  23. package/dist/commands/status.d.ts.map +1 -1
  24. package/dist/commands/status.js +3 -2
  25. package/dist/commands/status.js.map +1 -1
  26. package/dist/commands/up.d.ts.map +1 -1
  27. package/dist/commands/up.js +84 -20
  28. package/dist/commands/up.js.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/lib/control.d.ts +13 -1
  32. package/dist/lib/control.d.ts.map +1 -1
  33. package/dist/lib/control.js +45 -9
  34. package/dist/lib/control.js.map +1 -1
  35. package/dist/lib/delivery-proc.d.ts +10 -1
  36. package/dist/lib/delivery-proc.d.ts.map +1 -1
  37. package/dist/lib/delivery-proc.js +19 -0
  38. package/dist/lib/delivery-proc.js.map +1 -1
  39. package/dist/lib/teardown-spare.d.ts +7 -3
  40. package/dist/lib/teardown-spare.d.ts.map +1 -1
  41. package/dist/lib/teardown-spare.js +22 -10
  42. package/dist/lib/teardown-spare.js.map +1 -1
  43. package/dist/managed-handoff.d.ts +10 -0
  44. package/dist/managed-handoff.d.ts.map +1 -0
  45. package/dist/managed-handoff.js +42 -0
  46. package/dist/managed-handoff.js.map +1 -0
  47. package/dist/seed/reconcile.d.ts.map +1 -1
  48. package/dist/seed/reconcile.js +4 -1
  49. package/dist/seed/reconcile.js.map +1 -1
  50. package/dist/seed/store.d.ts.map +1 -1
  51. package/dist/seed/store.js +5 -7
  52. package/dist/seed/store.js.map +1 -1
  53. package/package.json +4 -4
@@ -1,8 +1,8 @@
1
1
  import { spawn as spawnProcess, execFile } from "node:child_process";
2
2
  import { readFileSync, rmSync, statSync } from "node:fs";
3
3
  import { resolve as resolvePath } from "node:path";
4
- import { agentFilePath, connectorServers, spawnEnvAllow, deprovisionAgent, discardLaunchArtifacts, firstFreeName, isReachable, loadAgentFile, reclaimWithChild, loadCotalConfig, mintCreds, newIdentity, DEV_OWNER, parseShareSelection, principalKey, mintLifecycleUid, spawnNameError, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, CotalEndpoint, } from "@cotal-ai/core";
5
- import { agentActorTokenKey, agentCredsKey, agentSecretFilePaths, agentSentinelCredsKey, authDir, credsFlag, defaultAgentOverride, defaultAgentType, defaultPersonaOverride, defaultPersonaRef, launchFlags, loadMeshes, findMesh, getSpaceAuth, materializeSecretToFile, mergeLaunchOptions, parseLaunchOptions, provenance, resolveMeshTarget, serverFlag, spaceAccountPath, spaceFlag, userAuthStateDir, workspaceSecretStore, refreshRegistrationPolicy, } from "@cotal-ai/workspace";
4
+ import { agentFilePath, connectorServers, spawnEnvAllow, deprovisionAgent, discardLaunchArtifacts, firstFreeName, isReachable, loadAgentFile, reclaimWithChild, loadCotalConfig, modelPolicyRefusal, mintCreds, newIdentity, DEV_OWNER, parseShareSelection, principalKey, mintLifecycleUid, MANAGED_HANDOFF_FILE_ENV, parseManagedLifecycleHandoff, spawnNameError, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, CotalEndpoint, } from "@cotal-ai/core";
5
+ import { agentActorTokenKey, agentCredsKey, agentSecretFilePaths, agentSentinelCredsKey, authDir, credsFlag, defaultAgentOverride, defaultAgentType, defaultPersonaOverride, defaultPersonaRef, launchFlags, loadMeshes, findMesh, getSpaceAuth, materializeSecretToFile, mergeLaunchOptions, parseLaunchOptions, preflightOrThrow, provenance, resolveMeshTarget, resolveTargetOrThrow, serverFlag, spaceAccountPath, spaceFlag, spaceKey, userAuthStateDir, workspaceSecretStore, refreshRegistrationPolicy, resolveSeatControlTarget, } from "@cotal-ai/workspace";
6
6
  import { c } from "../ui.js";
7
7
  import { completedFlagValue, completingFlagValue, hasCompletedFlagValue, positionalsForCompletion } from "../lib/completion.js";
8
8
  import { preflightOrExit, resolveTargetOrExit } from "../lib/connect.js";
@@ -10,6 +10,7 @@ import { askManager, failIfNotOk, onInstanceOrExit, resolveControlTarget, START_
10
10
  import { listDeclaredChannels, listDeclaredRoles, listPersonas } from "../lib/personas.js";
11
11
  import { spawnManifest } from "./spawn-manifest.js";
12
12
  import { extensionNames, materializeExtension } from "../ext-loader.js";
13
+ import { claimManagedHandoff } from "../managed-handoff.js";
13
14
  import { checkDialPolicy, checkEnforcement, checkServer, checkUserBundle, persistRemoteUserEntry, probeEnforcement, tlsIntent, userExchangeIssuer, verifyUserExchange, } from "./meshes-add.js";
14
15
  const ENROLLMENT_URL_ENV = "COTAL_ENROLLMENT_URL";
15
16
  const ENROLLMENT_FILE_ENV = "COTAL_ENROLLMENT_FILE";
@@ -100,21 +101,59 @@ export function checkEnrollmentBundle(raw, actor) {
100
101
  }
101
102
  return { bundle: { ...o, ...material.material, idp }, ...(stock ? { stock } : {}) };
102
103
  }
103
- async function registerEnrollmentMesh(stock, root) {
104
- const serverCheck = checkServer(stock.server);
105
- if (!serverCheck.ok)
106
- throw new Error(serverCheck.message.replace(/^✗\s*/, ""));
104
+ /** What a managed handoff's bootstrap refuses with at each phase that runs a check shared with the
105
+ * enrollment path. Those checks' diagnostics, and the filesystem errors under them, quote the
106
+ * space, the server, the exchange URL, the actor or a path named for one of them, and a handoff
107
+ * refusal never echoes the document, so each sentence names only the field and the phase. */
108
+ const HANDOFF_REFUSALS = {
109
+ space: "the managed handoff's space is malformed",
110
+ bundle: "the managed handoff's mesh fields failed the user-auth bundle check",
111
+ registration: "the managed handoff's space failed the local registration",
112
+ target: "the managed handoff's space failed target resolution",
113
+ server: "the managed handoff's server is not a broker URL this machine may dial",
114
+ exchange: "the managed handoff's exchangeUrl failed the exchange check",
115
+ enforcement: "the managed handoff's server failed the enforcement check",
116
+ policy: "the managed handoff's exchangeUrl failed the policy refresh",
117
+ preflight: "the managed handoff's server failed the broker preflight",
118
+ bearer: "the managed handoff's actorToken failed the agent auth preflight",
119
+ };
120
+ /** Map a parsed handoff onto the redeem consumer's shapes. Pure. */
121
+ export function handoffEnrollmentBundle(h) {
122
+ const userAuth = { provider: h.authProvider, idp: h.idp, endpoints: { url: h.exchangeUrl }, remote: true };
123
+ try {
124
+ const { bundle, stock } = checkEnrollmentBundle({
125
+ space: h.space, actor: h.actor, owner: h.owner, lifecycleUid: h.lifecycleUid, actorToken: h.actorToken, sentinelCreds: h.sentinelCreds,
126
+ subscribe: h.subscribe, allowSubscribe: h.allowSubscribe, allowPublish: h.allowPublish,
127
+ brokerAccess: { kind: "direct", url: h.server }, authServiceUrl: h.exchangeUrl, idp: h.idp,
128
+ server: h.server, tlsRequired: h.tlsRequired, userAuth, ...(h.policy ? { policy: h.policy } : {}),
129
+ }, h.actor);
130
+ return { bundle, stock: stock };
131
+ }
132
+ catch {
133
+ throw new Error(HANDOFF_REFUSALS.bundle);
134
+ }
135
+ }
136
+ /** With `refusals`, a step that fails its check or throws refuses with its phase's sentence instead
137
+ * of its diagnostic. */
138
+ async function registerEnrollmentMesh(stock, root, refusals) {
139
+ const step = async (phase, run) => {
140
+ let check;
141
+ try {
142
+ check = await run();
143
+ }
144
+ catch (e) {
145
+ throw refusals ? new Error(refusals[phase]) : e;
146
+ }
147
+ if (!check.ok)
148
+ throw new Error(refusals?.[phase] ?? check.message.replace(/^✗\s*/, ""));
149
+ return check.value;
150
+ };
151
+ await step("server", () => checkServer(stock.server));
107
152
  const tlsRequired = stock.tlsRequired || tlsIntent(stock.server, false);
108
- const dial = checkDialPolicy(stock.server, { tlsRequired, allowUnencryptedOverlay: false });
109
- if (!dial.ok)
110
- throw new Error(dial.message.replace(/^✗\s*/, ""));
111
- const exchange = await verifyUserExchange(stock.userAuth.endpoints.url, userExchangeIssuer(stock.space));
112
- if (!exchange.ok)
113
- throw new Error(exchange.message.replace(/^✗\s*/, ""));
114
- const enforcement = checkEnforcement("user", await probeEnforcement(stock.server), stock.server, stock.space, root);
115
- if (!enforcement.ok)
116
- throw new Error(enforcement.message.replace(/^✗\s*/, ""));
117
- persistRemoteUserEntry(stock.space, stock.server, root, stock, tlsRequired, Boolean(dial.value.residual));
153
+ const dial = await step("server", () => checkDialPolicy(stock.server, { tlsRequired, allowUnencryptedOverlay: false }));
154
+ await step("exchange", () => verifyUserExchange(stock.userAuth.endpoints.url, userExchangeIssuer(stock.space)));
155
+ await step("enforcement", async () => checkEnforcement("user", await probeEnforcement(stock.server), stock.server, stock.space, root));
156
+ await step("registration", () => ({ ok: true, value: persistRemoteUserEntry(stock.space, stock.server, root, stock, tlsRequired, Boolean(dial.residual)) }));
118
157
  }
119
158
  /** Completion for `cotal spawn` — `--space <TAB>` lists the running meshes, and the first positional
120
159
  * is a persona from the mesh this spawn would target. Resolved OFFLINE (registry + `current`, no
@@ -249,6 +288,8 @@ export const spawnFlags = [
249
288
  { name: "allow-stale", type: "string", value: "<a,b>", description: "with -f: waive named stale agents (apply-only)" },
250
289
  { name: "runtime", type: "string", value: "<name>", description: "with -f: override the manifest's runtime" },
251
290
  { name: "on", type: "string", value: "<instance>", description: "with --detach: target a specific manager instance id (multi-manager space); default = class anycast; `ps`'s instance id, not roster's `local.…` principal id" },
291
+ { name: "expect-owner", type: "string", value: "<u_…>", description: `with ${MANAGED_HANDOFF_FILE_ENV}: the owner the managed handoff must carry` },
292
+ { name: "expect-lifecycle-uid", type: "string", value: "<uid>", description: `with ${MANAGED_HANDOFF_FILE_ENV}: the lifecycle UID the managed handoff must carry` },
252
293
  ];
253
294
  /** Foreground `cotal spawn` resolves its `--agent` connector from the registry (spawn.ts below); on
254
295
  * the published binary nothing static-imports connectors, so the resolved one is materialized as a
@@ -295,7 +336,12 @@ async function spawnDetached(values, positionals, events, launchOptions) {
295
336
  const ref = spawnPersonaRef(values.config, positionals);
296
337
  const managerConfigRef = values.config ?? (!positionals[0] && defaultPersona ? ref : undefined);
297
338
  const on = onInstanceOrExit(values.on, "cotal spawn <persona> --detach");
298
- const t = await resolveControlTarget({ space: values.space, server: values.server, creds: values.creds }, "control-caller-privileged", on);
339
+ // From a managed seat's own shell the launch is the SEAT's, as its `cotal_spawn` tool is: the
340
+ // manager records the seat as the spawner, so the seat can stop the child it asked for (#718).
341
+ // `--on` keeps the pin on that path too.
342
+ const flags = { space: values.space, server: values.server, creds: values.creds };
343
+ const t = (values.creds === undefined ? await resolveSeatControlTarget(flags, on) : undefined)
344
+ ?? await resolveControlTarget(flags, "control-caller-privileged", on);
299
345
  let policy;
300
346
  try {
301
347
  policy = await refreshRegistrationPolicy(t);
@@ -373,6 +419,9 @@ async function spawnDetached(values, positionals, events, launchOptions) {
373
419
  export async function spawn(args) {
374
420
  const positionals = args.positionals;
375
421
  const values = args.values;
422
+ // Claimed first: custody already removed the file and its variable, so the text lives only here
423
+ // and no refusal below can leave it anywhere.
424
+ const handoffText = claimManagedHandoff();
376
425
  let enrollmentUrl;
377
426
  try {
378
427
  enrollmentUrl = enrollmentInput();
@@ -386,6 +435,56 @@ export async function spawn(args) {
386
435
  // command can start extension, bearer-preflight, or harness children.
387
436
  scrubEnrollmentEnv(process.env);
388
437
  }
438
+ let redeemedEnrollment;
439
+ if (handoffText === undefined && (values["expect-owner"] !== undefined || values["expect-lifecycle-uid"] !== undefined)) {
440
+ console.error(c.red(`✗ --expect-owner and --expect-lifecycle-uid apply only with ${MANAGED_HANDOFF_FILE_ENV}`));
441
+ process.exit(1);
442
+ }
443
+ if (handoffText !== undefined) {
444
+ const refusal = enrollmentUrl ? `${MANAGED_HANDOFF_FILE_ENV} cannot be combined with ${ENROLLMENT_URL_ENV}/${ENROLLMENT_FILE_ENV}`
445
+ : values.detach || values.file || values.creds ? `${MANAGED_HANDOFF_FILE_ENV} applies only to a foreground persona spawn without --creds`
446
+ : !values.space || !values.name || !values["expect-owner"] || !values["expect-lifecycle-uid"]
447
+ ? `${MANAGED_HANDOFF_FILE_ENV} requires --space, --name, --expect-owner and --expect-lifecycle-uid`
448
+ : !values.config ? `${MANAGED_HANDOFF_FILE_ENV} requires --config <persona-file>`
449
+ : undefined;
450
+ if (refusal) {
451
+ console.error(c.red(`✗ ${refusal}`));
452
+ process.exit(1);
453
+ }
454
+ try {
455
+ loadAgentFile(resolvePath(values.config));
456
+ }
457
+ catch (e) {
458
+ console.error(c.red(`✗ cannot load the managed handoff persona: ${e.message}`));
459
+ process.exit(1);
460
+ }
461
+ try {
462
+ const handoff = parseManagedLifecycleHandoff(handoffText, {
463
+ space: values.space, owner: values["expect-owner"], actor: values.name, lifecycleUid: values["expect-lifecycle-uid"],
464
+ });
465
+ try {
466
+ spaceKey(handoff.space);
467
+ }
468
+ catch {
469
+ throw new Error(HANDOFF_REFUSALS.space);
470
+ }
471
+ const { bundle, stock } = handoffEnrollmentBundle(handoff);
472
+ redeemedEnrollment = { bundle, stock };
473
+ let registered;
474
+ try {
475
+ registered = findMesh(handoff.space) !== undefined;
476
+ }
477
+ catch {
478
+ throw new Error(HANDOFF_REFUSALS.registration);
479
+ }
480
+ if (!registered)
481
+ await registerEnrollmentMesh(stock, resolvePath(values.config, ".."), HANDOFF_REFUSALS);
482
+ }
483
+ catch (e) {
484
+ console.error(c.red(`✗ ${e.message}`));
485
+ process.exit(1);
486
+ }
487
+ }
389
488
  if (enrollmentUrl && (values.detach || values.file)) {
390
489
  console.error(c.red(`✗ ${ENROLLMENT_URL_ENV}/${ENROLLMENT_FILE_ENV} apply only to a foreground persona spawn`));
391
490
  process.exit(1);
@@ -479,7 +578,6 @@ export async function spawn(args) {
479
578
  console.error(c.red("✗ --creds is only valid with --detach (it authenticates the manager control call)"));
480
579
  process.exit(1);
481
580
  }
482
- let redeemedEnrollment;
483
581
  // An enrollment may bootstrap the stock remote user-mesh record before normal target resolution.
484
582
  // Redeem only when the named space is not registered; an existing entry defers redemption until
485
583
  // after persona resolution so the response actor can be checked against the requested identity.
@@ -506,19 +604,32 @@ export async function spawn(args) {
506
604
  }
507
605
  }
508
606
  // Which mesh this spawn joins — creds + personas together, resolved from --server/--space, the
509
- // selected `current` mesh, a local project, or the registry's only running mesh.
510
- const target = await resolveTargetOrExit({ server: values.server, space: values.space });
607
+ // selected `current` mesh, a local project, or the registry's only running mesh. A handoff's
608
+ // resolution diagnostics quote its space, and only a remote user-auth record can host the
609
+ // lifecycle it carries, so any other record is refused before a later step reads it.
610
+ const target = handoffText === undefined
611
+ ? await resolveTargetOrExit({ server: values.server, space: values.space })
612
+ : await resolveTargetOrThrow({ server: values.server, space: values.space }).catch(() => {
613
+ console.error(c.red(`✗ ${HANDOFF_REFUSALS.target}`));
614
+ process.exit(1);
615
+ });
616
+ if (handoffText !== undefined && !(target.mode === "user" && target.userAuth?.remote)) {
617
+ console.error(c.red("✗ the managed handoff's space is registered here as a local mesh, so it cannot host a managed handoff"));
618
+ process.exit(1);
619
+ }
511
620
  let policy;
512
621
  try {
513
622
  policy = await refreshRegistrationPolicy(target);
514
623
  }
515
624
  catch (e) {
516
- console.error(c.red(`✗ ${e.message}`));
625
+ console.error(c.red(`✗ ${handoffText === undefined ? e.message : HANDOFF_REFUSALS.policy}`));
517
626
  process.exit(1);
518
627
  }
519
628
  const eventsRequired = policy?.events === "required";
629
+ // A handoff's space is a value from the document, so its policy refusals name the field instead.
630
+ const policySpace = handoffText === undefined ? `space "${target.space}"` : "the managed handoff's space";
520
631
  if (eventsRequired && values["no-events"]) {
521
- console.error(c.red(`✗ space "${target.space}" requires the event plane by registration policy; --no-events is not allowed`));
632
+ console.error(c.red(`✗ ${policySpace} requires the event plane by registration policy; --no-events is not allowed`));
522
633
  process.exit(1);
523
634
  }
524
635
  // Foreground is the operator's own in-process launch (no typed door, no epAdminReach) and is
@@ -539,7 +650,11 @@ export async function spawn(args) {
539
650
  }
540
651
  catch (e) {
541
652
  const code = e.code;
542
- if (code === "ENOENT") {
653
+ // The not-found text names the mesh's space and server, which for a handoff are its values.
654
+ if (handoffText !== undefined) {
655
+ console.error(c.red(`✗ cannot load the managed handoff persona: ${e.message}`));
656
+ }
657
+ else if (code === "ENOENT") {
543
658
  // A refusal that names no root is why this bug cost an hour. The old text asserted an absence
544
659
  // ("no default persona yet") and prescribed a remedy (`cotal setup`) without saying WHERE it
545
660
  // had looked — so when setup seeded a cwd-derived root and spawn read the resolved mesh's,
@@ -562,6 +677,13 @@ export async function spawn(args) {
562
677
  // --name / --role override the file (name defaults from the file's frontmatter).
563
678
  const requested = values.name ?? def.name;
564
679
  const role = values.role ?? def.role;
680
+ // Flags win over the file, resolved once: the model policy below judges these values and the
681
+ // connector launches on them. Left to the connector, the model is re-read from the persona at
682
+ // launch, after the check, so an edit in between would launch a model the policy never saw.
683
+ const model = values.model ?? def.model;
684
+ const variant = values.variant ?? def.variant;
685
+ // Opaque connector options: `--opt` flags win per key over the persona's `launchOptions:`.
686
+ const launchOptions = mergeLaunchOptions(def.launchOptions, cliLaunchOptions);
565
687
  if (redeemedEnrollment && redeemedEnrollment.bundle.actor !== requested) {
566
688
  console.error(c.red(`✗ the enrollment is for actor "${redeemedEnrollment.bundle.actor}" but this spawn names "${requested}"`));
567
689
  process.exit(1);
@@ -569,9 +691,34 @@ export async function spawn(args) {
569
691
  // #867: same door as the detached path — the effective identity (flag or file) must be mintable
570
692
  // before any provision work; core's own refusal names the offender and the `_` remedy.
571
693
  refuseUnmintableNameOrExit(requested, target.mode === "user");
694
+ // #581: the same per-role model allowlist the manager enforces on a detached spawn, judged on the
695
+ // effective role, model (flag over file) and launch options before anything is provisioned or launched.
696
+ try {
697
+ const refusal = modelPolicyRefusal(loadCotalConfig(target.root), {
698
+ persona: `persona "${ref}" (${path})`,
699
+ role, model, variant,
700
+ modelFlag: values.model !== undefined,
701
+ variantFlag: values.variant !== undefined,
702
+ launchOptions,
703
+ });
704
+ if (refusal) {
705
+ console.error(c.red(`✗ ${refusal}`));
706
+ process.exit(1);
707
+ }
708
+ }
709
+ catch (e) {
710
+ console.error(c.red(`✗ ${e.message}`));
711
+ process.exit(1);
712
+ }
572
713
  // Preflight: fail with one sentence if the mesh is down or won't take our creds, instead of
573
714
  // crashing mid-connect with a raw NATS Authorization Violation.
574
- await preflightOrExit(target);
715
+ if (handoffText === undefined)
716
+ await preflightOrExit(target);
717
+ else
718
+ await preflightOrThrow(target).catch(() => {
719
+ console.error(c.red(`✗ ${HANDOFF_REFUSALS.preflight}`));
720
+ process.exit(1);
721
+ });
575
722
  // A second `cotal spawn` of the same agent would otherwise join under a duplicate mesh identity:
576
723
  // auto-number the name past anyone already present (best-effort — this path bypasses the manager's
577
724
  // race-free reservation; see uniqueMeshName). Everything below (creds path, launch) uses `name`.
@@ -610,7 +757,6 @@ export async function spawn(args) {
610
757
  console.error(c.red(`✗ ${e.message}`));
611
758
  process.exit(1);
612
759
  }
613
- const variant = values.variant ?? def.variant;
614
760
  if (variant && !connector.supportsModelVariant) {
615
761
  console.error(c.red(`✗ ${agentType} connector does not support model variants (variant)`));
616
762
  process.exit(1);
@@ -651,7 +797,7 @@ export async function spawn(args) {
651
797
  // mode the owner is resolved inside the provisioning call below.
652
798
  if (launchEvents && !connector.eventChannel) {
653
799
  console.error(c.red(eventsRequired
654
- ? `✗ space "${space}" requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
800
+ ? `✗ ${policySpace} requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
655
801
  : `✗ connector "${connector.name}" does not publish an AG-UI event plane; pass --no-events to launch it without one`));
656
802
  process.exit(1);
657
803
  }
@@ -663,12 +809,12 @@ export async function spawn(args) {
663
809
  const remoteProvisioningUrl = target.mode === "user" && target.userAuth?.remote
664
810
  ? target.userAuth.endpoints?.agentProvisioningUrl
665
811
  : undefined;
666
- if (target.mode === "user" && target.userAuth?.remote && !remoteProvisioningUrl && !enrollmentUrl) {
812
+ if (target.mode === "user" && target.userAuth?.remote && !remoteProvisioningUrl && !enrollmentUrl && !redeemedEnrollment) {
667
813
  console.error(c.red(`✗ mesh "${target.space}" runs elsewhere and advertises no agent-provisioning endpoint, so agents cannot be provisioned from this machine`));
668
814
  console.error(c.dim(` a user-mode agent's credentials are granted where the mesh's signer lives; ask the mesh operator to advertise one (\`cotal up --agent-provisioning-url …\`), or run the agent there`));
669
815
  process.exit(1);
670
816
  }
671
- if (target.mode === "user" && target.userAuth?.remote && enrollmentUrl) {
817
+ if (target.mode === "user" && target.userAuth?.remote && (enrollmentUrl || redeemedEnrollment)) {
672
818
  if (!redeemedEnrollment) {
673
819
  try {
674
820
  const provider = resolveAuthProvider();
@@ -695,7 +841,7 @@ export async function spawn(args) {
695
841
  console.error(c.red("✗ the enrollment's authServiceUrl does not match the registered mesh exchange"));
696
842
  process.exit(1);
697
843
  }
698
- const remote = await provisionRemoteUserForeground(target, name, { body: enrolled, exchangeUrl: enrolled.authServiceUrl });
844
+ const remote = await provisionRemoteUserForeground(target, name, { body: enrolled, exchangeUrl: enrolled.authServiceUrl }, handoffText === undefined ? undefined : HANDOFF_REFUSALS);
699
845
  userAuth = remote.userAuth;
700
846
  userCleanup = remote.cleanup;
701
847
  remoteUserAuth = true;
@@ -850,11 +996,9 @@ export async function spawn(args) {
850
996
  lifecycleUid,
851
997
  servers: server,
852
998
  configPath: path,
853
- // Model override (wins over the agent file's `model:`) — launch-grammar parity with --detach.
854
- model: values.model,
999
+ model,
855
1000
  variant,
856
- // Opaque connector options: `--opt` flags win per key over the persona's `launchOptions:`.
857
- launchOptions: mergeLaunchOptions(def.launchOptions, cliLaunchOptions),
1001
+ launchOptions,
858
1002
  subscribe,
859
1003
  allowSubscribe,
860
1004
  allowPublish,
@@ -981,16 +1125,26 @@ function checkRemoteAgentMaterial(v, actor) {
981
1125
  * Deliberately NOT reusing the local path's cleanup: nothing here created broker state locally, so
982
1126
  * teardown is the mesh's business (its lifecycle owns the row and the durables). The spawned
983
1127
  * agent's material is shredded on exit; the row is not revoked from here, because this machine
984
- * holds no authority to revoke it. */
985
- async function provisionRemoteUserForeground(target, name, source) {
1128
+ * holds no authority to revoke it. For a managed handoff, `refusals` replaces the sentences of the
1129
+ * local-state, material and bearer steps, which quote the space, the actor or the exchange URL. */
1130
+ async function provisionRemoteUserForeground(target, name, source, refusals) {
986
1131
  const { space } = target;
987
- const dir = userAuthStateDir(target.root, space);
988
1132
  const store = workspaceSecretStore(target.root);
989
1133
  const composition = { injected: false, root: target.root };
990
1134
  const fail = (msg) => {
991
1135
  console.error(c.red(`✗ ${msg}`));
992
1136
  process.exit(1);
993
1137
  };
1138
+ // Resolved first, as by every consumer of the space's local state: each resolution migrates a
1139
+ // pre-hex or root-scoped layout, or refuses an ambiguous one, before any request or material.
1140
+ let paths;
1141
+ try {
1142
+ userAuthStateDir(target.root, space);
1143
+ paths = agentSecretFilePaths(target.root, space, name);
1144
+ }
1145
+ catch (e) {
1146
+ return fail(refusals?.registration ?? e.message);
1147
+ }
994
1148
  let provider;
995
1149
  try {
996
1150
  provider = resolveAuthProvider();
@@ -1022,9 +1176,9 @@ async function provisionRemoteUserForeground(target, name, source) {
1022
1176
  }
1023
1177
  const checked = checkRemoteAgentMaterial(body, name);
1024
1178
  if (!checked.ok)
1025
- return fail(checked.message);
1179
+ return fail(refusals?.bundle ?? checked.message);
1026
1180
  const material = checked.material;
1027
- const { actorToken: tokenPath, sentinelCreds: sentinelPath, health: healthPath } = agentSecretFilePaths(target.root, space, name);
1181
+ const { actorToken: tokenPath, sentinelCreds: sentinelPath, health: healthPath } = paths;
1028
1182
  try {
1029
1183
  // Land both secrets 0600 through the store, exactly as the local path does — the bearer
1030
1184
  // re-exec and the launch handoff read FILES.
@@ -1066,12 +1220,20 @@ async function provisionRemoteUserForeground(target, name, source) {
1066
1220
  };
1067
1221
  }
1068
1222
  catch (e) {
1069
- await store.delete(agentActorTokenKey(space, name, composition)).catch(() => { });
1070
- await store.delete(agentSentinelCredsKey(space, name, composition)).catch(() => { });
1071
- rmSync(tokenPath, { force: true });
1072
- rmSync(sentinelPath, { force: true });
1073
- rmSync(healthPath, { force: true });
1074
- return fail(`agent auth preflight failed for "${name}": ${e.message}`);
1223
+ let cause = e;
1224
+ try {
1225
+ await store.delete(agentActorTokenKey(space, name, composition)).catch(() => { });
1226
+ await store.delete(agentSentinelCredsKey(space, name, composition)).catch(() => { });
1227
+ rmSync(tokenPath, { force: true });
1228
+ rmSync(sentinelPath, { force: true });
1229
+ rmSync(healthPath, { force: true });
1230
+ }
1231
+ catch (shred) {
1232
+ // Material may be left behind, which outranks the failure that started the shred, and an
1233
+ // escaped error would bypass the refusal below.
1234
+ cause = shred;
1235
+ }
1236
+ return fail(refusals?.bearer ?? `agent auth preflight failed for "${name}": ${cause.message}`);
1075
1237
  }
1076
1238
  }
1077
1239
  /** Foreground USER-MODE onboarding — the CLI-side twin of the manager's user spawn provisioning: