@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/README.md +1 -1
- package/dist/attach-client.d.ts.map +1 -1
- package/dist/attach-client.js +21 -0
- package/dist/attach-client.js.map +1 -1
- package/dist/commands.d.ts +24 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +154 -47
- package/dist/commands.js.map +1 -1
- package/dist/launch.d.ts +26 -0
- package/dist/launch.d.ts.map +1 -0
- package/dist/launch.js +151 -0
- package/dist/launch.js.map +1 -0
- package/dist/manager.d.ts +26 -5
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +219 -83
- package/dist/manager.js.map +1 -1
- package/dist/roster.d.ts +3 -2
- package/dist/roster.d.ts.map +1 -1
- package/dist/roster.js +3 -2
- package/dist/roster.js.map +1 -1
- package/dist/runtime/index.d.ts +8 -6
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +7 -11
- package/dist/runtime/index.js.map +1 -1
- package/package.json +5 -3
- package/dist/runtime/tmux.d.ts +0 -15
- package/dist/runtime/tmux.d.ts.map +0 -1
- package/dist/runtime/tmux.js +0 -96
- package/dist/runtime/tmux.js.map +0 -1
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":"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"}
|
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
|
|
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
|
|
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
|
|
91
|
-
*
|
|
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.
|
package/dist/manager.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../src/manager.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../src/manager.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAuB,YAAY,EAA+B,eAAe,EAAa,MAAM,gBAAgB,CAAC;AACjI,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,oBAAoB,CAAC;AAqC5B,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oGAAoG;IACpG,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;;0GAEsG;IACtG,IAAI,EAAE,MAAM,CAAC;IACb,kFAAkF;IAClF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;gEAC4D;IAC5D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4FAA4F;IAC5F,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;wCACoC;IACpC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;sDAGkD;IAClD,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC5B;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;IA0DpB;;;;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;;8DAE0D;IACpD,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;IAcf;;;;;;;wCAOoC;YACtB,QAAQ;IA0BtB;;;;;+DAK2D;IACrD,UAAU,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAmK/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;CAgBb"}
|
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,
|
|
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
|
|
232
|
-
*
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
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
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
if (existsSync(f))
|
|
300
|
-
configPath = f;
|
|
422
|
+
catch (e) {
|
|
423
|
+
return { ok: false, error: e.message };
|
|
301
424
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
//
|
|
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
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
let
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
344
|
-
creds: credsPath,
|
|
345
|
-
servers: this.servers,
|
|
346
|
-
configPath,
|
|
347
|
-
transcript: opts.transcript,
|
|
348
|
-
mcpServers,
|
|
456
|
+
capabilities,
|
|
349
457
|
});
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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,
|