@brutalsystems/muster 0.7.11 → 0.7.14
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 +280 -9
- package/dist/claude-policy.js +0 -2
- package/dist/config.js +64 -1
- package/dist/enforcement.js +5 -0
- package/dist/guard.js +103 -0
- package/dist/identity/claude.js +14 -1
- package/dist/identity/processes.js +60 -12
- package/dist/identity-auth.js +66 -0
- package/dist/identity-cli.js +88 -0
- package/dist/identity-copy.js +124 -0
- package/dist/identity-store.js +101 -0
- package/dist/log.js +82 -4
- package/dist/mcp-tools.js +134 -0
- package/dist/muster.js +72 -96
- package/dist/registry.js +21 -4
- package/dist/requester-policy.js +106 -0
- package/dist/requester.js +59 -0
- package/dist/run.js +306 -54
- package/package.json +1 -1
package/dist/run.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { stat, readFile, writeFile, mkdir, rm } from "node:fs/promises";
|
|
1
|
+
import { stat, readFile, realpath, writeFile, mkdir, rm, } from "node:fs/promises";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { loadConfig } from "./config.js";
|
|
7
|
-
import { runSchema, assertAllowedWorkspace, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
|
|
7
|
+
import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
|
|
8
|
+
import { enforcementGrade } from "./enforcement.js";
|
|
9
|
+
import { LOCAL } from "./requester.js";
|
|
10
|
+
import { assertEnforcementMeetsMinimum, assertLevelWithinCeiling, assertMayAct, resolveIdentityForRequest, resolveRequesterPolicy, RequesterPolicyRefusal, } from "./requester-policy.js";
|
|
11
|
+
import { IdentityError, readIdentity, } from "./identity-store.js";
|
|
12
|
+
import { copyIdentity, identityEnv, setWorkspaceTrust, } from "./identity-copy.js";
|
|
8
13
|
import { Registry } from "./registry.js";
|
|
9
14
|
import { LaunchLog } from "./log.js";
|
|
10
15
|
import { hosts, selectHost } from "./hosts/index.js";
|
|
11
|
-
import { processRef, isSame, stopTree, delay, descendants, } from "./identity/processes.js";
|
|
16
|
+
import { processRef, isSame, stopTree, delay, descendants, ownedGroup, } from "./identity/processes.js";
|
|
12
17
|
import { resolveCodex } from "./identity/codex.js";
|
|
13
18
|
import { resolveClaude } from "./identity/claude.js";
|
|
14
19
|
import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
|
|
@@ -30,6 +35,7 @@ function taskHandle(entry, pid) {
|
|
|
30
35
|
sandbox: entry.sandbox,
|
|
31
36
|
mcp: entry.mcp ?? [],
|
|
32
37
|
...(entry.mcp_warnings ? { mcp_warnings: entry.mcp_warnings } : {}),
|
|
38
|
+
...(entry.enforcement ? { enforcement: entry.enforcement } : {}),
|
|
33
39
|
id: entry.id,
|
|
34
40
|
runtime: entry.runtime,
|
|
35
41
|
state: "running",
|
|
@@ -37,10 +43,24 @@ function taskHandle(entry, pid) {
|
|
|
37
43
|
pid,
|
|
38
44
|
};
|
|
39
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The variable that relocates an agent's configuration home — the one place the
|
|
48
|
+
* agent records its session, and therefore the one place resolution may look.
|
|
49
|
+
* OpenCode has none: its resolver uses the per-launch loopback HTTP endpoint
|
|
50
|
+
* rather than a configuration path, so relocating its configuration moves
|
|
51
|
+
* nothing Muster reads.
|
|
52
|
+
*/
|
|
53
|
+
function configHomeVar(runtime) {
|
|
54
|
+
if (runtime === "codex")
|
|
55
|
+
return "CODEX_HOME";
|
|
56
|
+
if (runtime === "claude")
|
|
57
|
+
return "CLAUDE_CONFIG_DIR";
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
40
60
|
export class Muster {
|
|
41
61
|
config;
|
|
42
62
|
home;
|
|
43
|
-
|
|
63
|
+
hostEnv;
|
|
44
64
|
drivers;
|
|
45
65
|
sourceEnv;
|
|
46
66
|
registry;
|
|
@@ -48,10 +68,21 @@ export class Muster {
|
|
|
48
68
|
owned = new Set();
|
|
49
69
|
closing = false;
|
|
50
70
|
active = new Set();
|
|
51
|
-
constructor(config, home,
|
|
71
|
+
constructor(config, home,
|
|
72
|
+
/**
|
|
73
|
+
* Muster's own subprocesses: the version and policy probes. Denylist-filtered
|
|
74
|
+
* from the parent and never handed to an agent — the agent's base comes from
|
|
75
|
+
* `agentBaseEnv` per launch, which a remote requester composes rather than
|
|
76
|
+
* inherits.
|
|
77
|
+
*
|
|
78
|
+
* Not what locates or reads an agent's session: that follows the agent's own
|
|
79
|
+
* configuration directory, which a launch under an identity relocates. See
|
|
80
|
+
* `resolutionEnv`, which is this environment plus that one override.
|
|
81
|
+
*/
|
|
82
|
+
hostEnv, drivers, sourceEnv) {
|
|
52
83
|
this.config = config;
|
|
53
84
|
this.home = home;
|
|
54
|
-
this.
|
|
85
|
+
this.hostEnv = hostEnv;
|
|
55
86
|
this.drivers = drivers;
|
|
56
87
|
this.sourceEnv = sourceEnv;
|
|
57
88
|
this.registry = new Registry(home);
|
|
@@ -61,7 +92,7 @@ export class Muster {
|
|
|
61
92
|
const env = launchEnv(opts.env ?? process.env), home = opts.home ?? join(env.HOME ?? homedir(), ".muster");
|
|
62
93
|
return new Muster(await loadConfig(home), home, env, opts.drivers ?? hosts(), { ...(opts.env ?? process.env) });
|
|
63
94
|
}
|
|
64
|
-
run(input, requester =
|
|
95
|
+
run(input, requester = LOCAL) {
|
|
65
96
|
if (this.closing)
|
|
66
97
|
return Promise.reject(new Error("Muster is closing"));
|
|
67
98
|
const operation = this.launch(input, requester);
|
|
@@ -78,6 +109,32 @@ export class Muster {
|
|
|
78
109
|
"project" in input &&
|
|
79
110
|
input.cwd !== undefined)
|
|
80
111
|
throw new Error("use project or cwd, not both");
|
|
112
|
+
assertLevelExclusive(input);
|
|
113
|
+
// Resolved before the request is parsed, so an unenrolled requester is
|
|
114
|
+
// refused without Muster having interpreted anything it sent.
|
|
115
|
+
const policy = resolveRequesterPolicy(requester, this.config);
|
|
116
|
+
if (requester.kind === "remote" &&
|
|
117
|
+
typeof input === "object" &&
|
|
118
|
+
input !== null) {
|
|
119
|
+
const raw = input;
|
|
120
|
+
if (raw.permissions !== undefined || raw.sandbox !== undefined)
|
|
121
|
+
throw new RequesterPolicyRefusal("not-expressible-remotely", "a remote request may name a level, not permissions or sandbox");
|
|
122
|
+
// `env_allow` is not the whole allowlist while a request may choose its
|
|
123
|
+
// own MCP servers: `mcpEnvironment` copies each selected server's
|
|
124
|
+
// declared `env_vars` and `bearer_token_env_var` out of the unfiltered
|
|
125
|
+
// source environment, so naming a server is how a remote request would
|
|
126
|
+
// pull that server's credentials into its agent despite `env_allow = []`.
|
|
127
|
+
// A plugin is the same shape, and an OpenCode launch that selects any
|
|
128
|
+
// plugin additionally admits whatever the target repository ships under
|
|
129
|
+
// .opencode/plugin. The spec's mitigation is to withhold the server
|
|
130
|
+
// rather than the variable, and no profile key can — so the selection
|
|
131
|
+
// itself is not expressible remotely, and a remote launch gets the
|
|
132
|
+
// receiver's configured `default_mcp` / `default_plugins`. Configuration
|
|
133
|
+
// is the grant.
|
|
134
|
+
for (const key of ["mcp", "plugin"])
|
|
135
|
+
if (raw[key] !== undefined)
|
|
136
|
+
throw new RequesterPolicyRefusal("not-expressible-remotely", `a remote request may not select ${key}; this receiver's configured defaults apply`);
|
|
137
|
+
}
|
|
81
138
|
const req = runSchema.parse(input);
|
|
82
139
|
if (req.project) {
|
|
83
140
|
const configured = this.config.projects[req.project];
|
|
@@ -88,6 +145,65 @@ export class Muster {
|
|
|
88
145
|
if (!(await stat(req.cwd)).isDirectory())
|
|
89
146
|
throw new Error("cwd is not a directory");
|
|
90
147
|
await assertAllowedWorkspace(req.cwd, this.config);
|
|
148
|
+
if (policy.profile)
|
|
149
|
+
// Intersection by two independent checks. `projects: {}` matters: a
|
|
150
|
+
// configured project path is a root in its own right, and a remote
|
|
151
|
+
// profile must not inherit those grants.
|
|
152
|
+
await assertAllowedWorkspace(req.cwd, {
|
|
153
|
+
allowed_roots: policy.profile.allowed_roots,
|
|
154
|
+
projects: {},
|
|
155
|
+
});
|
|
156
|
+
// The effective level, and the reason this is not `?? "read"`: a remote
|
|
157
|
+
// request usually names no level, and `resolvePermissions` with no level
|
|
158
|
+
// falls through to `req.permissions ?? config.permissions` and
|
|
159
|
+
// `req.sandbox ?? config.sandbox` — the *receiving machine's* global
|
|
160
|
+
// defaults. So the profile is the floor here as well as the ceiling. A
|
|
161
|
+
// local requester has no `policy.level`, which leaves this undefined and
|
|
162
|
+
// local behaviour untouched.
|
|
163
|
+
const level = req.level ?? policy.level;
|
|
164
|
+
if (level)
|
|
165
|
+
assertLevelWithinCeiling(level, policy);
|
|
166
|
+
// Validated here, before anything is reserved or spawned: a bad identity
|
|
167
|
+
// must leave no registry entry behind. The copy cannot be made yet — it is
|
|
168
|
+
// named after the launch id, which only `reserve` produces — so this is the
|
|
169
|
+
// read-only half, and the copy happens below once that id exists.
|
|
170
|
+
const identityName = resolveIdentityForRequest(req.identity, policy);
|
|
171
|
+
let identityMeta;
|
|
172
|
+
if (identityName) {
|
|
173
|
+
identityMeta = await readIdentity(this.home, identityName);
|
|
174
|
+
if (identityMeta.agent !== req.runtime)
|
|
175
|
+
throw new IdentityError(identityName, `belongs to agent ${identityMeta.agent}, not ${req.runtime}`);
|
|
176
|
+
// Claude is the one runtime whose credential cannot survive the copy. It
|
|
177
|
+
// is keyed by the configuration directory's PATH, and a launch points
|
|
178
|
+
// Claude at the per-launch copy, which hashes to a service name that does
|
|
179
|
+
// not exist — the unsuffixed item is not a fallback. So `token_env` is the
|
|
180
|
+
// only route a Claude identity can launch under. Without this, such an
|
|
181
|
+
// identity would pass validation, copy, spawn a real agent and die at the
|
|
182
|
+
// deadline with "no Claude session registry found".
|
|
183
|
+
if (identityMeta.agent === "claude" && !identityMeta.token_env)
|
|
184
|
+
throw new IdentityError(identityName, "a Claude identity needs a token: run `claude setup-token`, export the " +
|
|
185
|
+
"token, and name the variable with --token-env. The keychain credential " +
|
|
186
|
+
"is keyed to the template's own path and cannot be used from the " +
|
|
187
|
+
"per-launch copy a launch actually runs from");
|
|
188
|
+
// Reported now rather than by an agent that starts unauthenticated: the
|
|
189
|
+
// variable is read from the unfiltered source environment because that is
|
|
190
|
+
// where the operator set it, and `hostEnv` filters CLAUDE_CODE_* away.
|
|
191
|
+
if (identityMeta.token_env && !this.sourceEnv[identityMeta.token_env])
|
|
192
|
+
throw new IdentityError(identityName, `needs ${identityMeta.token_env} set in the environment`);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The one request every permission- and argv-deriving helper is given.
|
|
196
|
+
*
|
|
197
|
+
* `launchArgs`, `openCodeSessionLaunch` and `openCodeTaskLaunch` each call
|
|
198
|
+
* `resolvePermissions` again internally, so resolving the pair here and
|
|
199
|
+
* handing them the raw `req` would put the effective level in the record
|
|
200
|
+
* and the machine's global defaults in the argv — a remote `read` launch
|
|
201
|
+
* whose registry entry says `read-only` while the spawned process was
|
|
202
|
+
* given `--approve-for-me`. Two values where there should be one is how
|
|
203
|
+
* that happens, so there is one: nothing below derives containment from
|
|
204
|
+
* `req`.
|
|
205
|
+
*/
|
|
206
|
+
const effective = level ? { ...req, level } : req;
|
|
91
207
|
if (req.kind === "task" && req.host)
|
|
92
208
|
throw new Error("host applies only to session launches");
|
|
93
209
|
if (req.terminal && !req.open)
|
|
@@ -97,31 +213,51 @@ export class Muster {
|
|
|
97
213
|
if (req.open &&
|
|
98
214
|
(req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
|
|
99
215
|
throw new Error("--open requires a tmux session on macOS");
|
|
100
|
-
const permissions = resolvePermissions(
|
|
101
|
-
const
|
|
102
|
-
|
|
216
|
+
const permissions = resolvePermissions(effective, this.config);
|
|
217
|
+
const enforcement = enforcementGrade(req.runtime, permissions);
|
|
218
|
+
assertEnforcementMeetsMinimum(req.runtime, enforcement, policy);
|
|
219
|
+
// Composed here, before any process of any kind is started — the version
|
|
220
|
+
// probes and host availability checks below all spawn. A profile whose
|
|
221
|
+
// composed environment has no PATH or LANG must refuse the launch rather
|
|
222
|
+
// than leave a half-started one behind.
|
|
223
|
+
const agentBase = agentBaseEnv(requester, policy.profile, this.sourceEnv);
|
|
224
|
+
const selectedMcp = selectMcp(effective, this.config);
|
|
225
|
+
const selectedPlugins = req.runtime === "opencode" ? selectPlugins(effective, this.config) : [];
|
|
103
226
|
let argv = [];
|
|
104
227
|
if (req.runtime === "opencode")
|
|
105
|
-
runtimeArgs(
|
|
228
|
+
runtimeArgs(effective);
|
|
106
229
|
else
|
|
107
|
-
argv = launchArgs(
|
|
230
|
+
argv = launchArgs(effective, this.config);
|
|
108
231
|
const driver = req.kind === "session"
|
|
109
232
|
? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
|
|
110
233
|
: undefined;
|
|
111
234
|
if (req.open &&
|
|
112
235
|
(!driver?.open || !(await driver.openAvailable?.(terminal))))
|
|
113
236
|
throw new Error(`--open requires the selected terminal (${terminal}) installed on macOS and tmux`);
|
|
114
|
-
let
|
|
237
|
+
let codexPolicy = [];
|
|
115
238
|
if (req.runtime === "claude")
|
|
116
|
-
await assertClaudePolicy(this.
|
|
239
|
+
await assertClaudePolicy(this.hostEnv);
|
|
117
240
|
if (req.runtime === "opencode")
|
|
118
|
-
await assertOpenCodeVersion(this.
|
|
119
|
-
const { entry: reservation, deduped } = await this.registry.reserve({
|
|
241
|
+
await assertOpenCodeVersion(this.hostEnv, "1.18.31", Date.now() + 4000);
|
|
242
|
+
const { entry: reservation, deduped } = await this.registry.reserve({
|
|
243
|
+
runtime: req.runtime,
|
|
244
|
+
kind: req.kind,
|
|
245
|
+
cwd: req.cwd,
|
|
246
|
+
...permissions,
|
|
247
|
+
enforcement,
|
|
248
|
+
}, this.config.max_concurrent, req.requestKey
|
|
120
249
|
? {
|
|
121
250
|
requestKey: req.requestKey,
|
|
122
|
-
paramsFingerprint: paramsFingerprint(
|
|
251
|
+
paramsFingerprint: paramsFingerprint(
|
|
252
|
+
// The identity that actually determined the account, not the one
|
|
253
|
+
// named in the request: a remote requester granted exactly one
|
|
254
|
+
// identity names none and gets it anyway, and that launch must
|
|
255
|
+
// not share a fingerprint with a request that named something
|
|
256
|
+
// else. `effective` is left alone — nothing else derives from
|
|
257
|
+
// identity.
|
|
258
|
+
{ ...effective, identity: identityName }, permissions, selectedMcp.map((s) => s.name), selectedPlugins.map((p) => p.name)),
|
|
123
259
|
}
|
|
124
|
-
: undefined);
|
|
260
|
+
: undefined, requester);
|
|
125
261
|
if (deduped) {
|
|
126
262
|
await this.log.write({
|
|
127
263
|
event: "deduped",
|
|
@@ -129,7 +265,7 @@ export class Muster {
|
|
|
129
265
|
request_key: req.requestKey,
|
|
130
266
|
status: reservation.status,
|
|
131
267
|
requester,
|
|
132
|
-
});
|
|
268
|
+
}, policy.logMode);
|
|
133
269
|
return this.recordedOutcome(reservation);
|
|
134
270
|
}
|
|
135
271
|
this.owned.add(reservation.launchId);
|
|
@@ -149,15 +285,65 @@ export class Muster {
|
|
|
149
285
|
requester,
|
|
150
286
|
...(req.open ? { open: true, terminal } : {}),
|
|
151
287
|
args: req.args,
|
|
288
|
+
// The RESOLVED name, not req.identity: a remote requester granted
|
|
289
|
+
// exactly one identity names none and gets it anyway, and the audit
|
|
290
|
+
// trail must state the account that was actually used. Not in
|
|
291
|
+
// METADATA_DROP — a receiver needs this in a metadata-only record
|
|
292
|
+
// more than anywhere else, since it is the account it lent out.
|
|
293
|
+
...(identityName ? { identity: identityName } : {}),
|
|
152
294
|
mcp: selectedMcp.map((s) => s.name),
|
|
153
295
|
...(selectedPlugins.length
|
|
154
296
|
? { plugins: selectedPlugins.map((p) => p.name) }
|
|
155
297
|
: {}),
|
|
156
298
|
...permissions,
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
299
|
+
enforcement,
|
|
300
|
+
}, policy.logMode);
|
|
301
|
+
const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, agentBase, this.sourceEnv), req.cwd, deadline);
|
|
302
|
+
let runtimeEnv = mcpEnvironment(prepared.servers, agentBase, this.sourceEnv);
|
|
160
303
|
entry = await this.registry.update(entry.launchId, prepared.summary);
|
|
304
|
+
if (identityName && identityMeta) {
|
|
305
|
+
// The launch id exists now, so the copy can be made and named after it.
|
|
306
|
+
const copy = await copyIdentity(this.home, identityName, identityMeta, entry.launchId);
|
|
307
|
+
// Written into the copy, never the live ~/.claude.json: a running
|
|
308
|
+
// session rewrites that file continuously and Muster cannot coordinate.
|
|
309
|
+
if (identityMeta.agent === "claude")
|
|
310
|
+
// RESOLVED, not as written: Claude keys projects[] by what its own
|
|
311
|
+
// getcwd(2) returns, which has every symlink resolved. On macOS a
|
|
312
|
+
// launch into /tmp/... is really /private/tmp/..., so the un-resolved
|
|
313
|
+
// key is one Claude never looks up — the trust dialog appears, the
|
|
314
|
+
// session never registers, and the launch burns its whole deadline.
|
|
315
|
+
// `assertAllowedWorkspace` already realpaths for containment.
|
|
316
|
+
await setWorkspaceTrust(copy, await realpath(req.cwd));
|
|
317
|
+
runtimeEnv = {
|
|
318
|
+
...runtimeEnv,
|
|
319
|
+
...identityEnv(identityMeta, copy, this.sourceEnv),
|
|
320
|
+
};
|
|
321
|
+
entry = await this.registry.update(entry.launchId, {
|
|
322
|
+
identityPath: copy,
|
|
323
|
+
// Recorded alongside the path, because the path outlives the directory
|
|
324
|
+
// it names and never named the account.
|
|
325
|
+
identity: identityName,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
// Where the agent will record its session. Distinct from `hostEnv`, which
|
|
329
|
+
// keeps spawning Muster's own helpers — see `resolutionEnv`.
|
|
330
|
+
//
|
|
331
|
+
// Derived from the COMPOSED agent environment, not from `identityPath`
|
|
332
|
+
// alone: a requester profile's `env_defaults` delivers any key by design,
|
|
333
|
+
// so a profile setting CLAUDE_CONFIG_DIR or CODEX_HOME hands the agent one
|
|
334
|
+
// configuration home while resolution searched the host's — reserve, spawn,
|
|
335
|
+
// dead at the deadline. The identity copy still wins where there is one,
|
|
336
|
+
// because `identityEnv` is merged into `runtimeEnv` above.
|
|
337
|
+
const configVar = configHomeVar(req.runtime);
|
|
338
|
+
const configHome = configVar
|
|
339
|
+
? (runtimeEnv[configVar] ?? entry.identityPath)
|
|
340
|
+
: undefined;
|
|
341
|
+
// Persisted only when it differs from the host's, so a launch with neither
|
|
342
|
+
// an identity nor an override writes nothing new and reads exactly as
|
|
343
|
+
// before. `refreshed()` has only the entry in scope and must agree.
|
|
344
|
+
if (configVar && configHome && configHome !== this.hostEnv[configVar])
|
|
345
|
+
entry = await this.registry.update(entry.launchId, { configHome });
|
|
346
|
+
const resolveEnv = this.resolutionEnv(req.runtime, configHome);
|
|
161
347
|
let bridgePath;
|
|
162
348
|
if (req.runtime === "claude" && prepared.servers.length) {
|
|
163
349
|
const dir = join(this.home, "mcp");
|
|
@@ -169,15 +355,15 @@ export class Muster {
|
|
|
169
355
|
await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
|
|
170
356
|
}
|
|
171
357
|
if (req.runtime !== "opencode")
|
|
172
|
-
argv = launchArgs(
|
|
358
|
+
argv = launchArgs(effective, this.config, prepared.servers, bridgePath);
|
|
173
359
|
if (req.runtime === "codex") {
|
|
174
|
-
|
|
175
|
-
argv.splice(1, 0, ...
|
|
360
|
+
codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
|
|
361
|
+
argv.splice(1, 0, ...codexPolicy);
|
|
176
362
|
}
|
|
177
363
|
else if (req.runtime === "opencode" && req.kind === "session") {
|
|
178
364
|
const port = await allocateLoopbackPort();
|
|
179
365
|
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
180
|
-
const launch = await openCodeSessionLaunch(
|
|
366
|
+
const launch = await openCodeSessionLaunch(effective, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
|
|
181
367
|
argv = launch.argv;
|
|
182
368
|
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
183
369
|
entry = await this.registry.update(entry.launchId, {
|
|
@@ -187,7 +373,7 @@ export class Muster {
|
|
|
187
373
|
}
|
|
188
374
|
else if (req.runtime === "opencode" && req.kind === "task") {
|
|
189
375
|
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
190
|
-
const launch = await openCodeTaskLaunch(
|
|
376
|
+
const launch = await openCodeTaskLaunch(effective, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
|
|
191
377
|
argv = launch.argv;
|
|
192
378
|
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
193
379
|
}
|
|
@@ -197,7 +383,7 @@ export class Muster {
|
|
|
197
383
|
// recovery branch in Registry.transaction.
|
|
198
384
|
entry = await this.registry.update(entry.launchId, { spawning: true });
|
|
199
385
|
if (req.kind === "task")
|
|
200
|
-
return await this.startTask(entry, argv, runtimeEnv);
|
|
386
|
+
return await this.startTask(entry, argv, runtimeEnv, policy.logMode);
|
|
201
387
|
const launched = await driver.launch({
|
|
202
388
|
argv,
|
|
203
389
|
cwd: req.cwd,
|
|
@@ -210,20 +396,43 @@ export class Muster {
|
|
|
210
396
|
await driver.stop(launched.hostRef);
|
|
211
397
|
throw new Error("process exited during launch");
|
|
212
398
|
}
|
|
213
|
-
|
|
214
|
-
|
|
399
|
+
const group = await ownedGroup(launched.pid);
|
|
400
|
+
entry = {
|
|
401
|
+
...entry,
|
|
402
|
+
root,
|
|
403
|
+
...(group ? { group } : {}),
|
|
404
|
+
host: driver.id,
|
|
405
|
+
hostRef: launched.hostRef,
|
|
406
|
+
};
|
|
407
|
+
// Only the fields that changed, not the whole entry: `entry` still
|
|
408
|
+
// carries its recorded `requester`, and update() refuses a patch that
|
|
409
|
+
// mentions that field at all, changed or not.
|
|
410
|
+
await this.registry.update(entry.launchId, {
|
|
411
|
+
root,
|
|
412
|
+
...(group ? { group } : {}),
|
|
413
|
+
host: driver.id,
|
|
414
|
+
hostRef: launched.hostRef,
|
|
415
|
+
});
|
|
215
416
|
let diagnostic = req.runtime === "claude"
|
|
216
417
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
217
418
|
: req.runtime === "opencode"
|
|
218
419
|
? "no OpenCode session matched the launch endpoint, cwd, and creation window"
|
|
219
420
|
: "no descendant runtime identity found";
|
|
421
|
+
// `resolveEnv`, not `hostEnv`: this connection is how the thread
|
|
422
|
+
// `resolveCodex` found gets READ — both its `rejectionOf` screen, where a
|
|
423
|
+
// non-null answer is fatal, and `codexReachable` at readiness. An
|
|
424
|
+
// app-server reading the operator's CODEX_HOME has no rollout for a thread
|
|
425
|
+
// created under the copy, so it answers "no rollout found", every
|
|
426
|
+
// candidate is rejected, and the launch burns its deadline. Pointing
|
|
427
|
+
// discovery at the copy while its reads stay on the host is the same bug
|
|
428
|
+
// one call downstream.
|
|
220
429
|
if (req.runtime === "codex")
|
|
221
|
-
rpc = new CodexRpc(
|
|
430
|
+
rpc = new CodexRpc(resolveEnv, req.cwd);
|
|
222
431
|
while (Date.now() < deadline) {
|
|
223
432
|
if (deadline - Date.now() < 25)
|
|
224
433
|
break;
|
|
225
434
|
if (await isSame(root)) {
|
|
226
|
-
const tracked = (await Promise.all((await descendants(root.pid)).map(processRef))).filter((p) => !!p);
|
|
435
|
+
const tracked = (await Promise.all((await descendants(root.pid, Date.now() + 1000, entry.group)).map(processRef))).filter((p) => !!p);
|
|
227
436
|
const refs = [...(entry.descendants ?? [])];
|
|
228
437
|
for (const ref of tracked)
|
|
229
438
|
if (!refs.some((r) => r.pid === ref.pid && r.start === ref.start))
|
|
@@ -240,7 +449,7 @@ export class Muster {
|
|
|
240
449
|
let openingTerminal = false;
|
|
241
450
|
try {
|
|
242
451
|
const identity = req.runtime === "codex"
|
|
243
|
-
? await resolveCodex(root.pid,
|
|
452
|
+
? await resolveCodex(root.pid, resolveEnv, deadline, async (id) => {
|
|
244
453
|
try {
|
|
245
454
|
const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
|
|
246
455
|
return threadRejection(thread, id);
|
|
@@ -251,7 +460,7 @@ export class Muster {
|
|
|
251
460
|
})
|
|
252
461
|
: req.runtime === "opencode"
|
|
253
462
|
? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
|
|
254
|
-
: await resolveClaude(root.pid,
|
|
463
|
+
: await resolveClaude(root.pid, resolveEnv, deadline);
|
|
255
464
|
if (identity) {
|
|
256
465
|
diagnostic =
|
|
257
466
|
req.runtime === "codex"
|
|
@@ -284,6 +493,7 @@ export class Muster {
|
|
|
284
493
|
const peer = {
|
|
285
494
|
kind: "session",
|
|
286
495
|
...permissions,
|
|
496
|
+
enforcement,
|
|
287
497
|
...prepared.summary,
|
|
288
498
|
name: named.display,
|
|
289
499
|
canonical_id: named.canonicalId,
|
|
@@ -314,11 +524,7 @@ export class Muster {
|
|
|
314
524
|
status: "running",
|
|
315
525
|
peer,
|
|
316
526
|
});
|
|
317
|
-
await this.log.write({
|
|
318
|
-
event: "ready",
|
|
319
|
-
launch_id: entry.launchId,
|
|
320
|
-
peer,
|
|
321
|
-
});
|
|
527
|
+
await this.log.write({ event: "ready", launch_id: entry.launchId, peer }, policy.logMode);
|
|
322
528
|
return peer;
|
|
323
529
|
}
|
|
324
530
|
}
|
|
@@ -345,7 +551,7 @@ export class Muster {
|
|
|
345
551
|
error: message,
|
|
346
552
|
});
|
|
347
553
|
await this.log
|
|
348
|
-
.write({ event: "failure", launch_id: entry.launchId, error: message })
|
|
554
|
+
.write({ event: "failure", launch_id: entry.launchId, error: message }, policy.logMode)
|
|
349
555
|
.catch(() => { });
|
|
350
556
|
throw e;
|
|
351
557
|
}
|
|
@@ -382,7 +588,7 @@ export class Muster {
|
|
|
382
588
|
throw new Error(`Request key ${key} has no recorded session`);
|
|
383
589
|
return entry.peer;
|
|
384
590
|
}
|
|
385
|
-
async startTask(entry, argv, env) {
|
|
591
|
+
async startTask(entry, argv, env, logMode) {
|
|
386
592
|
const dir = join(this.home, "tasks", entry.id);
|
|
387
593
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
388
594
|
const outputPath = join(dir, "output.txt"), exitPath = join(dir, "exit.json"), startPath = join(dir, "start"), specPath = join(dir, "launch.json");
|
|
@@ -410,9 +616,11 @@ export class Muster {
|
|
|
410
616
|
child.kill();
|
|
411
617
|
throw new Error("task owner exited during launch");
|
|
412
618
|
}
|
|
619
|
+
const group = await ownedGroup(child.pid);
|
|
413
620
|
try {
|
|
414
621
|
await this.registry.update(entry.launchId, {
|
|
415
622
|
root,
|
|
623
|
+
...(group ? { group } : {}),
|
|
416
624
|
outputPath,
|
|
417
625
|
exitPath,
|
|
418
626
|
status: "running",
|
|
@@ -420,15 +628,11 @@ export class Muster {
|
|
|
420
628
|
await writeFile(startPath, "start", { mode: 0o600 });
|
|
421
629
|
child.unref();
|
|
422
630
|
const handle = taskHandle(entry, root.pid);
|
|
423
|
-
await this.log.write({
|
|
424
|
-
event: "started",
|
|
425
|
-
launch_id: entry.launchId,
|
|
426
|
-
run: handle,
|
|
427
|
-
});
|
|
631
|
+
await this.log.write({ event: "started", launch_id: entry.launchId, run: handle }, logMode);
|
|
428
632
|
return handle;
|
|
429
633
|
}
|
|
430
634
|
catch (e) {
|
|
431
|
-
await stopTree(root);
|
|
635
|
+
await stopTree(root, group);
|
|
432
636
|
await rm(specPath, { force: true });
|
|
433
637
|
throw e;
|
|
434
638
|
}
|
|
@@ -451,6 +655,8 @@ export class Muster {
|
|
|
451
655
|
finally {
|
|
452
656
|
if (entry.mcpConfigPath)
|
|
453
657
|
await rm(entry.mcpConfigPath, { force: true });
|
|
658
|
+
if (entry.identityPath)
|
|
659
|
+
await rm(entry.identityPath, { recursive: true, force: true });
|
|
454
660
|
}
|
|
455
661
|
}
|
|
456
662
|
async stopEntryProcesses(entry) {
|
|
@@ -458,7 +664,7 @@ export class Muster {
|
|
|
458
664
|
return;
|
|
459
665
|
if (!(await isSame(entry.root))) {
|
|
460
666
|
for (const ref of entry.descendants ?? [])
|
|
461
|
-
await stopTree(ref);
|
|
667
|
+
await stopTree(ref, entry.group);
|
|
462
668
|
return;
|
|
463
669
|
}
|
|
464
670
|
// Validate the persisted process identity before ever using a window reference.
|
|
@@ -468,9 +674,9 @@ export class Muster {
|
|
|
468
674
|
(await driver.list()).some((s) => s.hostRef === entry.hostRef && s.pid === entry.root.pid))
|
|
469
675
|
await driver.stop(entry.hostRef);
|
|
470
676
|
else
|
|
471
|
-
await stopTree(entry.root);
|
|
677
|
+
await stopTree(entry.root, entry.group);
|
|
472
678
|
for (const ref of entry.descendants ?? [])
|
|
473
|
-
await stopTree(ref);
|
|
679
|
+
await stopTree(ref, entry.group);
|
|
474
680
|
}
|
|
475
681
|
async find(id) {
|
|
476
682
|
const all = await this.registry.all();
|
|
@@ -496,13 +702,37 @@ export class Muster {
|
|
|
496
702
|
throw new Error(`${result.reason}: ${result.candidates.join(", ")}`);
|
|
497
703
|
return peers[named.indexOf(result.peer)];
|
|
498
704
|
}
|
|
499
|
-
async stop(id) {
|
|
705
|
+
async stop(id, requester = LOCAL) {
|
|
500
706
|
const entry = await this.find(id);
|
|
707
|
+
assertMayAct(entry, requester);
|
|
501
708
|
await this.stopEntry(entry);
|
|
502
709
|
await this.registry.update(entry.launchId, { status: "stopped" });
|
|
503
710
|
await this.log.write({ event: "stopped", launch_id: entry.launchId });
|
|
504
711
|
return { stopped: true, id: entry.id };
|
|
505
712
|
}
|
|
713
|
+
/**
|
|
714
|
+
* Where to look for the agent's own session registry. Two different uses of an
|
|
715
|
+
* environment meet here, and conflating them is the bug this exists to avoid:
|
|
716
|
+
*
|
|
717
|
+
* - *Spawning* Muster's own helpers — the version probes, `assertClaudePolicy`
|
|
718
|
+
* and the Codex RPC connection — keeps `hostEnv`. They need a working PATH
|
|
719
|
+
* and they are Muster's machinery, not the agent's.
|
|
720
|
+
* - *Locating* what the agent wrote must follow the agent's configuration
|
|
721
|
+
* directory. A launch under an identity relocates that directory to the
|
|
722
|
+
* per-launch copy, so the session record is inside the copy; resolving
|
|
723
|
+
* against `hostEnv` would search the operator's own directory, find nothing,
|
|
724
|
+
* and fail the whole launch deadline with a useless diagnostic.
|
|
725
|
+
*
|
|
726
|
+
* The identity's token is deliberately absent: the resolvers read paths.
|
|
727
|
+
* OpenCode needs no entry — its resolver uses the per-launch loopback HTTP
|
|
728
|
+
* endpoint rather than a configuration path.
|
|
729
|
+
*/
|
|
730
|
+
resolutionEnv(runtime, configHome) {
|
|
731
|
+
const key = configHomeVar(runtime);
|
|
732
|
+
if (!configHome || !key)
|
|
733
|
+
return this.hostEnv;
|
|
734
|
+
return { ...this.hostEnv, [key]: configHome };
|
|
735
|
+
}
|
|
506
736
|
async refreshed(entry) {
|
|
507
737
|
if (entry.kind !== "session" ||
|
|
508
738
|
entry.status !== "running" ||
|
|
@@ -514,7 +744,11 @@ export class Muster {
|
|
|
514
744
|
try {
|
|
515
745
|
let metadata;
|
|
516
746
|
if (entry.runtime === "codex") {
|
|
517
|
-
|
|
747
|
+
// Derived from the entry for the same reason as the claude branch
|
|
748
|
+
// below: this reads the session's own thread, and a session launched
|
|
749
|
+
// under an identity recorded it inside the copy. On `hostEnv` a healthy
|
|
750
|
+
// codex identity session reads as `unreachable` in every listing.
|
|
751
|
+
rpc = new CodexRpc(this.resolutionEnv("codex", entry.configHome ?? entry.identityPath), entry.cwd);
|
|
518
752
|
metadata = await codexReachable(rpc, entry.id, deadline);
|
|
519
753
|
}
|
|
520
754
|
else if (entry.runtime === "opencode") {
|
|
@@ -535,7 +769,12 @@ export class Muster {
|
|
|
535
769
|
throw new Error("session endpoint ownership changed during refresh");
|
|
536
770
|
}
|
|
537
771
|
else {
|
|
538
|
-
|
|
772
|
+
// Derived from the entry, not from a launch-time value: this runs long
|
|
773
|
+
// after the launch returned and has only the recorded entry in scope. A
|
|
774
|
+
// running session launched under an identity recorded itself inside the
|
|
775
|
+
// copy, so re-resolution must look there or `list` reports every such
|
|
776
|
+
// session as unreachable.
|
|
777
|
+
const identity = await resolveClaude(entry.root.pid, this.resolutionEnv(entry.runtime, entry.configHome ?? entry.identityPath), deadline);
|
|
539
778
|
if (!identity ||
|
|
540
779
|
identity.id !== entry.id ||
|
|
541
780
|
!(await claudeReachable(identity.socketPath, deadline)))
|
|
@@ -570,6 +809,12 @@ export class Muster {
|
|
|
570
809
|
await rpc?.close();
|
|
571
810
|
}
|
|
572
811
|
}
|
|
812
|
+
/**
|
|
813
|
+
* Unscoped by requester, unlike stop and output. Filtering the listing is a
|
|
814
|
+
* contract change and nothing can call this remotely yet; the decision belongs
|
|
815
|
+
* with the supported entry point (#44), which is also what would need to
|
|
816
|
+
* choose between hiding other requesters' entries and redacting them.
|
|
817
|
+
*/
|
|
573
818
|
async list(kind) {
|
|
574
819
|
const entries = await this.registry.all();
|
|
575
820
|
const result = [];
|
|
@@ -579,6 +824,8 @@ export class Muster {
|
|
|
579
824
|
const e = await this.refreshed(original);
|
|
580
825
|
if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
|
|
581
826
|
await rm(e.mcpConfigPath, { force: true });
|
|
827
|
+
if (e.identityPath && !["starting", "running"].includes(e.status))
|
|
828
|
+
await rm(e.identityPath, { recursive: true, force: true });
|
|
582
829
|
if (e.kind === "session")
|
|
583
830
|
result.push({
|
|
584
831
|
...e.peer,
|
|
@@ -586,6 +833,9 @@ export class Muster {
|
|
|
586
833
|
...(e.permissions
|
|
587
834
|
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
588
835
|
: {}),
|
|
836
|
+
// From the entry, not the peer: a listing must report the same fact a
|
|
837
|
+
// launch result does, and an entry predating the field has none.
|
|
838
|
+
...(e.enforcement ? { enforcement: e.enforcement } : {}),
|
|
589
839
|
id: e.id,
|
|
590
840
|
runtime: e.peer?.runtime ?? e.runtime,
|
|
591
841
|
state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
|
|
@@ -605,6 +855,7 @@ export class Muster {
|
|
|
605
855
|
...(e.permissions
|
|
606
856
|
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
607
857
|
: {}),
|
|
858
|
+
...(e.enforcement ? { enforcement: e.enforcement } : {}),
|
|
608
859
|
id: e.id,
|
|
609
860
|
runtime: e.runtime,
|
|
610
861
|
cwd: e.cwd,
|
|
@@ -617,8 +868,9 @@ export class Muster {
|
|
|
617
868
|
}
|
|
618
869
|
return result;
|
|
619
870
|
}
|
|
620
|
-
async output(id) {
|
|
871
|
+
async output(id, requester = LOCAL) {
|
|
621
872
|
const entry = await this.find(id);
|
|
873
|
+
assertMayAct(entry, requester);
|
|
622
874
|
if (entry.kind !== "task")
|
|
623
875
|
throw new Error("output is available only for task runs");
|
|
624
876
|
if (!entry.outputPath)
|