@brutalsystems/muster 0.11.1 → 1.0.1

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,4 +1,4 @@
1
- import { stat, readFile, realpath, writeFile, mkdir, rm, } from "node:fs/promises";
1
+ import { stat, readFile, 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";
@@ -10,10 +10,13 @@ import { LOCAL } from "./requester.js";
10
10
  import { assertEnforcementMeetsMinimum, assertLevelWithinCeiling, assertMayAct, resolveIdentityForRequest, resolveRequesterPolicy, RequesterPolicyRefusal, } from "./requester-policy.js";
11
11
  import { IdentityError, readIdentity, } from "./identity-store.js";
12
12
  import { resolveCredential } from "./identity-token.js";
13
- import { copyIdentity, identityEnv, setCodexWorkspaceTrust, setWorkspaceTrust, } from "./identity-copy.js";
13
+ import { copyIdentity, identityEnv } from "./identity-copy.js";
14
14
  import { Registry } from "./registry.js";
15
15
  import { LaunchLog } from "./log.js";
16
16
  import { hosts, selectHost } from "./hosts/index.js";
17
+ import { AGENTS } from "./agents.js";
18
+ import { assertOptionsSupported, screenWorkspace } from "./options.js";
19
+ import { workspaceTrustKey } from "./identity/workspace-key.js";
17
20
  import { processRef, isSame, stopTree, delay, descendants, ownedGroup, } from "./identity/processes.js";
18
21
  import { resolveCodex } from "./identity/codex.js";
19
22
  import { claudeConfigDir, claudeRegistryDiagnostic, claudeWorkspaceTrusted, resolveClaude, } from "./identity/claude.js";
@@ -58,12 +61,13 @@ function taskHandle(entry, pid) {
58
61
  * rather than a configuration path, so relocating its configuration moves
59
62
  * nothing Muster reads.
60
63
  */
64
+ /**
65
+ * `undefined` rather than the table's `null`, because every caller treats this
66
+ * as "there is no such variable" via `?? entry.identityPath` and an absent
67
+ * value is what that idiom expects.
68
+ */
61
69
  function configHomeVar(runtime) {
62
- if (runtime === "codex")
63
- return "CODEX_HOME";
64
- if (runtime === "claude")
65
- return "CLAUDE_CONFIG_DIR";
66
- return undefined;
70
+ return AGENTS[runtime].configHomeVar ?? undefined;
67
71
  }
68
72
  export class Muster {
69
73
  config;
@@ -147,7 +151,11 @@ export class Muster {
147
151
  // itself is not expressible remotely, and a remote launch gets the
148
152
  // receiver's configured `default_mcp` / `default_plugins`. Configuration
149
153
  // is the grant.
150
- for (const key of ["mcp", "plugin"])
154
+ // `options` joins them for the same reason, and one of its own:
155
+ // auto-approve-path would have this machine trust a directory a remote
156
+ // caller named, and trust is what lets that directory configure the
157
+ // session. Configuration is the grant.
158
+ for (const key of ["mcp", "plugin", "options"])
151
159
  if (raw[key] !== undefined)
152
160
  throw new RequesterPolicyRefusal("not-expressible-remotely", `a remote request may not select ${key}; this receiver's configured defaults apply`);
153
161
  }
@@ -248,6 +256,9 @@ export class Muster {
248
256
  if (req.open &&
249
257
  (req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
250
258
  throw new Error("--open requires a tmux session on macOS");
259
+ // Refused here, with the other checks that cost nothing to fail: an option
260
+ // the runtime cannot honour must not leave a registry entry behind.
261
+ assertOptionsSupported(req.runtime, req.options);
251
262
  const permissions = resolvePermissions(effective, this.config);
252
263
  const enforcement = enforcementGrade(req.runtime, permissions);
253
264
  assertEnforcementMeetsMinimum(req.runtime, enforcement, policy);
@@ -367,22 +378,13 @@ export class Muster {
367
378
  if (identityName && identityMeta) {
368
379
  // The launch id exists now, so the copy can be made and named after it.
369
380
  const copy = await copyIdentity(this.home, identityName, identityMeta, entry.launchId);
370
- // Written into the copy, never the live ~/.claude.json: a running
371
- // session rewrites that file continuously and Muster cannot coordinate.
372
- if (identityMeta.agent === "claude")
373
- // RESOLVED, not as written: Claude keys projects[] by what its own
374
- // getcwd(2) returns, which has every symlink resolved. On macOS a
375
- // launch into /tmp/... is really /private/tmp/..., so the un-resolved
376
- // key is one Claude never looks up — the trust dialog appears, the
377
- // session never registers, and the launch burns its whole deadline.
378
- // `assertAllowedWorkspace` already realpaths for containment.
379
- await setWorkspaceTrust(copy, await realpath(req.cwd));
380
- // Codex gates the same way behind a different file and a different key,
381
- // and a fresh copy trusts nothing — so every Codex identity launch sat
382
- // on "Do you trust the contents of this directory?" until the deadline.
383
- // Resolved for the same reason: Codex keys `projects` by its own cwd.
384
- else if (identityMeta.agent === "codex")
385
- await setCodexWorkspaceTrust(copy, await realpath(req.cwd));
381
+ // Keyed on the identity's agent, which `resolveIdentity` has already
382
+ // refused unless it equals req.runtime — so this is the same row either
383
+ // way, and reading it from the identity keeps the check and the use
384
+ // agreed. A fresh copy trusts nothing, so without this every identity
385
+ // launch sat on "Do you trust the contents of this directory?" until the
386
+ // deadline. `realpath` and the reason for it are documented on the slot.
387
+ await AGENTS[identityMeta.agent].trustWorkspace?.(copy, await workspaceTrustKey(req.cwd));
386
388
  runtimeEnv = {
387
389
  ...runtimeEnv,
388
390
  ...identityEnv(identityMeta, copy, this.sourceEnv, credential?.token),
@@ -403,6 +405,16 @@ export class Muster {
403
405
  // configuration home while resolution searched the host's — reserve, spawn,
404
406
  // dead at the deadline. The identity copy still wins where there is one,
405
407
  // because `identityEnv` is merged into `runtimeEnv` above.
408
+ // `--options auto-approve-path`: record the launch directory as trusted in
409
+ // the profile the agent will actually use, so the one-time dialog does not
410
+ // stop the launch. Screened first — a directory that ships hooks or MCP
411
+ // servers configures the session it is trusted by, and those run without
412
+ // asking, so it is named and refused rather than waved through.
413
+ if (req.options.includes("auto-approve-path")) {
414
+ const findings = await screenWorkspace(req.cwd);
415
+ if (findings.length)
416
+ throw new Error(`--options auto-approve-path refused: ${req.cwd} ships ${findings.join(" and ")}; trusting it would let that configure the session. Trust it yourself once, or launch without the option.`);
417
+ }
406
418
  const configVar = configHomeVar(req.runtime);
407
419
  const configHome = configVar
408
420
  ? (runtimeEnv[configVar] ?? entry.identityPath)
@@ -412,6 +424,11 @@ export class Muster {
412
424
  // before. `refreshed()` has only the entry in scope and must agree.
413
425
  if (configVar && configHome && configHome !== this.hostEnv[configVar])
414
426
  entry = await this.registry.update(entry.launchId, { configHome });
427
+ // Written before the agent starts and into the SAME dir the child will open.
428
+ // An identity launch has already had its copy trusted above, so this is a
429
+ // no-op there rather than a second write.
430
+ if (req.options.includes("auto-approve-path") && configHome)
431
+ await AGENTS[req.runtime].trustWorkspace?.(configHome, await workspaceTrustKey(req.cwd));
415
432
  const resolveEnv = this.resolutionEnv(req.runtime, configHome);
416
433
  let bridgePath;
417
434
  if (req.runtime === "claude" && prepared.servers.length) {
@@ -850,9 +867,6 @@ export class Muster {
850
867
  .catch(() => { });
851
868
  throw e;
852
869
  }
853
- finally {
854
- await rpc?.close();
855
- }
856
870
  }
857
871
  /**
858
872
  * The answer to a request we have already seen. Never a new launch: a repeat
@@ -934,17 +948,11 @@ export class Muster {
934
948
  }
935
949
  async stopEntry(entry) {
936
950
  try {
937
- const openCodeSessionId = entry.peer?.session_id;
938
- const abortDeadline = Date.now() + 1000;
939
- if (entry.runtime === "opencode" &&
940
- entry.root &&
941
- entry.server_url &&
942
- typeof openCodeSessionId === "string" &&
943
- (await isSame(entry.root)) &&
944
- (await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, abortDeadline)))
945
- await new OpenCodeHttp(entry.server_url)
946
- .abort(openCodeSessionId, abortDeadline)
947
- .catch(() => { });
951
+ // One second, and best effort: the kill below is what guarantees the
952
+ // stop, so an agent that will not answer must not delay it. Which agents
953
+ // have anything to say, and every precondition on saying it, lives on
954
+ // the row rather than here.
955
+ await AGENTS[entry.runtime].beforeStop?.(entry, Date.now() + 1000);
948
956
  await this.stopEntryProcesses(entry);
949
957
  }
950
958
  finally {
@@ -1040,47 +1048,11 @@ export class Muster {
1040
1048
  !entry.peer)
1041
1049
  return entry;
1042
1050
  const deadline = Date.now() + 1500;
1043
- let rpc;
1044
1051
  try {
1045
- let metadata;
1046
- if (entry.runtime === "codex") {
1047
- // Derived from the entry for the same reason as the claude branch
1048
- // below: this reads the session's own thread, and a session launched
1049
- // under an identity recorded it inside the copy. On `hostEnv` a healthy
1050
- // codex identity session reads as `unreachable` in every listing.
1051
- rpc = new CodexRpc(this.resolutionEnv("codex", entry.configHome ?? entry.identityPath), entry.cwd);
1052
- metadata = await codexReachable(rpc, entry.id, deadline);
1053
- }
1054
- else if (entry.runtime === "opencode") {
1055
- if (!entry.server_url)
1056
- throw new Error("session endpoint missing");
1057
- if (!(await isSame(entry.root)) ||
1058
- !(await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, deadline)))
1059
- throw new Error("session endpoint is not owned by the launch");
1060
- metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), {
1061
- id: entry.id,
1062
- pid: entry.root.pid,
1063
- rawName: null,
1064
- cwd: entry.cwd,
1065
- serverUrl: entry.server_url,
1066
- }, deadline);
1067
- if (!(await isSame(entry.root)) ||
1068
- !(await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, deadline)))
1069
- throw new Error("session endpoint ownership changed during refresh");
1070
- }
1071
- else {
1072
- // Derived from the entry, not from a launch-time value: this runs long
1073
- // after the launch returned and has only the recorded entry in scope. A
1074
- // running session launched under an identity recorded itself inside the
1075
- // copy, so re-resolution must look there or `list` reports every such
1076
- // session as unreachable.
1077
- const identity = await resolveClaude(entry.root.pid, this.resolutionEnv(entry.runtime, entry.configHome ?? entry.identityPath), deadline);
1078
- if (!identity ||
1079
- identity.id !== entry.id ||
1080
- !(await claudeReachable(identity.socketPath, deadline)))
1081
- throw new Error("session unreachable");
1082
- metadata = identity;
1083
- }
1052
+ // The agent's own configuration home, which an identity launch relocates
1053
+ // to a per-launch copy — composed here because only Muster knows where
1054
+ // that copy went, and handed to the row that knows what to do with it.
1055
+ const metadata = await AGENTS[entry.runtime].refreshMetadata(entry, this.resolutionEnv(entry.runtime, entry.configHome ?? entry.identityPath), deadline);
1084
1056
  const named = assignNames([
1085
1057
  {
1086
1058
  runtime: runtimeName(entry.runtime),
@@ -1101,13 +1073,13 @@ export class Muster {
1101
1073
  });
1102
1074
  }
1103
1075
  catch {
1076
+ // Any failure is "unreachable", including a connection this refresh never
1077
+ // managed to open. Each implementation owns whatever it opened, so there
1078
+ // is nothing left for this to close.
1104
1079
  return await this.registry.update(entry.launchId, {
1105
1080
  peer: { ...entry.peer, state: "unreachable" },
1106
1081
  });
1107
1082
  }
1108
- finally {
1109
- await rpc?.close();
1110
- }
1111
1083
  }
1112
1084
  /**
1113
1085
  * Unscoped by requester, unlike stop and output. Filtering the listing is a
@@ -0,0 +1,32 @@
1
+ import { OpenCodeHttp } from "../opencode-http.js";
2
+ import { openCodeEndpointOwnedBy } from "../identity/opencode.js";
3
+ import { isSame } from "../identity/processes.js";
4
+ /**
5
+ * Ask an OpenCode session to abort before anything is signalled.
6
+ *
7
+ * OpenCode is a server with work possibly in flight, so it gets told to stop
8
+ * rather than only being killed — Claude and Codex have nothing equivalent to
9
+ * say. Best effort by design: a failure here is not a reason to leave processes
10
+ * running, and the kill that follows is what actually guarantees the stop.
11
+ *
12
+ * Every precondition is a reason not to shout at a stranger. The endpoint must
13
+ * still be owned by the process Muster started, because a loopback port is a
14
+ * shared resource and a pid can be reused — without that check an abort could
15
+ * be sent to whatever now happens to be listening on that port.
16
+ *
17
+ * Lives outside `opencode-policy.ts` deliberately: that module imports
18
+ * `guard.ts`, which imports the agent table, so referencing it from a row would
19
+ * close a cycle and leave the slot undefined depending on evaluation order.
20
+ */
21
+ export async function abortOpenCodeSession(entry, deadline) {
22
+ const sessionId = entry.peer?.session_id;
23
+ if (!entry.root ||
24
+ !entry.server_url ||
25
+ typeof sessionId !== "string" ||
26
+ !(await isSame(entry.root)) ||
27
+ !(await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, deadline)))
28
+ return;
29
+ await new OpenCodeHttp(entry.server_url)
30
+ .abort(sessionId, deadline)
31
+ .catch(() => { });
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.11.1",
3
+ "version": "1.0.1",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {