@cotal-ai/cli 0.50.1 → 0.51.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 (88) hide show
  1. package/dist/command.d.ts.map +1 -1
  2. package/dist/command.js +24 -1
  3. package/dist/command.js.map +1 -1
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +18 -1
  6. package/dist/commands/agents.js.map +1 -1
  7. package/dist/commands/down.d.ts.map +1 -1
  8. package/dist/commands/down.js +114 -5
  9. package/dist/commands/down.js.map +1 -1
  10. package/dist/commands/endpoints.d.ts.map +1 -1
  11. package/dist/commands/endpoints.js +2 -1
  12. package/dist/commands/endpoints.js.map +1 -1
  13. package/dist/commands/join.d.ts.map +1 -1
  14. package/dist/commands/join.js +25 -0
  15. package/dist/commands/join.js.map +1 -1
  16. package/dist/commands/meshes-add.d.ts +2 -54
  17. package/dist/commands/meshes-add.d.ts.map +1 -1
  18. package/dist/commands/meshes-add.js +20 -105
  19. package/dist/commands/meshes-add.js.map +1 -1
  20. package/dist/commands/meshes.d.ts.map +1 -1
  21. package/dist/commands/meshes.js +5 -2
  22. package/dist/commands/meshes.js.map +1 -1
  23. package/dist/commands/service.d.ts +17 -0
  24. package/dist/commands/service.d.ts.map +1 -0
  25. package/dist/commands/service.js +541 -0
  26. package/dist/commands/service.js.map +1 -0
  27. package/dist/commands/spawn.d.ts +44 -2
  28. package/dist/commands/spawn.d.ts.map +1 -1
  29. package/dist/commands/spawn.js +292 -45
  30. package/dist/commands/spawn.js.map +1 -1
  31. package/dist/commands/status.d.ts.map +1 -1
  32. package/dist/commands/status.js +11 -4
  33. package/dist/commands/status.js.map +1 -1
  34. package/dist/commands/sync.d.ts +50 -0
  35. package/dist/commands/sync.d.ts.map +1 -0
  36. package/dist/commands/sync.js +284 -0
  37. package/dist/commands/sync.js.map +1 -0
  38. package/dist/commands/up.d.ts.map +1 -1
  39. package/dist/commands/up.js +71 -13
  40. package/dist/commands/up.js.map +1 -1
  41. package/dist/commands/update.d.ts.map +1 -1
  42. package/dist/commands/update.js +26 -5
  43. package/dist/commands/update.js.map +1 -1
  44. package/dist/commands/use.js +1 -1
  45. package/dist/commands/use.js.map +1 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +27 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/lib/manifest/apply.d.ts.map +1 -1
  50. package/dist/lib/manifest/apply.js +2 -0
  51. package/dist/lib/manifest/apply.js.map +1 -1
  52. package/dist/lib/manifest/model.d.ts +2 -0
  53. package/dist/lib/manifest/model.d.ts.map +1 -1
  54. package/dist/lib/manifest/prepare.d.ts +2 -0
  55. package/dist/lib/manifest/prepare.d.ts.map +1 -1
  56. package/dist/lib/manifest/prepare.js +1 -0
  57. package/dist/lib/manifest/prepare.js.map +1 -1
  58. package/dist/lib/manifest/resolve.js +1 -0
  59. package/dist/lib/manifest/resolve.js.map +1 -1
  60. package/dist/lib/manifest/schema.d.ts +2 -0
  61. package/dist/lib/manifest/schema.d.ts.map +1 -1
  62. package/dist/lib/manifest/schema.js +2 -0
  63. package/dist/lib/manifest/schema.js.map +1 -1
  64. package/dist/lib/restore.d.ts +3 -0
  65. package/dist/lib/restore.d.ts.map +1 -1
  66. package/dist/lib/restore.js +17 -0
  67. package/dist/lib/restore.js.map +1 -1
  68. package/dist/lib/seat-admission.d.ts +51 -0
  69. package/dist/lib/seat-admission.d.ts.map +1 -0
  70. package/dist/lib/seat-admission.js +81 -0
  71. package/dist/lib/seat-admission.js.map +1 -0
  72. package/dist/lib/seat-capture.d.ts +47 -0
  73. package/dist/lib/seat-capture.d.ts.map +1 -0
  74. package/dist/lib/seat-capture.js +195 -0
  75. package/dist/lib/seat-capture.js.map +1 -0
  76. package/dist/lib/seat-restore.d.ts +39 -0
  77. package/dist/lib/seat-restore.d.ts.map +1 -0
  78. package/dist/lib/seat-restore.js +249 -0
  79. package/dist/lib/seat-restore.js.map +1 -0
  80. package/dist/lib/seat-resume.d.ts +24 -0
  81. package/dist/lib/seat-resume.d.ts.map +1 -0
  82. package/dist/lib/seat-resume.js +127 -0
  83. package/dist/lib/seat-resume.js.map +1 -0
  84. package/package.json +5 -5
  85. package/dist/lib/join-target.d.ts +0 -173
  86. package/dist/lib/join-target.d.ts.map +0 -1
  87. package/dist/lib/join-target.js +0 -335
  88. package/dist/lib/join-target.js.map +0 -1
@@ -1,4 +1,28 @@
1
1
  import { type CompletionResult, type ExtensionRef, type ParsedArgs } from "@cotal-ai/core";
2
+ import { type UserBundle } from "./meshes-add.js";
3
+ export declare function enrollmentInput(env?: NodeJS.ProcessEnv): string | undefined;
4
+ export declare function scrubEnrollmentEnv(env: Record<string, string | undefined> | undefined): void;
5
+ export interface EnrollmentBundle extends RemoteAgentMaterial {
6
+ space: string;
7
+ brokerAccess: {
8
+ kind: string;
9
+ [key: string]: unknown;
10
+ };
11
+ authServiceUrl: string;
12
+ idp: {
13
+ url: string;
14
+ issuer: string;
15
+ audience: string;
16
+ };
17
+ server?: string;
18
+ tlsRequired?: boolean;
19
+ userAuth?: unknown;
20
+ policy?: unknown;
21
+ }
22
+ export declare function checkEnrollmentBundle(raw: unknown, actor: string): {
23
+ bundle: EnrollmentBundle;
24
+ stock?: UserBundle;
25
+ };
2
26
  /** Completion for `cotal spawn` — `--space <TAB>` lists the running meshes, and the first positional
3
27
  * is a persona from the mesh this spawn would target. Resolved OFFLINE (registry + `current`, no
4
28
  * probe — a <TAB> must stay cheap and never open the network), so it lists the *target* mesh's
@@ -79,11 +103,11 @@ export declare const spawnFlags: readonly [{
79
103
  }, {
80
104
  readonly name: "events";
81
105
  readonly type: "boolean";
82
- readonly description: "publish the session's AG-UI event plane to its own event channel";
106
+ readonly description: "force the session's AG-UI event plane on (already the default where supported)";
83
107
  }, {
84
108
  readonly name: "no-events";
85
109
  readonly type: "boolean";
86
- readonly description: "explicit default: no event plane";
110
+ readonly description: "opt out of the session's AG-UI event plane";
87
111
  }, {
88
112
  readonly name: "share-tools";
89
113
  readonly type: "string";
@@ -156,4 +180,22 @@ export declare const spawnFlags: readonly [{
156
180
  * the hook stays root-free exactly as it was before #869. */
157
181
  export declare function spawnRequiredExtensions(_args: ParsedArgs): readonly ExtensionRef[];
158
182
  export declare function spawn(args: ParsedArgs): Promise<void>;
183
+ /** What a remote mesh's agent-provisioning endpoint returns (U6 §2). The client validates every
184
+ * field it uses: this arrives over the network from a deployment, so a missing or wrong-typed
185
+ * piece must be a named refusal here, never an `undefined` written into a 0600 file. */
186
+ interface RemoteAgentMaterial {
187
+ actor: string;
188
+ owner: string;
189
+ lifecycleUid: string;
190
+ actorToken: string;
191
+ sentinelCreds: string;
192
+ subscribe?: string[];
193
+ allowSubscribe?: string[];
194
+ allowPublish?: string[];
195
+ }
196
+ /** Run the one-shot bearer proof with an explicit credential-scrubbed environment. The enrollment
197
+ * path removes the variables from the parent before any child, and this boundary independently
198
+ * refuses to inherit them if another caller reaches it with ambient enrollment input. */
199
+ export declare function runBearerPreflight(bearerCmd: string[], env?: NodeJS.ProcessEnv): Promise<void>;
200
+ export {};
159
201
  //# sourceMappingURL=spawn.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../src/commands/spawn.ts"],"names":[],"mappings":"AAGA,OAAO,EAqBL,KAAK,gBAAgB,EAErB,KAAK,YAAY,EAKjB,KAAK,UAAU,EAEhB,MAAM,gBAAgB,CAAC;AAmCxB;;;+BAG+B;AAC/B,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,gBAAgB,CAmC9D;AA4BD,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,WAAW,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAE5I;AAuED;;;6EAG6E;AAC7E,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYiB,CAAC;AAEzC;;;;;;;;;;;;;;8DAc8D;AAC9D,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,YAAY,EAAE,CAElF;AAgED,wBAAsB,KAAK,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA0Z3D"}
1
+ {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../src/commands/spawn.ts"],"names":[],"mappings":"AAGA,OAAO,EAqBL,KAAK,gBAAgB,EAErB,KAAK,YAAY,EAKjB,KAAK,UAAU,EAEhB,MAAM,gBAAgB,CAAC;AAoCxB,OAAO,EAUL,KAAK,UAAU,EAChB,MAAM,iBAAiB,CAAC;AAazB,wBAAgB,eAAe,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,GAAG,SAAS,CAmBxF;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,SAAS,GAAG,IAAI,CAM5F;AAED,MAAM,WAAW,gBAAiB,SAAQ,mBAAmB;IAC3D,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IACvD,cAAc,EAAE,MAAM,CAAC;IACvB,GAAG,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACvD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,gBAAgB,CAAC;IAAC,KAAK,CAAC,EAAE,UAAU,CAAA;CAAE,CA0CnH;AAeD;;;+BAG+B;AAC/B,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,gBAAgB,CAmC9D;AA4BD,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,WAAW,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAE5I;AAuED;;;6EAG6E;AAC7E,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYiB,CAAC;AAEzC;;;;;;;;;;;;;;8DAc8D;AAC9D,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,YAAY,EAAE,CAElF;AA4ED,wBAAsB,KAAK,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA4gB3D;AAED;;yFAEyF;AACzF,UAAU,mBAAmB;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAoRD;;yFAEyF;AACzF,wBAAsB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAWjH"}
@@ -1,8 +1,8 @@
1
1
  import { spawn as spawnProcess, execFile } from "node:child_process";
2
- import { rmSync } from "node:fs";
2
+ import { readFileSync, rmSync, statSync } from "node:fs";
3
3
  import { resolve as resolvePath } from "node:path";
4
4
  import { agentFilePath, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, isReachable, loadAgentFile, loadCotalConfig, mintCreds, newIdentity, DEV_OWNER, parseShareSelection, principalKey, mintLifecycleUid, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, CotalEndpoint, } from "@cotal-ai/core";
5
- import { agentActorTokenKey, agentCredsKey, agentSecretFilePaths, agentSentinelCredsKey, authDir, credsFlag, defaultAgentOverride, defaultAgentType, defaultPersonaOverride, defaultPersonaRef, launchFlags, loadMeshes, getSpaceAuth, materializeSecretToFile, mergeLaunchOptions, parseLaunchOptions, provenance, resolveMeshTarget, serverFlag, spaceAccountPath, spaceFlag, userAuthStateDir, workspaceSecretStore, } from "@cotal-ai/workspace";
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";
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,112 @@ 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 { checkDialPolicy, checkEnforcement, checkServer, checkUserBundle, persistRemoteUserEntry, probeEnforcement, tlsIntent, userExchangeIssuer, verifyUserExchange, } from "./meshes-add.js";
14
+ const ENROLLMENT_URL_ENV = "COTAL_ENROLLMENT_URL";
15
+ const ENROLLMENT_FILE_ENV = "COTAL_ENROLLMENT_FILE";
16
+ /** Remove the one line terminator a text file ends with, and nothing else: the file holds the URL
17
+ * and at most that terminator. Any other whitespace belongs to the credential the owner minted and
18
+ * must reach the URL grammar, which refuses it rather than silently redeeming a repaired URL. The
19
+ * environment value is taken byte for byte; nothing is stripped from it. */
20
+ function stripTrailingNewline(value) {
21
+ return value.replace(/\r?\n$/, "");
22
+ }
23
+ export function enrollmentInput(env = process.env) {
24
+ const rawUrl = env[ENROLLMENT_URL_ENV];
25
+ const file = env[ENROLLMENT_FILE_ENV]?.trim();
26
+ if (rawUrl && file)
27
+ throw new Error(`both ${ENROLLMENT_URL_ENV} and ${ENROLLMENT_FILE_ENV} are set - pass one enrollment source, not both`);
28
+ if (rawUrl)
29
+ return rawUrl;
30
+ if (!file)
31
+ return undefined;
32
+ let st;
33
+ try {
34
+ st = statSync(file);
35
+ }
36
+ catch (e) {
37
+ throw new Error(`cannot read the enrollment file (${e instanceof Error ? e.message : String(e)})`);
38
+ }
39
+ if (!st.isFile())
40
+ throw new Error("the enrollment file path is not a regular file");
41
+ if (process.platform !== "win32" && (st.mode & 0o777) !== 0o600)
42
+ throw new Error("the enrollment file must have mode 0600");
43
+ const value = stripTrailingNewline(readFileSync(file, "utf8"));
44
+ if (!value)
45
+ throw new Error("the enrollment file is empty");
46
+ return value;
47
+ }
48
+ export function scrubEnrollmentEnv(env) {
49
+ if (!env)
50
+ return;
51
+ for (const key of Object.keys(env)) {
52
+ const normalized = key.toUpperCase();
53
+ if (normalized === ENROLLMENT_URL_ENV || normalized === ENROLLMENT_FILE_ENV)
54
+ delete env[key];
55
+ }
56
+ }
57
+ export function checkEnrollmentBundle(raw, actor) {
58
+ const material = checkRemoteAgentMaterial(raw, actor);
59
+ if (!material.ok)
60
+ throw new Error(material.message.replaceAll("agent-provisioning endpoint", "enrollment endpoint"));
61
+ const o = raw;
62
+ if (typeof o.space !== "string" || !o.space)
63
+ throw new Error("the enrollment bundle names no space");
64
+ if (o.brokerAccess === null || typeof o.brokerAccess !== "object" || typeof o.brokerAccess.kind !== "string" || !o.brokerAccess.kind)
65
+ throw new Error("the enrollment bundle carries no brokerAccess kind");
66
+ if (typeof o.authServiceUrl !== "string" || !o.authServiceUrl)
67
+ throw new Error("the enrollment bundle carries no authServiceUrl");
68
+ const idp = o.idp;
69
+ if (idp === null || typeof idp !== "object" || typeof idp.url !== "string" || !idp.url ||
70
+ typeof idp.issuer !== "string" || !idp.issuer || typeof idp.audience !== "string" || !idp.audience)
71
+ throw new Error("the enrollment bundle carries no complete idp { url, issuer, audience }");
72
+ let stock;
73
+ const hasStock = [o.server, o.tlsRequired, o.userAuth].some((v) => v !== undefined);
74
+ const rawPolicy = o.policy;
75
+ if (rawPolicy !== undefined && !hasStock)
76
+ throw new Error("the enrollment bundle's policy requires its stock server, tlsRequired, and userAuth fields");
77
+ if (hasStock) {
78
+ if ([o.server, o.tlsRequired, o.userAuth].some((v) => v === undefined))
79
+ throw new Error("the enrollment bundle's stock mesh fields must include server, tlsRequired, and userAuth together");
80
+ const checked = checkUserBundle(JSON.stringify({
81
+ space: o.space,
82
+ server: o.server,
83
+ tlsRequired: o.tlsRequired,
84
+ userAuth: o.userAuth,
85
+ policy: o.policy,
86
+ sentinelCreds: material.material.sentinelCreds,
87
+ }));
88
+ if (!checked.ok)
89
+ throw new Error(checked.message.replace(/^✗\s*/, ""));
90
+ stock = checked.value;
91
+ if (stock.userAuth.idp.url !== idp.url || stock.userAuth.idp.issuer !== idp.issuer || stock.userAuth.idp.audience !== idp.audience)
92
+ throw new Error("the enrollment bundle's stock userAuth IdP pins do not match its top-level idp pins");
93
+ if (stock.userAuth.endpoints?.url !== o.authServiceUrl)
94
+ throw new Error("the enrollment bundle's stock userAuth exchange URL does not match authServiceUrl");
95
+ const access = o.brokerAccess;
96
+ if (access.kind === "direct" && (typeof access.url !== "string" || !access.url))
97
+ throw new Error("the enrollment bundle's direct brokerAccess carries no url");
98
+ if (access.kind === "direct" && access.url !== stock.server)
99
+ throw new Error("the enrollment bundle's direct brokerAccess url does not match its stock server");
100
+ }
101
+ return { bundle: { ...o, ...material.material, idp }, ...(stock ? { stock } : {}) };
102
+ }
103
+ async function registerEnrollmentMesh(stock, root) {
104
+ const serverCheck = checkServer(stock.server);
105
+ if (!serverCheck.ok)
106
+ throw new Error(serverCheck.message.replace(/^✗\s*/, ""));
107
+ 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));
118
+ }
13
119
  /** Completion for `cotal spawn` — `--space <TAB>` lists the running meshes, and the first positional
14
120
  * is a persona from the mesh this spawn would target. Resolved OFFLINE (registry + `current`, no
15
121
  * probe — a <TAB> must stay cheap and never open the network), so it lists the *target* mesh's
@@ -195,6 +301,19 @@ async function spawnDetached(values, positionals, events, launchOptions) {
195
301
  const managerConfigRef = values.config ?? (!positionals[0] && defaultPersona ? ref : undefined);
196
302
  const on = onInstanceOrExit(values.on, "cotal spawn <persona> --detach");
197
303
  const t = await resolveControlTarget({ space: values.space, server: values.server, creds: values.creds }, "control-caller-privileged", on);
304
+ let policy;
305
+ try {
306
+ policy = await refreshRegistrationPolicy(t);
307
+ }
308
+ catch (e) {
309
+ console.error(c.red(`✗ ${e.message}`));
310
+ process.exit(1);
311
+ }
312
+ const eventsRequired = policy?.events === "required";
313
+ if (eventsRequired && events === false) {
314
+ console.error(c.red(`✗ space "${t.space}" requires the event plane by registration policy; --no-events is not allowed`));
315
+ process.exit(1);
316
+ }
198
317
  provenance.read("mesh", `${t.space} (${t.server})`);
199
318
  console.error(c.dim("waiting for it to join the mesh (the manager replies on a real outcome - join, exit, or ~30s) …"));
200
319
  const reply = await askManager(t.space, t.server, "start", {
@@ -218,8 +337,8 @@ async function spawnDetached(values, positionals, events, launchOptions) {
218
337
  subscribe: splitFlag(values.subscribe),
219
338
  allowSubscribe: splitFlag(values["allow-subscribe"]),
220
339
  allowPublish: splitFlag(values["allow-publish"]),
221
- // Tri-state: true (--events), false (--no-events, explicit), absent → manager default.
222
- events,
340
+ // Explicit choice: true (--events/default), false (--no-events).
341
+ events: eventsRequired || events,
223
342
  // #159 B1: the manager replies only on a REAL outcome (presence join / process exit / ~30s
224
343
  // readiness backstop) — the start request must outlive that window, not the 5s op default.
225
344
  // `--on <instance>` pins the spawn to that exact manager instance (P2 item 3 multi-manager).
@@ -232,6 +351,41 @@ async function spawnDetached(values, positionals, events, launchOptions) {
232
351
  export async function spawn(args) {
233
352
  const positionals = args.positionals;
234
353
  const values = args.values;
354
+ let enrollmentUrl;
355
+ try {
356
+ enrollmentUrl = enrollmentInput();
357
+ }
358
+ catch (e) {
359
+ console.error(c.red(`✗ ${e.message}`));
360
+ process.exit(1);
361
+ }
362
+ finally {
363
+ // The URL is the credential. Keep the local value, but remove both input forms before this
364
+ // command can start extension, bearer-preflight, or harness children.
365
+ scrubEnrollmentEnv(process.env);
366
+ }
367
+ if (enrollmentUrl && (values.detach || values.file)) {
368
+ console.error(c.red(`✗ ${ENROLLMENT_URL_ENV}/${ENROLLMENT_FILE_ENV} apply only to a foreground persona spawn`));
369
+ process.exit(1);
370
+ }
371
+ if (enrollmentUrl && !values.space) {
372
+ console.error(c.red(`✗ ${ENROLLMENT_URL_ENV}/${ENROLLMENT_FILE_ENV} require --space <s>`));
373
+ process.exit(1);
374
+ }
375
+ const enrollmentBootstrap = Boolean(enrollmentUrl && values.space && !findMesh(values.space));
376
+ if (enrollmentBootstrap) {
377
+ if (!values.config || (!values.config.includes("/") && !values.config.includes("\\") && !values.config.endsWith(".md"))) {
378
+ console.error(c.red("✗ enrollment bootstrap needs --config <persona-file>; no remote persona catalog exists on this machine yet"));
379
+ process.exit(1);
380
+ }
381
+ try {
382
+ loadAgentFile(resolvePath(values.config));
383
+ }
384
+ catch (e) {
385
+ console.error(c.red(`✗ cannot load the enrollment bootstrap persona: ${e.message}`));
386
+ process.exit(1);
387
+ }
388
+ }
235
389
  // `spawn -f cotal.yaml` is a distinct path: deploy a manifest onto a RUNNING mesh (additive,
236
390
  // ownership-scoped). The broker must already be reachable; bringing up a fresh mesh is `up -f`.
237
391
  // `--on` is read only by the detached imperative launch. A foreground spawn runs the connector in
@@ -278,12 +432,15 @@ export async function spawn(args) {
278
432
  console.error(e.message);
279
433
  process.exit(1);
280
434
  }
281
- // The AG-UI event plane is OFF by default. Tri-state: true (--events), false (--no-events,
282
- // explicit), undefined (absent). Foreground treats absent as off; detached forwards the tri-state
283
- // so absent defers to the manager's default. The flag ARMS the emitter; the manager separately
435
+ // The AG-UI event plane is ON by default. `--events` still forces on; `--no-events` is the only
436
+ // opt-out. The flag ARMS the emitter; the manager separately
284
437
  // grants publish on the channel the connector names. Both are required, which is what stops a
285
438
  // hand-written grant from turning events on by itself.
286
- const events = values.events ? true : values["no-events"] ? false : undefined;
439
+ if (values.events && values["no-events"]) {
440
+ console.error(c.red("✗ --events and --no-events are mutually exclusive"));
441
+ process.exit(1);
442
+ }
443
+ const events = values["no-events"] ? false : true;
287
444
  // `--detach`: the SAME grammar, launched by the manager into a detached PTY. The persona is
288
445
  // resolved manager-side (its workspace root owns `.cotal/agents`); flags ride the control
289
446
  // request and override the file exactly as the foreground path does.
@@ -296,9 +453,49 @@ export async function spawn(args) {
296
453
  console.error(c.red("✗ --creds is only valid with --detach (it authenticates the manager control call)"));
297
454
  process.exit(1);
298
455
  }
456
+ let redeemedEnrollment;
457
+ // An enrollment may bootstrap the stock remote user-mesh record before normal target resolution.
458
+ // Redeem only when the named space is not registered; an existing entry defers redemption until
459
+ // after persona resolution so the response actor can be checked against the requested identity.
460
+ if (enrollmentBootstrap) {
461
+ let provider;
462
+ try {
463
+ provider = resolveAuthProvider();
464
+ if (!provider.postAgentEnrollment)
465
+ throw new Error(`the registered auth provider "${provider.name}" cannot redeem remote agent enrollments`);
466
+ const body = await provider.postAgentEnrollment({ url: enrollmentUrl });
467
+ const actor = typeof body?.actor === "string" ? body.actor : "";
468
+ if (!actor)
469
+ throw new Error("the enrollment endpoint returned no actor - it cannot launch this seat");
470
+ redeemedEnrollment = checkEnrollmentBundle(body, actor);
471
+ if (redeemedEnrollment.bundle.space !== values.space)
472
+ throw new Error(`the enrollment is for space "${redeemedEnrollment.bundle.space}" but --space names "${values.space}"`);
473
+ if (!redeemedEnrollment.stock)
474
+ throw new Error("space is not registered and the enrollment bundle carries no stock { server, tlsRequired, userAuth } mesh material");
475
+ await registerEnrollmentMesh(redeemedEnrollment.stock, resolvePath(values.config, ".."));
476
+ }
477
+ catch (e) {
478
+ console.error(c.red(`✗ ${e.message}`));
479
+ process.exit(1);
480
+ }
481
+ }
299
482
  // Which mesh this spawn joins — creds + personas together, resolved from --server/--space, the
300
483
  // selected `current` mesh, a local project, or the registry's only running mesh.
301
484
  const target = await resolveTargetOrExit({ server: values.server, space: values.space });
485
+ let policy;
486
+ try {
487
+ policy = await refreshRegistrationPolicy(target);
488
+ }
489
+ catch (e) {
490
+ console.error(c.red(`✗ ${e.message}`));
491
+ process.exit(1);
492
+ }
493
+ const eventsRequired = policy?.events === "required";
494
+ if (eventsRequired && values["no-events"]) {
495
+ console.error(c.red(`✗ space "${target.space}" requires the event plane by registration policy; --no-events is not allowed`));
496
+ process.exit(1);
497
+ }
498
+ const launchEvents = eventsRequired || events;
302
499
  const { space, server, auth } = target;
303
500
  const composition = { injected: false, root: target.root };
304
501
  const defaultPersona = defaultPersonaOverride();
@@ -306,7 +503,7 @@ export async function spawn(args) {
306
503
  // (.cotal/agents/<name>.md under the TARGET mesh's root). With none, fall back to its `default`
307
504
  // persona — `cotal spawn` with no args launches `<root>/.cotal/agents/default.md`.
308
505
  const ref = spawnPersonaRef(values.config, positionals);
309
- const path = agentFilePath(target.root, ref);
506
+ const path = redeemedEnrollment && values.config ? resolvePath(values.config) : agentFilePath(target.root, ref);
310
507
  let def;
311
508
  try {
312
509
  def = loadAgentFile(path);
@@ -336,6 +533,10 @@ export async function spawn(args) {
336
533
  // --name / --role override the file (name defaults from the file's frontmatter).
337
534
  const requested = values.name ?? def.name;
338
535
  const role = values.role ?? def.role;
536
+ if (redeemedEnrollment && redeemedEnrollment.bundle.actor !== requested) {
537
+ console.error(c.red(`✗ the enrollment is for actor "${redeemedEnrollment.bundle.actor}" but this spawn names "${requested}"`));
538
+ process.exit(1);
539
+ }
339
540
  // Preflight: fail with one sentence if the mesh is down or won't take our creds, instead of
340
541
  // crashing mid-connect with a raw NATS Authorization Violation.
341
542
  await preflightOrExit(target);
@@ -407,8 +608,10 @@ export async function spawn(args) {
407
608
  // provisioning: a connector that cannot emit must stop the launch while there is still nothing to
408
609
  // roll back. The GRANT cannot be derived yet, because it is keyed on the principal and in user
409
610
  // mode the owner is resolved inside the provisioning call below.
410
- if (events && !connector.eventChannel) {
411
- console.error(c.red(`\u2717 connector "${connector.name}" does not publish an AG-UI event plane, but --events was requested`));
611
+ if (launchEvents && !connector.eventChannel) {
612
+ console.error(c.red(eventsRequired
613
+ ? `✗ space "${space}" requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
614
+ : `✗ connector "${connector.name}" does not publish an AG-UI event plane; pass --no-events to launch it without one`));
412
615
  process.exit(1);
413
616
  }
414
617
  // A REMOTE user mesh (registered with `meshes add --from`) that advertises a provisioning
@@ -419,13 +622,51 @@ export async function spawn(args) {
419
622
  const remoteProvisioningUrl = target.mode === "user" && target.userAuth?.remote
420
623
  ? target.userAuth.endpoints?.agentProvisioningUrl
421
624
  : undefined;
422
- if (target.mode === "user" && target.userAuth?.remote && !remoteProvisioningUrl) {
625
+ if (target.mode === "user" && target.userAuth?.remote && !remoteProvisioningUrl && !enrollmentUrl) {
423
626
  console.error(c.red(`✗ mesh "${target.space}" runs elsewhere and advertises no agent-provisioning endpoint, so agents cannot be provisioned from this machine`));
424
627
  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`));
425
628
  process.exit(1);
426
629
  }
427
- if (remoteProvisioningUrl) {
428
- const remote = await provisionRemoteUserForeground(target, name, remoteProvisioningUrl);
630
+ if (target.mode === "user" && target.userAuth?.remote && enrollmentUrl) {
631
+ if (!redeemedEnrollment) {
632
+ try {
633
+ const provider = resolveAuthProvider();
634
+ if (!provider.postAgentEnrollment)
635
+ throw new Error(`the registered auth provider "${provider.name}" cannot redeem remote agent enrollments`);
636
+ const body = await provider.postAgentEnrollment({ url: enrollmentUrl, idpUrl: target.userAuth.idp.url });
637
+ redeemedEnrollment = checkEnrollmentBundle(body, name);
638
+ }
639
+ catch (e) {
640
+ console.error(c.red(`✗ ${e.message}`));
641
+ process.exit(1);
642
+ }
643
+ }
644
+ const enrolled = redeemedEnrollment.bundle;
645
+ if (enrolled.space !== space) {
646
+ console.error(c.red(`✗ the enrollment is for space "${enrolled.space}" but this spawn targets "${space}"`));
647
+ process.exit(1);
648
+ }
649
+ if (enrolled.idp.url !== target.userAuth.idp.url || enrolled.idp.issuer !== target.userAuth.idp.issuer || enrolled.idp.audience !== target.userAuth.idp.audience) {
650
+ console.error(c.red("✗ the enrollment's IdP pins do not match the registered mesh"));
651
+ process.exit(1);
652
+ }
653
+ if (target.userAuth.endpoints?.url !== enrolled.authServiceUrl) {
654
+ console.error(c.red("✗ the enrollment's authServiceUrl does not match the registered mesh exchange"));
655
+ process.exit(1);
656
+ }
657
+ const remote = await provisionRemoteUserForeground(target, name, { body: enrolled, exchangeUrl: enrolled.authServiceUrl });
658
+ userAuth = remote.userAuth;
659
+ userCleanup = remote.cleanup;
660
+ if (remote.material.subscribe)
661
+ subscribe = remote.material.subscribe;
662
+ if (remote.material.allowSubscribe)
663
+ allowSubscribe = remote.material.allowSubscribe;
664
+ if (remote.material.allowPublish)
665
+ allowPublish = remote.material.allowPublish;
666
+ lifecycleUid = remote.material.lifecycleUid;
667
+ }
668
+ else if (remoteProvisioningUrl) {
669
+ const remote = await provisionRemoteUserForeground(target, name, { provisioningUrl: remoteProvisioningUrl });
429
670
  userAuth = remote.userAuth;
430
671
  userCleanup = remote.cleanup;
431
672
  // The mesh's grant is the authority on what this agent may read and post; the launch forwards
@@ -457,7 +698,7 @@ export async function spawn(args) {
457
698
  capabilities: def.capabilities,
458
699
  lifecycleUid,
459
700
  liveOnly: values["live-only"],
460
- ...(events ? { eventChannel: connector.eventChannel } : {}),
701
+ ...(launchEvents ? { eventChannel: connector.eventChannel } : {}),
461
702
  }));
462
703
  // The launch's post-set is forwarded to the session as COTAL_ALLOW_PUBLISH, so it has to carry
463
704
  // whatever was actually minted. Letting the two diverge is how an agent ends up holding a right
@@ -471,7 +712,7 @@ export async function spawn(args) {
471
712
  // on the display name: a name is not an identity, and the manager derives the same subject from
472
713
  // the same connector function, so a foreground and a detached spawn of one persona land on one
473
714
  // channel rather than two.
474
- if (events)
715
+ if (launchEvents)
475
716
  allowPublish = [...(allowPublish ?? []), connector.eventChannel({ owner: DEV_OWNER, actor: identity.id })];
476
717
  const prov = new CotalEndpoint({
477
718
  space,
@@ -577,7 +818,8 @@ export async function spawn(args) {
577
818
  // Fork an existing session into the mesh. `prompt + resume` is a supported combo (claude accepts
578
819
  // the positional prompt alongside `--resume … --fork-session`); an unsupported connector throws.
579
820
  resume: values.resume,
580
- events,
821
+ events: launchEvents,
822
+ eventsRequired,
581
823
  mcpServers,
582
824
  envAllow,
583
825
  // Where a connector that keeps per-agent local state roots it. The manager passes its own
@@ -585,6 +827,7 @@ export async function spawn(args) {
585
827
  // construction because its write-ahead log had nowhere to live that a later start would look.
586
828
  workspaceRoot: target.root,
587
829
  });
830
+ scrubEnrollmentEnv(spec.env);
588
831
  // What happens next belongs to the CONNECTOR: naming one harness's first-run gate for all of
589
832
  // them sends the operator looking for a prompt that never appears, and reads as a hang.
590
833
  console.error(`spawning ${name}${role ? ` (${role})` : ""} on the mesh${connector.launchHint ? ` - ${connector.launchHint}` : ""}`);
@@ -677,7 +920,7 @@ function checkRemoteAgentMaterial(v, actor) {
677
920
  * teardown is the mesh's business (its lifecycle owns the row and the durables). The spawned
678
921
  * agent's material is shredded on exit; the row is not revoked from here, because this machine
679
922
  * holds no authority to revoke it. */
