@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/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
- 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,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
- 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
+ // 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(req, this.config, prepared.servers, bridgePath);
364
+ argv = launchArgs(effective, this.config, prepared.servers, bridgePath);
173
365
  if (req.runtime === "codex") {
174
- policy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
175
- argv.splice(1, 0, ...policy);
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(req, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
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(req, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
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
- await this.registry.update(entry.launchId, entry);
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(this.env, req.cwd);
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, this.env, deadline, async (id) => {
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, this.env, deadline);
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
- rpc = new CodexRpc(this.env, entry.cwd);
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
- const identity = await resolveClaude(entry.root.pid, this.env, deadline);
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.12",
3
+ "version": "0.7.15",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {