@brutalsystems/muster 0.7.12 → 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/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, 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
- env;
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, env, drivers, sourceEnv) {
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.env = env;
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 = "cli") {
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(req, this.config);
101
- const selectedMcp = selectMcp(req, this.config);
102
- const selectedPlugins = req.runtime === "opencode" ? selectPlugins(req, this.config) : [];
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(req);
228
+ runtimeArgs(effective);
106
229
  else
107
- argv = launchArgs(req, this.config);
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 policy = [];
237
+ let codexPolicy = [];
115
238
  if (req.runtime === "claude")
116
- await assertClaudePolicy(this.env);
239
+ await assertClaudePolicy(this.hostEnv);
117
240
  if (req.runtime === "opencode")
118
- await assertOpenCodeVersion(this.env, "1.18.31", Date.now() + 4000);
119
- const { entry: reservation, deduped } = await this.registry.reserve({ runtime: req.runtime, kind: req.kind, cwd: req.cwd, ...permissions }, this.config.max_concurrent, req.requestKey
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(req, permissions, selectedMcp.map((s) => s.name), selectedPlugins.map((p) => p.name)),
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
- const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, this.env, this.sourceEnv), req.cwd, deadline);
159
- let runtimeEnv = mcpEnvironment(prepared.servers, this.env, this.sourceEnv);
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(req, this.config, prepared.servers, bridgePath);
358
+ argv = launchArgs(effective, this.config, prepared.servers, bridgePath);
173
359
  if (req.runtime === "codex") {
174
- policy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
175
- argv.splice(1, 0, ...policy);
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(req, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
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(req, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
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,
@@ -218,14 +404,30 @@ export class Muster {
218
404
  host: driver.id,
219
405
  hostRef: launched.hostRef,
220
406
  };
221
- await this.registry.update(entry.launchId, entry);
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
+ });
222
416
  let diagnostic = req.runtime === "claude"
223
417
  ? "no Claude session registry found (check terminal for workspace trust or login prompts)"
224
418
  : req.runtime === "opencode"
225
419
  ? "no OpenCode session matched the launch endpoint, cwd, and creation window"
226
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.
227
429
  if (req.runtime === "codex")
228
- rpc = new CodexRpc(this.env, req.cwd);
430
+ rpc = new CodexRpc(resolveEnv, req.cwd);
229
431
  while (Date.now() < deadline) {
230
432
  if (deadline - Date.now() < 25)
231
433
  break;
@@ -247,7 +449,7 @@ export class Muster {
247
449
  let openingTerminal = false;
248
450
  try {
249
451
  const identity = req.runtime === "codex"
250
- ? await resolveCodex(root.pid, this.env, deadline, async (id) => {
452
+ ? await resolveCodex(root.pid, resolveEnv, deadline, async (id) => {
251
453
  try {
252
454
  const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
253
455
  return threadRejection(thread, id);
@@ -258,7 +460,7 @@ export class Muster {
258
460
  })
259
461
  : req.runtime === "opencode"
260
462
  ? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
261
- : await resolveClaude(root.pid, this.env, deadline);
463
+ : await resolveClaude(root.pid, resolveEnv, deadline);
262
464
  if (identity) {
263
465
  diagnostic =
264
466
  req.runtime === "codex"
@@ -291,6 +493,7 @@ export class Muster {
291
493
  const peer = {
292
494
  kind: "session",
293
495
  ...permissions,
496
+ enforcement,
294
497
  ...prepared.summary,
295
498
  name: named.display,
296
499
  canonical_id: named.canonicalId,
@@ -321,11 +524,7 @@ export class Muster {
321
524
  status: "running",
322
525
  peer,
323
526
  });
324
- await this.log.write({
325
- event: "ready",
326
- launch_id: entry.launchId,
327
- peer,
328
- });
527
+ await this.log.write({ event: "ready", launch_id: entry.launchId, peer }, policy.logMode);
329
528
  return peer;
330
529
  }
331
530
  }
@@ -352,7 +551,7 @@ export class Muster {
352
551
  error: message,
353
552
  });
354
553
  await this.log
355
- .write({ event: "failure", launch_id: entry.launchId, error: message })
554
+ .write({ event: "failure", launch_id: entry.launchId, error: message }, policy.logMode)
356
555
  .catch(() => { });
357
556
  throw e;
358
557
  }
@@ -389,7 +588,7 @@ export class Muster {
389
588
  throw new Error(`Request key ${key} has no recorded session`);
390
589
  return entry.peer;
391
590
  }
392
- async startTask(entry, argv, env) {
591
+ async startTask(entry, argv, env, logMode) {
393
592
  const dir = join(this.home, "tasks", entry.id);
394
593
  await mkdir(dir, { recursive: true, mode: 0o700 });
395
594
  const outputPath = join(dir, "output.txt"), exitPath = join(dir, "exit.json"), startPath = join(dir, "start"), specPath = join(dir, "launch.json");
@@ -429,11 +628,7 @@ export class Muster {
429
628
  await writeFile(startPath, "start", { mode: 0o600 });
430
629
  child.unref();
431
630
  const handle = taskHandle(entry, root.pid);
432
- await this.log.write({
433
- event: "started",
434
- launch_id: entry.launchId,
435
- run: handle,
436
- });
631
+ await this.log.write({ event: "started", launch_id: entry.launchId, run: handle }, logMode);
437
632
  return handle;
438
633
  }
439
634
  catch (e) {
@@ -460,6 +655,8 @@ export class Muster {
460
655
  finally {
461
656
  if (entry.mcpConfigPath)
462
657
  await rm(entry.mcpConfigPath, { force: true });
658
+ if (entry.identityPath)
659
+ await rm(entry.identityPath, { recursive: true, force: true });
463
660
  }
464
661
  }
465
662
  async stopEntryProcesses(entry) {
@@ -505,13 +702,37 @@ export class Muster {
505
702
  throw new Error(`${result.reason}: ${result.candidates.join(", ")}`);
506
703
  return peers[named.indexOf(result.peer)];
507
704
  }
508
- async stop(id) {
705
+ async stop(id, requester = LOCAL) {
509
706
  const entry = await this.find(id);
707
+ assertMayAct(entry, requester);
510
708
  await this.stopEntry(entry);
511
709
  await this.registry.update(entry.launchId, { status: "stopped" });
512
710
  await this.log.write({ event: "stopped", launch_id: entry.launchId });
513
711
  return { stopped: true, id: entry.id };
514
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
+ }
515
736
  async refreshed(entry) {
516
737
  if (entry.kind !== "session" ||
517
738
  entry.status !== "running" ||
@@ -523,7 +744,11 @@ export class Muster {
523
744
  try {
524
745
  let metadata;
525
746
  if (entry.runtime === "codex") {
526
- rpc = new CodexRpc(this.env, entry.cwd);
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);
527
752
  metadata = await codexReachable(rpc, entry.id, deadline);
528
753
  }
529
754
  else if (entry.runtime === "opencode") {
@@ -544,7 +769,12 @@ export class Muster {
544
769
  throw new Error("session endpoint ownership changed during refresh");
545
770
  }
546
771
  else {
547
- const identity = await resolveClaude(entry.root.pid, this.env, deadline);
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);
548
778
  if (!identity ||
549
779
  identity.id !== entry.id ||
550
780
  !(await claudeReachable(identity.socketPath, deadline)))
@@ -579,6 +809,12 @@ export class Muster {
579
809
  await rpc?.close();
580
810
  }
581
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
+ */
582
818
  async list(kind) {
583
819
  const entries = await this.registry.all();
584
820
  const result = [];
@@ -588,6 +824,8 @@ export class Muster {
588
824
  const e = await this.refreshed(original);
589
825
  if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
590
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 });
591
829
  if (e.kind === "session")
592
830
  result.push({
593
831
  ...e.peer,
@@ -595,6 +833,9 @@ export class Muster {
595
833
  ...(e.permissions
596
834
  ? { permissions: e.permissions, sandbox: e.sandbox }
597
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 } : {}),
598
839
  id: e.id,
599
840
  runtime: e.peer?.runtime ?? e.runtime,
600
841
  state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
@@ -614,6 +855,7 @@ export class Muster {
614
855
  ...(e.permissions
615
856
  ? { permissions: e.permissions, sandbox: e.sandbox }
616
857
  : {}),
858
+ ...(e.enforcement ? { enforcement: e.enforcement } : {}),
617
859
  id: e.id,
618
860
  runtime: e.runtime,
619
861
  cwd: e.cwd,
@@ -626,8 +868,9 @@ export class Muster {
626
868
  }
627
869
  return result;
628
870
  }
629
- async output(id) {
871
+ async output(id, requester = LOCAL) {
630
872
  const entry = await this.find(id);
873
+ assertMayAct(entry, requester);
631
874
  if (entry.kind !== "task")
632
875
  throw new Error("output is available only for task runs");
633
876
  if (!entry.outputPath)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.12",
3
+ "version": "0.7.14",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {