@brutalsystems/muster 0.7.12 → 0.7.15
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 +264 -5
- 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-auth.js +66 -0
- package/dist/identity-cli.js +88 -0
- package/dist/identity-copy.js +184 -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 +15 -2
- package/dist/requester-policy.js +106 -0
- package/dist/requester.js +59 -0
- package/dist/run.js +296 -47
- package/package.json +1 -1
package/dist/run.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
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, setCodexWorkspaceTrust, 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";
|
|
@@ -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,71 @@ 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
|
+
// Codex gates the same way behind a different file and a different key,
|
|
318
|
+
// and a fresh copy trusts nothing — so every Codex identity launch sat
|
|
319
|
+
// on "Do you trust the contents of this directory?" until the deadline.
|
|
320
|
+
// Resolved for the same reason: Codex keys `projects` by its own cwd.
|
|
321
|
+
else if (identityMeta.agent === "codex")
|
|
322
|
+
await setCodexWorkspaceTrust(copy, await realpath(req.cwd));
|
|
323
|
+
runtimeEnv = {
|
|
324
|
+
...runtimeEnv,
|
|
325
|
+
...identityEnv(identityMeta, copy, this.sourceEnv),
|
|
326
|
+
};
|
|
327
|
+
entry = await this.registry.update(entry.launchId, {
|
|
328
|
+
identityPath: copy,
|
|
329
|
+
// Recorded alongside the path, because the path outlives the directory
|
|
330
|
+
// it names and never named the account.
|
|
331
|
+
identity: identityName,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// Where the agent will record its session. Distinct from `hostEnv`, which
|
|
335
|
+
// keeps spawning Muster's own helpers — see `resolutionEnv`.
|
|
336
|
+
//
|
|
337
|
+
// Derived from the COMPOSED agent environment, not from `identityPath`
|
|
338
|
+
// alone: a requester profile's `env_defaults` delivers any key by design,
|
|
339
|
+
// so a profile setting CLAUDE_CONFIG_DIR or CODEX_HOME hands the agent one
|
|
340
|
+
// configuration home while resolution searched the host's — reserve, spawn,
|
|
341
|
+
// dead at the deadline. The identity copy still wins where there is one,
|
|
342
|
+
// because `identityEnv` is merged into `runtimeEnv` above.
|
|
343
|
+
const configVar = configHomeVar(req.runtime);
|
|
344
|
+
const configHome = configVar
|
|
345
|
+
? (runtimeEnv[configVar] ?? entry.identityPath)
|
|
346
|
+
: undefined;
|
|
347
|
+
// Persisted only when it differs from the host's, so a launch with neither
|
|
348
|
+
// an identity nor an override writes nothing new and reads exactly as
|
|
349
|
+
// before. `refreshed()` has only the entry in scope and must agree.
|
|
350
|
+
if (configVar && configHome && configHome !== this.hostEnv[configVar])
|
|
351
|
+
entry = await this.registry.update(entry.launchId, { configHome });
|
|
352
|
+
const resolveEnv = this.resolutionEnv(req.runtime, configHome);
|
|
161
353
|
let bridgePath;
|
|
162
354
|
if (req.runtime === "claude" && prepared.servers.length) {
|
|
163
355
|
const dir = join(this.home, "mcp");
|
|
@@ -169,15 +361,15 @@ export class Muster {
|
|
|
169
361
|
await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
|
|
170
362
|
}
|
|
171
363
|
if (req.runtime !== "opencode")
|
|
172
|
-
argv = launchArgs(
|
|
364
|
+
argv = launchArgs(effective, this.config, prepared.servers, bridgePath);
|
|
173
365
|
if (req.runtime === "codex") {
|
|
174
|
-
|
|
175
|
-
argv.splice(1, 0, ...
|
|
366
|
+
codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
|
|
367
|
+
argv.splice(1, 0, ...codexPolicy);
|
|
176
368
|
}
|
|
177
369
|
else if (req.runtime === "opencode" && req.kind === "session") {
|
|
178
370
|
const port = await allocateLoopbackPort();
|
|
179
371
|
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
180
|
-
const launch = await openCodeSessionLaunch(
|
|
372
|
+
const launch = await openCodeSessionLaunch(effective, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
|
|
181
373
|
argv = launch.argv;
|
|
182
374
|
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
183
375
|
entry = await this.registry.update(entry.launchId, {
|
|
@@ -187,7 +379,7 @@ export class Muster {
|
|
|
187
379
|
}
|
|
188
380
|
else if (req.runtime === "opencode" && req.kind === "task") {
|
|
189
381
|
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
190
|
-
const launch = await openCodeTaskLaunch(
|
|
382
|
+
const launch = await openCodeTaskLaunch(effective, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
|
|
191
383
|
argv = launch.argv;
|
|
192
384
|
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
193
385
|
}
|
|
@@ -197,7 +389,7 @@ export class Muster {
|
|
|
197
389
|
// recovery branch in Registry.transaction.
|
|
198
390
|
entry = await this.registry.update(entry.launchId, { spawning: true });
|
|
199
391
|
if (req.kind === "task")
|
|
200
|
-
return await this.startTask(entry, argv, runtimeEnv);
|
|
392
|
+
return await this.startTask(entry, argv, runtimeEnv, policy.logMode);
|
|
201
393
|
const launched = await driver.launch({
|
|
202
394
|
argv,
|
|
203
395
|
cwd: req.cwd,
|
|
@@ -218,14 +410,30 @@ export class Muster {
|
|
|
218
410
|
host: driver.id,
|
|
219
411
|
hostRef: launched.hostRef,
|
|
220
412
|
};
|
|
221
|
-
|
|
413
|
+
// Only the fields that changed, not the whole entry: `entry` still
|
|
414
|
+
// carries its recorded `requester`, and update() refuses a patch that
|
|
415
|
+
// mentions that field at all, changed or not.
|
|
416
|
+
await this.registry.update(entry.launchId, {
|
|
417
|
+
root,
|
|
418
|
+
...(group ? { group } : {}),
|
|
419
|
+
host: driver.id,
|
|
420
|
+
hostRef: launched.hostRef,
|
|
421
|
+
});
|
|
222
422
|
let diagnostic = req.runtime === "claude"
|
|
223
423
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
224
424
|
: req.runtime === "opencode"
|
|
225
425
|
? "no OpenCode session matched the launch endpoint, cwd, and creation window"
|
|
226
426
|
: "no descendant runtime identity found";
|
|
427
|
+
// `resolveEnv`, not `hostEnv`: this connection is how the thread
|
|
428
|
+
// `resolveCodex` found gets READ — both its `rejectionOf` screen, where a
|
|
429
|
+
// non-null answer is fatal, and `codexReachable` at readiness. An
|
|
430
|
+
// app-server reading the operator's CODEX_HOME has no rollout for a thread
|
|
431
|
+
// created under the copy, so it answers "no rollout found", every
|
|
432
|
+
// candidate is rejected, and the launch burns its deadline. Pointing
|
|
433
|
+
// discovery at the copy while its reads stay on the host is the same bug
|
|
434
|
+
// one call downstream.
|
|
227
435
|
if (req.runtime === "codex")
|
|
228
|
-
rpc = new CodexRpc(
|
|
436
|
+
rpc = new CodexRpc(resolveEnv, req.cwd);
|
|
229
437
|
while (Date.now() < deadline) {
|
|
230
438
|
if (deadline - Date.now() < 25)
|
|
231
439
|
break;
|
|
@@ -247,7 +455,7 @@ export class Muster {
|
|
|
247
455
|
let openingTerminal = false;
|
|
248
456
|
try {
|
|
249
457
|
const identity = req.runtime === "codex"
|
|
250
|
-
? await resolveCodex(root.pid,
|
|
458
|
+
? await resolveCodex(root.pid, resolveEnv, deadline, async (id) => {
|
|
251
459
|
try {
|
|
252
460
|
const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
|
|
253
461
|
return threadRejection(thread, id);
|
|
@@ -258,7 +466,7 @@ export class Muster {
|
|
|
258
466
|
})
|
|
259
467
|
: req.runtime === "opencode"
|
|
260
468
|
? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
|
|
261
|
-
: await resolveClaude(root.pid,
|
|
469
|
+
: await resolveClaude(root.pid, resolveEnv, deadline);
|
|
262
470
|
if (identity) {
|
|
263
471
|
diagnostic =
|
|
264
472
|
req.runtime === "codex"
|
|
@@ -291,6 +499,7 @@ export class Muster {
|
|
|
291
499
|
const peer = {
|
|
292
500
|
kind: "session",
|
|
293
501
|
...permissions,
|
|
502
|
+
enforcement,
|
|
294
503
|
...prepared.summary,
|
|
295
504
|
name: named.display,
|
|
296
505
|
canonical_id: named.canonicalId,
|
|
@@ -321,11 +530,7 @@ export class Muster {
|
|
|
321
530
|
status: "running",
|
|
322
531
|
peer,
|
|
323
532
|
});
|
|
324
|
-
await this.log.write({
|
|
325
|
-
event: "ready",
|
|
326
|
-
launch_id: entry.launchId,
|
|
327
|
-
peer,
|
|
328
|
-
});
|
|
533
|
+
await this.log.write({ event: "ready", launch_id: entry.launchId, peer }, policy.logMode);
|
|
329
534
|
return peer;
|
|
330
535
|
}
|
|
331
536
|
}
|
|
@@ -352,7 +557,7 @@ export class Muster {
|
|
|
352
557
|
error: message,
|
|
353
558
|
});
|
|
354
559
|
await this.log
|
|
355
|
-
.write({ event: "failure", launch_id: entry.launchId, error: message })
|
|
560
|
+
.write({ event: "failure", launch_id: entry.launchId, error: message }, policy.logMode)
|
|
356
561
|
.catch(() => { });
|
|
357
562
|
throw e;
|
|
358
563
|
}
|
|
@@ -389,7 +594,7 @@ export class Muster {
|
|
|
389
594
|
throw new Error(`Request key ${key} has no recorded session`);
|
|
390
595
|
return entry.peer;
|
|
391
596
|
}
|
|
392
|
-
async startTask(entry, argv, env) {
|
|
597
|
+
async startTask(entry, argv, env, logMode) {
|
|
393
598
|
const dir = join(this.home, "tasks", entry.id);
|
|
394
599
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
395
600
|
const outputPath = join(dir, "output.txt"), exitPath = join(dir, "exit.json"), startPath = join(dir, "start"), specPath = join(dir, "launch.json");
|
|
@@ -429,11 +634,7 @@ export class Muster {
|
|
|
429
634
|
await writeFile(startPath, "start", { mode: 0o600 });
|
|
430
635
|
child.unref();
|
|
431
636
|
const handle = taskHandle(entry, root.pid);
|
|
432
|
-
await this.log.write({
|
|
433
|
-
event: "started",
|
|
434
|
-
launch_id: entry.launchId,
|
|
435
|
-
run: handle,
|
|
436
|
-
});
|
|
637
|
+
await this.log.write({ event: "started", launch_id: entry.launchId, run: handle }, logMode);
|
|
437
638
|
return handle;
|
|
438
639
|
}
|
|
439
640
|
catch (e) {
|
|
@@ -460,6 +661,8 @@ export class Muster {
|
|
|
460
661
|
finally {
|
|
461
662
|
if (entry.mcpConfigPath)
|
|
462
663
|
await rm(entry.mcpConfigPath, { force: true });
|
|
664
|
+
if (entry.identityPath)
|
|
665
|
+
await rm(entry.identityPath, { recursive: true, force: true });
|
|
463
666
|
}
|
|
464
667
|
}
|
|
465
668
|
async stopEntryProcesses(entry) {
|
|
@@ -505,13 +708,37 @@ export class Muster {
|
|
|
505
708
|
throw new Error(`${result.reason}: ${result.candidates.join(", ")}`);
|
|
506
709
|
return peers[named.indexOf(result.peer)];
|
|
507
710
|
}
|
|
508
|
-
async stop(id) {
|
|
711
|
+
async stop(id, requester = LOCAL) {
|
|
509
712
|
const entry = await this.find(id);
|
|
713
|
+
assertMayAct(entry, requester);
|
|
510
714
|
await this.stopEntry(entry);
|
|
511
715
|
await this.registry.update(entry.launchId, { status: "stopped" });
|
|
512
716
|
await this.log.write({ event: "stopped", launch_id: entry.launchId });
|
|
513
717
|
return { stopped: true, id: entry.id };
|
|
514
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* Where to look for the agent's own session registry. Two different uses of an
|
|
721
|
+
* environment meet here, and conflating them is the bug this exists to avoid:
|
|
722
|
+
*
|
|
723
|
+
* - *Spawning* Muster's own helpers — the version probes, `assertClaudePolicy`
|
|
724
|
+
* and the Codex RPC connection — keeps `hostEnv`. They need a working PATH
|
|
725
|
+
* and they are Muster's machinery, not the agent's.
|
|
726
|
+
* - *Locating* what the agent wrote must follow the agent's configuration
|
|
727
|
+
* directory. A launch under an identity relocates that directory to the
|
|
728
|
+
* per-launch copy, so the session record is inside the copy; resolving
|
|
729
|
+
* against `hostEnv` would search the operator's own directory, find nothing,
|
|
730
|
+
* and fail the whole launch deadline with a useless diagnostic.
|
|
731
|
+
*
|
|
732
|
+
* The identity's token is deliberately absent: the resolvers read paths.
|
|
733
|
+
* OpenCode needs no entry — its resolver uses the per-launch loopback HTTP
|
|
734
|
+
* endpoint rather than a configuration path.
|
|
735
|
+
*/
|
|
736
|
+
resolutionEnv(runtime, configHome) {
|
|
737
|
+
const key = configHomeVar(runtime);
|
|
738
|
+
if (!configHome || !key)
|
|
739
|
+
return this.hostEnv;
|
|
740
|
+
return { ...this.hostEnv, [key]: configHome };
|
|
741
|
+
}
|
|
515
742
|
async refreshed(entry) {
|
|
516
743
|
if (entry.kind !== "session" ||
|
|
517
744
|
entry.status !== "running" ||
|
|
@@ -523,7 +750,11 @@ export class Muster {
|
|
|
523
750
|
try {
|
|
524
751
|
let metadata;
|
|
525
752
|
if (entry.runtime === "codex") {
|
|
526
|
-
|
|
753
|
+
// Derived from the entry for the same reason as the claude branch
|
|
754
|
+
// below: this reads the session's own thread, and a session launched
|
|
755
|
+
// under an identity recorded it inside the copy. On `hostEnv` a healthy
|
|
756
|
+
// codex identity session reads as `unreachable` in every listing.
|
|
757
|
+
rpc = new CodexRpc(this.resolutionEnv("codex", entry.configHome ?? entry.identityPath), entry.cwd);
|
|
527
758
|
metadata = await codexReachable(rpc, entry.id, deadline);
|
|
528
759
|
}
|
|
529
760
|
else if (entry.runtime === "opencode") {
|
|
@@ -544,7 +775,12 @@ export class Muster {
|
|
|
544
775
|
throw new Error("session endpoint ownership changed during refresh");
|
|
545
776
|
}
|
|
546
777
|
else {
|
|
547
|
-
|
|
778
|
+
// Derived from the entry, not from a launch-time value: this runs long
|
|
779
|
+
// after the launch returned and has only the recorded entry in scope. A
|
|
780
|
+
// running session launched under an identity recorded itself inside the
|
|
781
|
+
// copy, so re-resolution must look there or `list` reports every such
|
|
782
|
+
// session as unreachable.
|
|
783
|
+
const identity = await resolveClaude(entry.root.pid, this.resolutionEnv(entry.runtime, entry.configHome ?? entry.identityPath), deadline);
|
|
548
784
|
if (!identity ||
|
|
549
785
|
identity.id !== entry.id ||
|
|
550
786
|
!(await claudeReachable(identity.socketPath, deadline)))
|
|
@@ -579,6 +815,12 @@ export class Muster {
|
|
|
579
815
|
await rpc?.close();
|
|
580
816
|
}
|
|
581
817
|
}
|
|
818
|
+
/**
|
|
819
|
+
* Unscoped by requester, unlike stop and output. Filtering the listing is a
|
|
820
|
+
* contract change and nothing can call this remotely yet; the decision belongs
|
|
821
|
+
* with the supported entry point (#44), which is also what would need to
|
|
822
|
+
* choose between hiding other requesters' entries and redacting them.
|
|
823
|
+
*/
|
|
582
824
|
async list(kind) {
|
|
583
825
|
const entries = await this.registry.all();
|
|
584
826
|
const result = [];
|
|
@@ -588,6 +830,8 @@ export class Muster {
|
|
|
588
830
|
const e = await this.refreshed(original);
|
|
589
831
|
if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
|
|
590
832
|
await rm(e.mcpConfigPath, { force: true });
|
|
833
|
+
if (e.identityPath && !["starting", "running"].includes(e.status))
|
|
834
|
+
await rm(e.identityPath, { recursive: true, force: true });
|
|
591
835
|
if (e.kind === "session")
|
|
592
836
|
result.push({
|
|
593
837
|
...e.peer,
|
|
@@ -595,6 +839,9 @@ export class Muster {
|
|
|
595
839
|
...(e.permissions
|
|
596
840
|
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
597
841
|
: {}),
|
|
842
|
+
// From the entry, not the peer: a listing must report the same fact a
|
|
843
|
+
// launch result does, and an entry predating the field has none.
|
|
844
|
+
...(e.enforcement ? { enforcement: e.enforcement } : {}),
|
|
598
845
|
id: e.id,
|
|
599
846
|
runtime: e.peer?.runtime ?? e.runtime,
|
|
600
847
|
state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
|
|
@@ -614,6 +861,7 @@ export class Muster {
|
|
|
614
861
|
...(e.permissions
|
|
615
862
|
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
616
863
|
: {}),
|
|
864
|
+
...(e.enforcement ? { enforcement: e.enforcement } : {}),
|
|
617
865
|
id: e.id,
|
|
618
866
|
runtime: e.runtime,
|
|
619
867
|
cwd: e.cwd,
|
|
@@ -626,8 +874,9 @@ export class Muster {
|
|
|
626
874
|
}
|
|
627
875
|
return result;
|
|
628
876
|
}
|
|
629
|
-
async output(id) {
|
|
877
|
+
async output(id, requester = LOCAL) {
|
|
630
878
|
const entry = await this.find(id);
|
|
879
|
+
assertMayAct(entry, requester);
|
|
631
880
|
if (entry.kind !== "task")
|
|
632
881
|
throw new Error("output is available only for task runs");
|
|
633
882
|
if (!entry.outputPath)
|