@cotal-ai/manager 0.6.0 → 0.8.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/launch.js ADDED
@@ -0,0 +1,151 @@
1
+ import { readFileSync, writeFileSync, lstatSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { z } from "zod";
4
+ import { assertValidChannel, assertValidName, ensureDirNoSymlink, isConcreteChannel, realDirNoSymlink } from "@cotal-ai/core";
5
+ /**
6
+ * Load + materialize a resolved launch spec for `supervise --launch`.
7
+ *
8
+ * The CLI's manifest resolver produces the spec (`cotal-launch/v1`); the manager treats it as
9
+ * **untrusted input** — strict schema, path-safe run id + agent names — then materializes each
10
+ * agent's persona to a **transient, non-authoritative** file under `.cotal/run/<runId>/agents/`
11
+ * (never `.cotal/agents/`, never persona-discoverable). Creds are minted from the spec's `policy`,
12
+ * not from this file, so the file carries no ACL/capability frontmatter.
13
+ */
14
+ // A NATS-/path-safe token: connector type, role (a route token), capability, run id. Keeps a
15
+ // hand-edited/malicious launch spec from feeding rewritten route/capability strings into the manager
16
+ // path (channel policies are re-validated at provision time; these complete the untrusted contract).
17
+ const TOKEN = /^[A-Za-z0-9_-]+$/;
18
+ const RunId = z.string().regex(TOKEN, "runId must be a path-safe token ([A-Za-z0-9_-])");
19
+ const LaunchAgentSchema = z.strictObject({
20
+ name: z.string().min(1),
21
+ agent: z.string().regex(TOKEN, "agent must be a connector token ([A-Za-z0-9_-])"),
22
+ role: z.string().regex(TOKEN, "role must be a route-safe token ([A-Za-z0-9_-])").optional(),
23
+ model: z.string().optional(),
24
+ description: z.string().optional(),
25
+ body: z.string().optional(),
26
+ capabilities: z.array(z.string().regex(TOKEN, "capability must be a safe token ([A-Za-z0-9_-])")).optional(),
27
+ subscribe: z.array(z.string()),
28
+ allowSubscribe: z.array(z.string()),
29
+ allowPublish: z.array(z.string()),
30
+ personaPath: z.string().optional(),
31
+ hash: z.string().regex(/^[A-Za-z0-9]+$/, "hash must be alphanumeric"),
32
+ });
33
+ const LaunchSpecSchema = z.strictObject({
34
+ apiVersion: z.literal("cotal-launch/v1"),
35
+ space: z.string().min(1),
36
+ runId: RunId,
37
+ agents: z.array(LaunchAgentSchema),
38
+ });
39
+ /** Parse + strictly validate a launch spec file. Throws on any deviation (it's untrusted, local
40
+ * though it is) — including an agent name that isn't a safe mesh/file token (no path traversal). */
41
+ export function loadLaunchSpec(path) {
42
+ let json;
43
+ try {
44
+ json = JSON.parse(readFileSync(path, "utf8"));
45
+ }
46
+ catch (e) {
47
+ throw new Error(`launch spec ${path}: ${e.message}`);
48
+ }
49
+ const r = LaunchSpecSchema.safeParse(json);
50
+ if (!r.success)
51
+ throw new Error(`launch spec ${path}: ${r.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`);
52
+ for (const a of r.data.agents) {
53
+ assertValidName(a.name); // names the transient file → must be safe
54
+ validateLaunchPolicy(a); // don't let --launch be a looser second manifest format
55
+ }
56
+ return r.data;
57
+ }
58
+ /** Resolve + load the launch spec for a run id, deriving the path from a known root rather than
59
+ * accepting one. The `launch` control op (`spawn -f` onto a running manager) passes a `runId`, NOT a
60
+ * path — so a (admin-only) caller can never make the manager read an arbitrary host-local JSON: the
61
+ * id must be a safe token, `.cotal/run` must be a real (non-symlink) dir chain, and the spec file
62
+ * itself must not be a symlink. Then the usual untrusted-input validation ({@link loadLaunchSpec})
63
+ * applies, and the spec's self-declared `runId` must match the requested one. */
64
+ export function launchSpecForRun(root, runId) {
65
+ if (!TOKEN.test(runId))
66
+ throw new Error(`unsafe runId ${JSON.stringify(runId)} (allowed: letters, digits, _ -)`);
67
+ const runDir = realDirNoSymlink(root, ".cotal", "run"); // refuse a symlinked .cotal / run parent
68
+ if (!runDir)
69
+ throw new Error(`no launch spec for run ${runId} (.cotal/run is absent)`);
70
+ const path = join(runDir, `${runId}.json`);
71
+ let st;
72
+ try {
73
+ st = lstatSync(path);
74
+ }
75
+ catch {
76
+ throw new Error(`no launch spec for run ${runId} at ${path}`);
77
+ }
78
+ if (st.isSymbolicLink())
79
+ throw new Error(`refusing to read launch spec "${path}": it is a symlink`);
80
+ const spec = loadLaunchSpec(path);
81
+ if (spec.runId !== runId)
82
+ throw new Error(`launch spec runId "${spec.runId}" does not match requested "${runId}"`);
83
+ return spec;
84
+ }
85
+ // Capabilities that actually grant anything in v1 (provisionAgent only acts on `spawn`); an unknown
86
+ // capability is inert downstream, so reject it at the boundary rather than carry a no-op grant.
87
+ const KNOWN_CAPABILITIES = new Set(["spawn"]);
88
+ /** Re-enforce the v1 manifest's policy constraints at the manager boundary so a hand-edited/malicious
89
+ * launch spec can't smuggle in what the CLI schema would reject: concrete channels only (no wildcard
90
+ * scopes — the v1 wildcard deferral), `subscribe ⊆ allowSubscribe`, and known capabilities. Channel
91
+ * policies are re-checked again at provision time, but this fails BEFORE any provisioning side effect. */
92
+ function validateLaunchPolicy(a) {
93
+ const where = `launch agent "${a.name}"`;
94
+ for (const [field, list] of [["subscribe", a.subscribe], ["allowSubscribe", a.allowSubscribe], ["allowPublish", a.allowPublish]])
95
+ for (const ch of list) {
96
+ try {
97
+ assertValidChannel(ch);
98
+ }
99
+ catch (e) {
100
+ throw new Error(`${where}: ${field}: ${e.message}`);
101
+ }
102
+ if (!isConcreteChannel(ch))
103
+ throw new Error(`${where}: ${field} "${ch}" is a wildcard — not allowed in a v1 launch spec`);
104
+ }
105
+ const missing = a.subscribe.filter((c) => !a.allowSubscribe.includes(c));
106
+ if (missing.length)
107
+ throw new Error(`${where}: subscribe [${missing.join(", ")}] not within allowSubscribe`);
108
+ for (const cap of a.capabilities ?? [])
109
+ if (!KNOWN_CAPABILITIES.has(cap))
110
+ throw new Error(`${where}: unknown capability "${cap}" (known: ${[...KNOWN_CAPABILITIES].join(", ")})`);
111
+ }
112
+ /** Materialize one resolved agent's persona to a transient file the connector reads, and return its
113
+ * path. Carries only what a connector needs (identity/role/model/description + body) plus a loud
114
+ * generated-artifact header — never ACL/capability frontmatter (creds come from the spec's policy). */
115
+ export function materializePersona(root, runId, a) {
116
+ // 0700 dirs created component-by-component, refusing a symlinked parent (writes can't escape the
117
+ // run tree); plus a lexical direct-child check on the final path as belt-and-suspenders.
118
+ const dir = ensureDirNoSymlink(root, ".cotal", "run", runId, "agents");
119
+ const path = resolve(dir, `${a.name}.md`);
120
+ if (dirname(path) !== dir)
121
+ throw new Error(`unsafe agent name "${a.name}" — persona path escapes ${dir}`);
122
+ const fm = ["---", `name: ${a.name}`];
123
+ if (a.role)
124
+ fm.push(`role: ${scalar(a.role)}`);
125
+ if (a.model)
126
+ fm.push(`model: ${scalar(a.model)}`);
127
+ if (a.description)
128
+ fm.push(`description: ${scalar(a.description)}`);
129
+ fm.push("---", "");
130
+ const src = a.personaPath ?? "the manifest";
131
+ const header = `<!-- Generated runtime artifact from a cotal mesh manifest (run ${runId}). Do NOT edit — regenerated on each launch and deleted by \`cotal down\`. Edit ${src} instead. This file is not a reusable persona and carries no access authority. -->`;
132
+ const body = a.body ? `${a.body.trim()}\n` : "";
133
+ // `wx`: exclusive create — fails rather than following a symlink pre-planted at the path.
134
+ writeFileSync(path, `${fm.join("\n")}${header}\n\n${body}`, { mode: 0o600, flag: "wx" });
135
+ return path;
136
+ }
137
+ /** Build the manager spawn opts for a launch agent: identity/role/model + the resolved object
138
+ * (which carries the ACL authority) + the materialized configPath. */
139
+ export function launchAgentToStartOpts(a, configPath) {
140
+ return { name: a.name, agent: a.agent, role: a.role, model: a.model, config: configPath, resolved: a };
141
+ }
142
+ /** Quote a frontmatter scalar so the agent-file parser reads it back unchanged (it strips a matching
143
+ * outer quote pair). Plain tokens pass through; anything with structural chars is double-quoted. */
144
+ function scalar(v) {
145
+ if (v === v.trim() && !/^\[/.test(v) && !/[:#"'\r\n]/.test(v))
146
+ return v;
147
+ if (!v.includes('"') && !/[\r\n]/.test(v))
148
+ return `"${v}"`;
149
+ return JSON.stringify(v.replace(/[\r\n]+/g, " "));
150
+ }
151
+ //# sourceMappingURL=launch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"launch.js","sourceRoot":"","sources":["../src/launch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACjE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,gBAAgB,EAA6C,MAAM,gBAAgB,CAAC;AAEzK;;;;;;;;GAQG;AAEH,6FAA6F;AAC7F,qGAAqG;AACrG,qGAAqG;AACrG,MAAM,KAAK,GAAG,kBAAkB,CAAC;AACjC,MAAM,KAAK,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,iDAAiD,CAAC,CAAC;AAEzF,MAAM,iBAAiB,GAAG,CAAC,CAAC,YAAY,CAAC;IACvC,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACvB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,iDAAiD,CAAC;IACjF,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,iDAAiD,CAAC,CAAC,QAAQ,EAAE;IAC3F,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC5B,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAClC,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC3B,YAAY,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,iDAAiD,CAAC,CAAC,CAAC,QAAQ,EAAE;IAC5G,SAAS,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IAC9B,cAAc,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IACnC,YAAY,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IACjC,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAClC,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,gBAAgB,EAAE,2BAA2B,CAAC;CACtE,CAAC,CAAC;AAEH,MAAM,gBAAgB,GAAG,CAAC,CAAC,YAAY,CAAC;IACtC,UAAU,EAAE,CAAC,CAAC,OAAO,CAAC,iBAAiB,CAAC;IACxC,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACxB,KAAK,EAAE,KAAK;IACZ,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,iBAAiB,CAAC;CACnC,CAAC,CAAC;AAEH;qGACqG;AACrG,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,IAAI,IAAa,CAAC;IAClB,IAAI,CAAC;QACH,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CAAC,eAAe,IAAI,KAAM,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;IAClE,CAAC;IACD,MAAM,CAAC,GAAG,gBAAgB,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,CAAC,CAAC,OAAO;QACZ,MAAM,IAAI,KAAK,CAAC,eAAe,IAAI,KAAK,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,QAAQ,KAAK,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACnI,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;QAC9B,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,0CAA0C;QACnE,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAAC,wDAAwD;IACnF,CAAC;IACD,OAAO,CAAC,CAAC,IAAI,CAAC;AAChB,CAAC;AAED;;;;;kFAKkF;AAClF,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,KAAa;IAC1D,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,gBAAgB,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,kCAAkC,CAAC,CAAC;IACjH,MAAM,MAAM,GAAG,gBAAgB,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,yCAAyC;IACjG,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,0BAA0B,KAAK,yBAAyB,CAAC,CAAC;IACvF,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,KAAK,OAAO,CAAC,CAAC;IAC3C,IAAI,EAAE,CAAC;IACP,IAAI,CAAC;QACH,EAAE,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;IACvB,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,0BAA0B,KAAK,OAAO,IAAI,EAAE,CAAC,CAAC;IAChE,CAAC;IACD,IAAI,EAAE,CAAC,cAAc,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,IAAI,oBAAoB,CAAC,CAAC;IACpG,MAAM,IAAI,GAAG,cAAc,CAAC,IAAI,CAAC,CAAC;IAClC,IAAI,IAAI,CAAC,KAAK,KAAK,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,sBAAsB,IAAI,CAAC,KAAK,+BAA+B,KAAK,GAAG,CAAC,CAAC;IACnH,OAAO,IAAI,CAAC;AACd,CAAC;AAED,oGAAoG;AACpG,gGAAgG;AAChG,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;AAE9C;;;2GAG2G;AAC3G,SAAS,oBAAoB,CAAC,CAAkB;IAC9C,MAAM,KAAK,GAAG,iBAAiB,CAAC,CAAC,IAAI,GAAG,CAAC;IACzC,KAAK,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,SAAS,CAAC,EAAE,CAAC,gBAAgB,EAAE,CAAC,CAAC,cAAc,CAAC,EAAE,CAAC,cAAc,EAAE,CAAC,CAAC,YAAY,CAAC,CAAU;QACvI,KAAK,MAAM,EAAE,IAAI,IAAI,EAAE,CAAC;YACtB,IAAI,CAAC;gBACH,kBAAkB,CAAC,EAAE,CAAC,CAAC;YACzB,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,KAAK,KAAK,KAAM,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;YACjE,CAAC;YACD,IAAI,CAAC,iBAAiB,CAAC,EAAE,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,KAAK,KAAK,KAAK,EAAE,mDAAmD,CAAC,CAAC;QAC5H,CAAC;IACH,MAAM,OAAO,GAAG,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;IACzE,IAAI,OAAO,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,gBAAgB,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,6BAA6B,CAAC,CAAC;IAC7G,KAAK,MAAM,GAAG,IAAI,CAAC,CAAC,YAAY,IAAI,EAAE;QACpC,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,yBAAyB,GAAG,aAAa,CAAC,GAAG,kBAAkB,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC9I,CAAC;AAED;;wGAEwG;AACxG,MAAM,UAAU,kBAAkB,CAAC,IAAY,EAAE,KAAa,EAAE,CAAkB;IAChF,iGAAiG;IACjG,yFAAyF;IACzF,MAAM,GAAG,GAAG,kBAAkB,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;IACvE,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC;IAC1C,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,sBAAsB,CAAC,CAAC,IAAI,4BAA4B,GAAG,EAAE,CAAC,CAAC;IAC1G,MAAM,EAAE,GAAG,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IACtC,IAAI,CAAC,CAAC,IAAI;QAAE,EAAE,CAAC,IAAI,CAAC,SAAS,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC/C,IAAI,CAAC,CAAC,KAAK;QAAE,EAAE,CAAC,IAAI,CAAC,UAAU,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAClD,IAAI,CAAC,CAAC,WAAW;QAAE,EAAE,CAAC,IAAI,CAAC,gBAAgB,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;IACpE,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACnB,MAAM,GAAG,GAAG,CAAC,CAAC,WAAW,IAAI,cAAc,CAAC;IAC5C,MAAM,MAAM,GAAG,mEAAmE,KAAK,mFAAmF,GAAG,oFAAoF,CAAC;IAClQ,MAAM,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;IAChD,0FAA0F;IAC1F,aAAa,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,MAAM,OAAO,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IACzF,OAAO,IAAI,CAAC;AACd,CAAC;AAED;uEACuE;AACvE,MAAM,UAAU,sBAAsB,CAAC,CAAkB,EAAE,UAAkB;IAQ3E,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;AACzG,CAAC;AAED;qGACqG;AACrG,SAAS,MAAM,CAAC,CAAS;IACvB,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IACxE,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC,GAAG,CAAC;IAC3D,OAAO,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,CAAC;AACpD,CAAC"}
package/dist/manager.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import type { ControlReply } from "@cotal-ai/core";
1
+ import type { ControlReply, MeshLaunchAgent } from "@cotal-ai/core";
2
2
  import { type RuntimeMode } from "./runtime/index.js";
3
3
  export interface ManagerOptions {
4
4
  space: string;
5
5
  servers?: string;
6
6
  name?: string;
7
- /** Spawn backend. `auto` (default) → pty, or tmux when already inside tmux. */
7
+ /** Spawn backend. `auto` (default) → pty; tmux/cmux are explicit-only (fail loud if unimported). */
8
8
  runtime?: RuntimeMode;
9
9
  workspaceRoot?: string;
10
10
  /** Port for the console + attach HTTP/WS endpoint (loopback). 0 → ephemeral. */
@@ -13,15 +13,26 @@ export interface ManagerOptions {
13
13
  /** A spawn request, typed. The control-plane `start` op parses one of these out of an
14
14
  * untyped request; roster boot constructs them directly. Both funnel into {@link Manager.startAgent}. */
15
15
  export interface StartAgentOpts {
16
+ /** The persona REF to spawn — a filename in `.cotal/agents` (the unique spawn key), discovered as
17
+ * `.cotal/agents/<name>.md`. NOT the mesh identity: the spawned peer presents under the file's
18
+ * own `name:` (auto-numbered on collision). The file must exist (no silent default-ACL fallback). */
16
19
  name: string;
17
20
  /** Connector / agent type — resolved from the registry. Defaults to `"cotal"`. */
18
21
  agent?: string;
19
22
  role?: string;
20
- /** Explicit agent-file name-or-path; otherwise `.cotal/agents/<name>.md` is discovered if present. */
23
+ /** Explicit agent-file path that overrides the `name` ref for *which file to load* (identity still
24
+ * comes from that file's `name:`). The file must exist. */
21
25
  config?: string;
26
+ /** Model override (the `--model` flag). Takes precedence over the agent file's `model:`. */
27
+ model?: string;
22
28
  /** Mirror the session's transcript to `tr-<name>`. Defaults to off; `true` (the
23
29
  * `--transcript` flag) opts in. */
24
30
  transcript?: boolean;
31
+ /** A fully-resolved launch profile (from a mesh manifest via `supervise --launch`). When present,
32
+ * `startAgent` takes identity/role/ACLs/capabilities/model from here — NOT from a persona file —
33
+ * and `config` points at the materialized transient persona the connector reads. The persona file
34
+ * is never the access authority in this path. */
35
+ resolved?: MeshLaunchAgent;
25
36
  }
26
37
  /**
27
38
  * The agent supervisor: a long-lived mesh node that owns agent process lifecycle.
@@ -87,8 +98,9 @@ export declare class Manager {
87
98
  * in-flight (reserved) slots. Lets a colliding spawn auto-number instead of being rejected, so
88
99
  * callers never have to invent a unique name. */
89
100
  private uniqueName;
90
- /** Spawn a teammate by name (loads `.cotal/agents/<name>.md`), as if a peer asked via the
91
- * control plane. Used to pre-spawn the demo's experts at startup so the manager owns them. */
101
+ /** Spawn a teammate by persona ref (`name` loads `.cotal/agents/<name>.md`; the peer presents
102
+ * under that file's own `name:`), as if a peer asked via the control plane. Used to pre-spawn the
103
+ * demo's experts at startup so the manager owns them. */
92
104
  startByName(name: string): Promise<ControlReply>;
93
105
  /** Resolve once `name` shows up on the mesh roster (presence registered), or after `timeoutMs`.
94
106
  * Lets the pre-spawn loop stagger heavy agent cold-starts so they don't all boot at once.
@@ -98,6 +110,15 @@ export declare class Manager {
98
110
  waitForPresence(name: string, timeoutMs?: number): Promise<boolean>;
99
111
  /** Parse an untyped control-plane `start` request into {@link StartAgentOpts}. */
100
112
  private opStart;
113
+ /** Boot one resolved agent from a mesh-manifest launch spec, for `cotal spawn -f` onto a RUNNING
114
+ * manager. The request carries a `{ runId, name }`, NEVER a path: the manager derives + validates
115
+ * `.cotal/run/<runId>.json` itself ({@link launchSpecForRun} — token-safe id, no-follow,
116
+ * `loadLaunchSpec`'s untrusted-input + `validateLaunchPolicy` contract), materializes the named
117
+ * agent's transient persona, and spawns via the same `startAgent({ resolved })` path as
118
+ * `supervise --launch`. The reply is enriched for the ownership ledger: the SPAWNED
119
+ * (collision-numbered) name + nkey id creds are filed under, plus the manifest `requested` name,
120
+ * `runId`, and resolved `hash`. */
121
+ private opLaunch;
101
122
  /** Spawn and supervise one agent. The single spawn path: both the control-plane
102
123
  * `start` op and declarative roster boot call this. Mints scoped creds in auth mode,
103
124
  * resolves the agent file, launches via the connector + runtime, and records the handle.
@@ -1 +1 @@
1
- {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../src/manager.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAuB,YAAY,EAA0C,MAAM,gBAAgB,CAAC;AAChH,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,oBAAoB,CAAC;AAW5B,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;0GAC0G;AAC1G,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,kFAAkF;IAClF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sGAAsG;IACtG,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;wCACoC;IACpC,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAkBD;;;;;GAKG;AACH,qBAAa,OAAO;IAClB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAC7C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAU;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmC;IAC1D;gGAC4F;IAC5F,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqB;IAC9C;gGAC4F;IAC5F,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,EAAE,CAAiB;IAC3B;+FAC2F;IAC3F,OAAO,CAAC,IAAI,CAAC,CAAY;gBAEb,IAAI,EAAE,cAAc;IAehC,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,uDAAuD;IACvD,IAAI,UAAU,IAAI,MAAM,CAEvB;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA+CtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAKb,MAAM;IAkDpB;;;;yEAIqE;IACrE,OAAO,CAAC,cAAc;IAMtB;;;;6DAIyD;IACzD,OAAO,CAAC,UAAU;IAclB;;;;uGAImG;IACnG,OAAO,CAAC,QAAQ;IAMhB;;uGAEmG;IACnG,OAAO,CAAC,cAAc;IAStB;;oGAEgG;IAChG,OAAO,CAAC,WAAW;IAKnB;iGAC6F;IAC7F,OAAO,CAAC,SAAS;IAMjB;;sDAEkD;IAClD,OAAO,CAAC,UAAU;IAIlB;mGAC+F;IACzF,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAItD;;;;0EAIsE;IAChE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC;IASzE,kFAAkF;IAClF,OAAO,CAAC,OAAO;IAaf;;;;;+DAK2D;IACrD,UAAU,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAyG/E;;;;;kGAK8F;IAC9F,OAAO,CAAC,SAAS;IAQjB;0GACsG;IACtG,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,MAAM;IAYd;;qGAEiG;YACnF,OAAO;IAgBrB;;;;;;;;4GAQwG;IACxG,OAAO,CAAC,eAAe;IAqCvB,OAAO,CAAC,QAAQ;IAqBhB,wFAAwF;IACxF,OAAO,CAAC,IAAI;CAab"}
1
+ {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../src/manager.ts"],"names":[],"mappings":"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"}
package/dist/manager.js CHANGED
@@ -1,8 +1,10 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { join, dirname } from "node:path";
3
- import { CotalEndpoint, DEFAULT_SERVER, agentFilePath, authDir, clearSpaceHistory, connectorServers, findCotalRoot, firstFreeName, loadAgentFile, loadCotalConfig, loadSpaceAuth, mintCreds, newIdentity, provisionAgent, registry, saveAgentFile, subjectMatches, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, } from "@cotal-ai/core";
1
+ import { accessSync, constants, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, dirname, delimiter } from "node:path";
3
+ import { CotalEndpoint, DEFAULT_SERVER, agentFilePath, clearSpaceHistory, connectorServers, firstFreeName, loadAgentFile, loadCotalConfig, mintCreds, newIdentity, provisionAgent, registry, saveAgentFile, subjectMatches, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, } from "@cotal-ai/core";
4
+ import { authDir, findCotalRoot, loadSpaceAuth } from "@cotal-ai/workspace";
4
5
  import { createRuntime, } from "./runtime/index.js";
5
6
  import { AttachEndpoint } from "./attach-endpoint.js";
7
+ import { launchSpecForRun, materializePersona, launchAgentToStartOpts } from "./launch.js";
6
8
  /** Concurrency ceiling — the manager refuses to hold more than this many live + in-flight +
7
9
  * cooling slots at once (P4a). Bounds a fork-bomb: spawn is a full agent process per call. */
8
10
  const MAX_AGENTS = 50;
@@ -10,6 +12,33 @@ const MAX_AGENTS = 50;
10
12
  * before living this long leaves a cooling stamp that still counts toward the ceiling until it
11
13
  * expires — so churn (spawn↔despawn or spawn↔fast-exit) can't outrun the concurrency bound. */
12
14
  const MIN_LIFETIME = 10_000;
15
+ /** Is `bin` an executable on PATH? A side-effect-free preflight for a connector's `requires` —
16
+ * scans PATH directly with `accessSync(X_OK)` rather than shelling out to `which`, so it can't
17
+ * hang or run the harness. An absolute/relative path is checked as-is; a bare name is looked up
18
+ * across PATH entries (empty entries skipped). POSIX-only (macOS/Linux); no PATHEXT handling. */
19
+ function binOnPath(bin) {
20
+ if (bin.includes("/")) {
21
+ try {
22
+ accessSync(bin, constants.X_OK);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
30
+ if (!dir)
31
+ continue;
32
+ try {
33
+ accessSync(join(dir, bin), constants.X_OK);
34
+ return true;
35
+ }
36
+ catch {
37
+ // not here — keep scanning
38
+ }
39
+ }
40
+ return false;
41
+ }
13
42
  /**
14
43
  * The agent supervisor: a long-lived mesh node that owns agent process lifecycle.
15
44
  * It serves control requests on the "manager" service and spawns/kills agents
@@ -129,6 +158,15 @@ export class Manager {
129
158
  case "start":
130
159
  // Spawn is a privileged-tier op; reaching it via admin is fine (admin ⊇ privileged powers).
131
160
  return this.opStart(args, caller);
161
+ case "launch":
162
+ // SECURITY: manifest launch is operator-only (admin tier). It is higher-power than `start`
163
+ // — it boots an operator-authored, coordinated policy set from a run spec and underpins the
164
+ // ownership ledger — so a merely spawn-capable agent (which CAN publish to the privileged
165
+ // subject) must not reach it. Gate at the handler like `purge`; the subject alone isn't a
166
+ // boundary because `spawn` grants privileged-subject publish and dispatch is by op here.
167
+ if (!admin)
168
+ return { ok: false, error: "launch is admin-only; not allowed on the privileged subject" };
169
+ return this.opLaunch(args, caller);
132
170
  case "stop": {
133
171
  if (!name)
134
172
  return { ok: false, error: "self-stop not allowed on privileged subject; send it on the self-service subject" };
@@ -228,8 +266,9 @@ export class Manager {
228
266
  uniqueName(base) {
229
267
  return firstFreeName(base, (n) => this.agents.has(n) || this.reserved.has(n));
230
268
  }
231
- /** Spawn a teammate by name (loads `.cotal/agents/<name>.md`), as if a peer asked via the
232
- * control plane. Used to pre-spawn the demo's experts at startup so the manager owns them. */
269
+ /** Spawn a teammate by persona ref (`name` loads `.cotal/agents/<name>.md`; the peer presents
270
+ * under that file's own `name:`), as if a peer asked via the control plane. Used to pre-spawn the
271
+ * demo's experts at startup so the manager owns them. */
233
272
  async startByName(name) {
234
273
  return this.startAgent({ name });
235
274
  }
@@ -254,9 +293,47 @@ export class Manager {
254
293
  agent: args.agent ? String(args.agent) : undefined,
255
294
  role: args.role ? String(args.role) : undefined,
256
295
  config: args.config ? String(args.config) : undefined,
296
+ model: args.model ? String(args.model) : undefined,
257
297
  transcript: typeof args.transcript === "boolean" ? args.transcript : undefined,
258
298
  }, caller);
259
299
  }
300
+ /** Boot one resolved agent from a mesh-manifest launch spec, for `cotal spawn -f` onto a RUNNING
301
+ * manager. The request carries a `{ runId, name }`, NEVER a path: the manager derives + validates
302
+ * `.cotal/run/<runId>.json` itself ({@link launchSpecForRun} — token-safe id, no-follow,
303
+ * `loadLaunchSpec`'s untrusted-input + `validateLaunchPolicy` contract), materializes the named
304
+ * agent's transient persona, and spawns via the same `startAgent({ resolved })` path as
305
+ * `supervise --launch`. The reply is enriched for the ownership ledger: the SPAWNED
306
+ * (collision-numbered) name + nkey id creds are filed under, plus the manifest `requested` name,
307
+ * `runId`, and resolved `hash`. */
308
+ async opLaunch(args, caller) {
309
+ const runId = String(args.runId ?? "").trim();
310
+ const name = String(args.name ?? "").trim();
311
+ if (!runId || !name)
312
+ return { ok: false, error: "launch requires runId + name" };
313
+ let spec;
314
+ try {
315
+ spec = launchSpecForRun(this.workspaceRoot, runId);
316
+ }
317
+ catch (e) {
318
+ return { ok: false, error: e.message };
319
+ }
320
+ const la = spec.agents.find((a) => a.name === name);
321
+ if (!la)
322
+ return { ok: false, error: `no agent "${name}" in launch spec for run ${runId}` };
323
+ let configPath;
324
+ try {
325
+ configPath = materializePersona(this.workspaceRoot, runId, la);
326
+ }
327
+ catch (e) {
328
+ return { ok: false, error: e.message };
329
+ }
330
+ const reply = await this.startAgent(launchAgentToStartOpts(la, configPath), caller);
331
+ if (reply.ok)
332
+ // `data.name` stays the spawned (numbered) identity — what creds are filed under and the ledger
333
+ // keys on; `requested`/`runId`/`hash` give the CLI the manifest name + drift hash for the ledger.
334
+ reply.data = { ...reply.data, requested: la.name, runId, hash: la.hash, newlyStarted: true };
335
+ return reply;
336
+ }
260
337
  /** Spawn and supervise one agent. The single spawn path: both the control-plane
261
338
  * `start` op and declarative roster boot call this. Mints scoped creds in auth mode,
262
339
  * resolves the agent file, launches via the connector + runtime, and records the handle.
@@ -264,96 +341,147 @@ export class Manager {
264
341
  * defaulting to the manager's own id for roster/pre-spawn — recorded for the spawner
265
342
  * ledger (own-children despawn + reap-on-parent-exit). */
266
343
  async startAgent(opts, spawner) {
267
- const base = opts.name.trim();
268
- if (!base)
344
+ // The spawn argument is a persona REF — a filename in `.cotal/agents` (the unique spawn KEY), or
345
+ // a path via `--config`. It is NOT the mesh identity: the identity comes from inside the file
346
+ // (`name:`), so a persona can be filed descriptively (review-critic.md) yet present under a
347
+ // free-form name (socrates) — the same model `cotal spawn` already uses. You always spawn by
348
+ // filename (unique on disk); two files can't collide on the key.
349
+ const ref = opts.name.trim();
350
+ if (!ref)
269
351
  return { ok: false, error: "name required" };
270
- const nameErr = this.nameError(base);
271
- if (nameErr)
272
- return { ok: false, error: nameErr };
352
+ // A bare ref maps to `.cotal/agents/<ref>.md`, so it must be a safe token (no path traversal); a
353
+ // `--config` path is validated by existsSync below instead.
354
+ if (!opts.config) {
355
+ const refErr = this.nameError(ref);
356
+ if (refErr)
357
+ return { ok: false, error: refErr };
358
+ }
273
359
  const agent = opts.agent ?? "cotal";
274
- // Synchronous availability gate (P4a/P4c) the free-name pick and the reserve run in one tick
275
- // BEFORE any await, so two concurrent spawns can't land on the same name (no TOCTOU between the
276
- // pick and the reserve), and the ceiling can't be overshot by fan-out racing the provision await.
360
+ // Capacity check first (cheap, fail-fast). Everything from here to the reserve below is
361
+ // SYNCHRONOUS (existsSync / registry / accessSync / readFileSync no await), so the gate stays
362
+ // atomic: the capacity snapshot and the reserve land in one tick (P4a/P4c), and two concurrent
363
+ // spawns can't overshoot the ceiling or pick the same name.
277
364
  const cooling = this.coolingCount(); // prune expired stamps, then count live cooling slots
278
365
  if (this.agents.size + this.reserved.size + cooling >= MAX_AGENTS)
279
366
  return { ok: false, error: `at capacity (${MAX_AGENTS} agents incl. in-flight + cooling); despawn one or wait` };
280
- // A taken name auto-numbers (reviewer reviewer-2 reviewer-3…) so callers never collide; the
281
- // persona file is still discovered from the requested base name below, so reviewer-2 wears it.
282
- // Deliberate semantics: this is create-new, not ensure-exists — a retried/redelivered identical
283
- // spawn from the same caller yields a fresh numbered agent, not a no-op. Accepted (MAX_AGENTS
284
- // bounds the blast radius). Follow-up: add a short per-(spawner,base,role) idempotency window if
285
- // autonomous orchestration ever produces phantom spawns.
286
- const name = this.uniqueName(base);
287
- this.reserved.add(name);
367
+ // Resolve the persona file (fail loud NO silent default-ACL fallback). A missing persona used
368
+ // to mint DEFAULT creds (read `general` only, default-deny publish, no capabilities), so a
369
+ // typo'd / renamed / spawned-by-display-name agent became live with silently-wrong ACLs — a
370
+ // behavioral/security bug. Fail loud instead, matching `cotal spawn` (loadAgentFile throws).
371
+ let configPath;
372
+ if (opts.config) {
373
+ configPath = agentFilePath(this.workspaceRoot, opts.config);
374
+ if (!existsSync(configPath))
375
+ return { ok: false, error: `agent file not found: ${configPath}` };
376
+ }
377
+ else {
378
+ configPath = agentFilePath(this.workspaceRoot, ref);
379
+ if (!existsSync(configPath))
380
+ return { ok: false, error: `no persona "${ref}" — ${configPath} not found; create it or pass --config (see \`cotal personas list\`)` };
381
+ }
382
+ // Connector + harness preflight before reserving a slot or minting — a missing connector or a
383
+ // missing `claude`/`opencode` binary fails here with a clear name, not obscurely at process
384
+ // spawn. No fallback. All synchronous, so the reserve gate stays atomic.
385
+ let connector;
288
386
  try {
289
- // Resolve an agent file from the manager's own workspace — an explicit
290
- // --config must exist; otherwise discover .cotal/agents/<name>.md if present.
291
- let configPath;
292
- if (opts.config) {
293
- configPath = agentFilePath(this.workspaceRoot, opts.config);
294
- if (!existsSync(configPath))
295
- return { ok: false, error: `agent file not found: ${configPath}` };
387
+ connector = registry.resolve("connector", agent);
388
+ }
389
+ catch (e) {
390
+ return { ok: false, error: e.message };
391
+ }
392
+ const missing = (connector.requires ?? []).filter((bin) => !binOnPath(bin));
393
+ if (missing.length)
394
+ return { ok: false, error: `${agent} harness needs ${missing.join(", ")} on PATH — not found` };
395
+ // Resolve the launch profile: IDENTITY (free-form `name:`) + role + read/post ACL + capabilities
396
+ // + model. Either from a fully-resolved manifest launch object (`opts.resolved`, whose `config`
397
+ // is a materialized transient persona — the file is NOT the access authority), or from the
398
+ // persona file. The number rides the IDENTITY (socrates → socrates-2), not the file ref — a
399
+ // redelivered identical spawn yields a fresh numbered agent (MAX_AGENTS bounds the blast radius).
400
+ let identityName;
401
+ let role;
402
+ let subscribe;
403
+ let allowSubscribe;
404
+ let allowPublish;
405
+ let capabilities;
406
+ let model = opts.model;
407
+ if (opts.resolved) {
408
+ const r = opts.resolved;
409
+ identityName = r.name;
410
+ role = opts.role ?? r.role;
411
+ subscribe = r.subscribe;
412
+ allowSubscribe = r.allowSubscribe?.length ? r.allowSubscribe : r.subscribe;
413
+ allowPublish = r.allowPublish;
414
+ capabilities = r.capabilities;
415
+ model = opts.model ?? r.model;
416
+ }
417
+ else {
418
+ let def;
419
+ try {
420
+ def = loadAgentFile(configPath);
296
421
  }
297
- else {
298
- const f = agentFilePath(this.workspaceRoot, base);
299
- if (existsSync(f))
300
- configPath = f;
422
+ catch (e) {
423
+ return { ok: false, error: e.message };
301
424
  }
302
- // --role overrides the file; the file fills it in for bookkeeping otherwise.
303
- let role = opts.role;
304
- // A stable nkey identity assigned at spawn: the public key is the agent's card.id
305
- // (threaded via COTAL_ID); the seed is retained to mint matching creds later.
425
+ identityName = def.name;
426
+ role = opts.role ?? def.role;
427
+ subscribe = def.subscribe;
428
+ // Defaulted the same way the loader/provisioner do minted into the creds (the broker
429
+ // boundary); runtime durable joins are re-authorized against the committed ACL by the daemon.
430
+ allowSubscribe = def.allowSubscribe ?? def.subscribe ?? ["general"];
431
+ allowPublish = def.allowPublish;
432
+ capabilities = def.capabilities;
433
+ }
434
+ const idErr = this.nameError(identityName);
435
+ if (idErr)
436
+ return { ok: false, error: opts.resolved ? `launch agent: ${idErr}` : `persona ${configPath}: ${idErr}` };
437
+ const name = this.uniqueName(identityName);
438
+ this.reserved.add(name);
439
+ try {
440
+ // A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
441
+ // COTAL_ID); the seed is retained to mint matching creds later.
306
442
  const identity = newIdentity();
307
- // The agent's read ACL, defaulted the same way the loader/provisioner do retained on the
308
- // managed record so the mediated join/leave op can validate channels allowSubscribe.
309
- let allowSubscribe = ["general"];
310
- let handle;
311
- try {
312
- const connector = registry.resolve("connector", agent);
313
- const def = configPath ? loadAgentFile(configPath) : undefined;
314
- if (!role)
315
- role = def?.role;
316
- allowSubscribe = def?.allowSubscribe ?? def?.subscribe ?? ["general"];
317
- // In auth mode, mint the agent's creds from the space signing key and write them where the
318
- // spawned session reads them (COTAL_CREDS path). Open mesh → no creds. Read scope = the
319
- // file's subscribe/allowSubscribe; post scope = its allowPublish (default-deny).
320
- let credsPath;
321
- if (this.auth) {
322
- // Pre-create the agent's bind-only chat (+ DM + role TASK) durables and mint its scoped
323
- // creds — the shared onboarding step (provisionAgent), the manager just supplies its
324
- // own connected endpoint as the privileged provisioner.
325
- const creds = await provisionAgent(this.ep, this.auth, identity, {
326
- subscribe: def?.subscribe,
327
- allowSubscribe,
328
- allowPublish: def?.allowPublish,
329
- role,
330
- capabilities: def?.capabilities,
331
- });
332
- credsPath = join(authDir(this.workspaceRoot), "creds", `${name}.creds`);
333
- mkdirSync(dirname(credsPath), { recursive: true });
334
- writeFileSync(credsPath, creds, { mode: 0o600 });
335
- }
336
- // Personal MCP servers the operator opted to share with manager-spawned agents of this
337
- // type (cotal config; default none → isolated, the memory-safe default this guards).
338
- const mcpServers = connectorServers(loadCotalConfig(this.workspaceRoot), agent);
339
- const spec = connector.buildLaunch({
340
- space: this.space,
341
- name,
443
+ // In auth mode, mint the agent's creds from the space signing key and write them where the
444
+ // spawned session reads them (COTAL_CREDS path). Open mesh no creds. Scope = the resolved
445
+ // subscribe/allowSubscribe (read) + allowPublish (post, default-deny).
446
+ let credsPath;
447
+ if (this.auth) {
448
+ // Pre-create the agent's bind-only chat (+ DM + role TASK) durables and mint its scoped creds
449
+ // the shared onboarding step (provisionAgent); the manager supplies its own connected
450
+ // endpoint as the privileged provisioner.
451
+ const creds = await provisionAgent(this.ep, this.auth, identity, {
452
+ subscribe,
453
+ allowSubscribe,
454
+ allowPublish,
342
455
  role,
343
- id: identity.id,
344
- creds: credsPath,
345
- servers: this.servers,
346
- configPath,
347
- transcript: opts.transcript,
348
- mcpServers,
456
+ capabilities,
349
457
  });
350
- handle = this.runtime.spawn(name, spec, this.workspaceRoot);
351
- }
352
- catch (e) {
353
- // Pre-set failure: the slot was never live, so no cold-start was paid — the reserved
354
- // rollback (finally) is enough, no cooling stamp.
355
- return { ok: false, error: e.message };
458
+ credsPath = join(authDir(this.workspaceRoot), "creds", `${name}.creds`);
459
+ mkdirSync(dirname(credsPath), { recursive: true });
460
+ writeFileSync(credsPath, creds, { mode: 0o600 });
356
461
  }
462
+ // Personal MCP servers the operator opted to share with manager-spawned agents of this type
463
+ // (cotal config; default none → isolated, the memory-safe default this guards).
464
+ const mcpServers = connectorServers(loadCotalConfig(this.workspaceRoot), agent);
465
+ const spec = connector.buildLaunch({
466
+ space: this.space,
467
+ name,
468
+ role,
469
+ id: identity.id,
470
+ creds: credsPath,
471
+ servers: this.servers,
472
+ configPath,
473
+ model,
474
+ // The SAME access set the creds were minted from (above) — forwarded so the session's
475
+ // runtime read/post set matches its credentials. Without this a manifest-spawned agent
476
+ // (materialized persona has no access frontmatter) falls back to `["general"]`, which its
477
+ // scoped creds deny, and it joins nothing.
478
+ subscribe,
479
+ allowSubscribe,
480
+ allowPublish,
481
+ transcript: opts.transcript,
482
+ mcpServers,
483
+ });
484
+ const handle = this.runtime.spawn(name, spec, this.workspaceRoot);
357
485
  const managed = {
358
486
  name,
359
487
  role,
@@ -370,6 +498,11 @@ export class Manager {
370
498
  this.watchExit(managed);
371
499
  return { ok: true, data: { name, role, agent, id: identity.id, mode: handle.kind } };
372
500
  }
501
+ catch (e) {
502
+ // Failure after reserve (provision / launch threw): the slot was never live, so no cold-start
503
+ // was paid — the reserved rollback (finally) is enough, no cooling stamp.
504
+ return { ok: false, error: e.message };
505
+ }
373
506
  finally {
374
507
  this.reserved.delete(name);
375
508
  }
@@ -507,6 +640,9 @@ export class Manager {
507
640
  const roster = new Map(this.ep.getRoster().map((p) => [p.card.name, p]));
508
641
  return [...this.agents.values()].map((a) => ({
509
642
  name: a.name,
643
+ // The spawned agent's nkey — lets an operator tool (e.g. `cotal down -f`) match a ledger entry
644
+ // by name AND id before stopping, so it never stops a same-named foreign agent.
645
+ id: a.id,
510
646
  role: a.role,
511
647
  agent: a.agent,
512
648
  space: this.space,