680
- async function provisionRemoteUserForeground(target, name, provisioningUrl) {
923
+ async function provisionRemoteUserForeground(target, name, source) {
681
924
  const { space } = target;
682
925
  const dir = userAuthStateDir(target.root, space);
683
926
  const store = workspaceSecretStore(target.root);
@@ -686,9 +929,6 @@ async function provisionRemoteUserForeground(target, name, provisioningUrl) {
686
929
  console.error(c.red(`✗ ${msg}`));
687
930
  process.exit(1);
688
931
  };
689
- const idpUrl = target.userAuth?.idp.url;
690
- if (!idpUrl)
691
- return fail(`mesh "${space}" records no IdP to sign in against - re-register it with \`cotal meshes add ${space} --from <url> --mode user\``);
692
932
  let provider;
693
933
  try {
694
934
  provider = resolveAuthProvider();
@@ -696,17 +936,27 @@ async function provisionRemoteUserForeground(target, name, provisioningUrl) {
696
936
  catch (e) {
697
937
  return fail(e.message);
698
938
  }
699
- // The login proof rides the provisioning request, so the POST is the PROVIDER's (this package
700
- // never touches the session cache); its thrown sentences are already operator-exact — the
701
- // no-login gate names the `cotal login --idp …` line, a refusal carries the mesh's own reason.
702
- if (!provider.postAgentProvisioning)
703
- return fail(`the registered auth provider "${provider.name}" cannot provision agents on a remote mesh - run the agent where the mesh runs`);
704
939
  let body;
705
- try {
706
- body = await provider.postAgentProvisioning({ url: provisioningUrl, idpUrl, actor: name });
940
+ let exchangeUrl = target.userAuth?.endpoints?.url;
941
+ if ("body" in source) {
942
+ body = source.body;
943
+ exchangeUrl = source.exchangeUrl;
707
944
  }
708
- catch (e) {
709
- return fail(e.message);
945
+ else {
946
+ const idpUrl = target.userAuth?.idp.url;
947
+ if (!idpUrl)
948
+ return fail(`mesh "${space}" records no IdP to sign in against - re-register it with \`cotal meshes add ${space} --from <url> --mode user\``);
949
+ // The login proof rides the provisioning request, so the POST is the PROVIDER's (this package
950
+ // never touches the session cache); its thrown sentences are already operator-exact — the
951
+ // no-login gate names the `cotal login --idp …` line, a refusal carries the mesh's own reason.
952
+ if (!provider.postAgentProvisioning)
953
+ return fail(`the registered auth provider "${provider.name}" cannot provision agents on a remote mesh - run the agent where the mesh runs`);
954
+ try {
955
+ body = await provider.postAgentProvisioning({ url: source.provisioningUrl, idpUrl, actor: name });
956
+ }
957
+ catch (e) {
958
+ return fail(e.message);
959
+ }
710
960
  }
711
961
  const checked = checkRemoteAgentMaterial(body, name);
712
962
  if (!checked.ok)
@@ -724,7 +974,6 @@ async function provisionRemoteUserForeground(target, name, provisioningUrl) {
724
974
  provenance.wrote(`remote actor material ${material.owner}.${name} (user mode)`, tokenPath);
725
975
  // The bearer preflight — the same one-shot proof the local path runs, pointed at the pinned
726
976
  // exchange instead of a local service. A dead auth chain stops the spawn here.
727
- const exchangeUrl = target.userAuth?.endpoints?.url;
728
977
  if (!exchangeUrl)
729
978
  return fail(`mesh "${space}" records no exchange endpoint - re-register it with \`cotal meshes add ${space} --from <url> --mode user\``);
730
979
  const bearerCmd = [
@@ -739,13 +988,7 @@ async function provisionRemoteUserForeground(target, name, provisioningUrl) {
739
988
  "--token-file", tokenPath,
740
989
  "--health-file", healthPath,
741
990
  ];
742
- await new Promise((res2, rej) => {
743
- execFile(bearerCmd[0], bearerCmd.slice(1), { timeout: 30_000, maxBuffer: 64 * 1024 }, (err, _stdout, stderr) => {
744
- if (err)
745
- return rej(new Error(stderr.trim() || err.message));
746
- res2();
747
- });
748
- });
991
+ await runBearerPreflight(bearerCmd);
749
992
  return {
750
993
  userAuth: { owner: material.owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd },
751
994
  material,
@@ -873,13 +1116,7 @@ async function provisionUserForeground(target, name, ref, opts) {
873
1116
  "--token-file", tokenPath,
874
1117
  "--health-file", healthPath,
875
1118
  ];
876
- await new Promise((res, rej) => {
877
- execFile(bearerCmd[0], bearerCmd.slice(1), { timeout: 30_000, maxBuffer: 64 * 1024 }, (err, _stdout, stderr) => {
878
- if (err)
879
- return rej(new Error(stderr.trim() || err.message));
880
- res();
881
- });
882
- });
1119
+ await runBearerPreflight(bearerCmd);
883
1120
  return {
884
1121
  userAuth: { owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd },
885
1122
  ...(eventGrant ? { eventChannel: eventGrant } : {}),
@@ -917,4 +1154,14 @@ async function provisionUserForeground(target, name, ref, opts) {
917
1154
  return fail(`agent auth preflight failed for "${name}": ${e.message}`);
918
1155
  }
919
1156
  }
1157
+ /** Run the one-shot bearer proof with an explicit credential-scrubbed environment. The enrollment
1158
+ * path removes the variables from the parent before any child, and this boundary independently
1159
+ * refuses to inherit them if another caller reaches it with ambient enrollment input. */
1160
+ export async function runBearerPreflight(bearerCmd, env = process.env) {
1161
+ const childEnv = { ...env };
1162
+ scrubEnrollmentEnv(childEnv);
1163
+ await new Promise((resolve, reject) => {
1164
+ execFile(bearerCmd[0], bearerCmd.slice(1), { timeout: 30_000, maxBuffer: 64 * 1024, env: childEnv }, (err, _stdout, stderr) => err ? reject(new Error(stderr.trim() || err.message)) : resolve());
1165
+ });
1166
+ }
920
1167
  //# sourceMappingURL=spawn.js.map