@brutalsystems/muster 3.1.0 → 3.1.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.
@@ -1,10 +1,11 @@
1
1
  /** Registry-reading mechanics vendored from Tin Can src/claude/discover.ts at
2
2
  * fbaaea5842fd4a5c86849d7f51c8e16b8683b058 (MIT). Only captured descendants are
3
3
  * read, rather than scanning and diffing live sessions. */
4
- import { readFile, access } from "node:fs/promises";
5
- import { join } from "node:path";
4
+ import { readFile, access, realpath } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
6
  import { homedir } from "node:os";
7
- import { descendants } from "./processes.js";
7
+ import { command, descendants } from "./processes.js";
8
+ import { workspaceTrustKey } from "./workspace-key.js";
8
9
  /**
9
10
  * Where Claude Code records its sessions. `CLAUDE_CONFIG_DIR` relocates the whole
10
11
  * configuration directory, sessions included, so discovery must follow it —
@@ -15,10 +16,11 @@ import { descendants } from "./processes.js";
15
16
  export function claudeSessionsDir(env) {
16
17
  return join(claudeConfigDir(env), "sessions");
17
18
  }
18
- /** The configuration directory itself, which `claudeSessionsDir` and the
19
- * workspace-trust read below both hang off. Split out so the two cannot drift:
20
- * a trust decision read from a different directory than the one the launch
21
- * records its session in would describe a profile nobody is launching. */
19
+ /** The configuration directory itself, which `claudeSessionsDir` and
20
+ * `claudeTrustFile` both follow. Resolved from the same CLAUDE_CONFIG_DIR so
21
+ * the two cannot drift: a trust decision read from a different profile than
22
+ * the one the launch records its session in would describe a profile nobody
23
+ * is launching. */
22
24
  export function claudeConfigDir(env) {
23
25
  const configured = env.CLAUDE_CONFIG_DIR;
24
26
  if (typeof configured === "string" && configured.length)
@@ -26,10 +28,49 @@ export function claudeConfigDir(env) {
26
28
  return join(env.HOME ?? homedir(), ".claude");
27
29
  }
28
30
  /**
29
- * Whether Claude Code has recorded an accepted workspace-trust dialog for one
30
- * directory, in the profile a launch would use. Trust is per-directory and is
31
- * NOT inherited from a parent: a trusted `/Source/arm` says nothing about
32
- * `/Source/arm/centrumx`, and the child still stops at the dialog.
31
+ * The file Claude Code keeps workspace trust in. Not inside the configuration
32
+ * directory by default: with no CLAUDE_CONFIG_DIR it is `~/.claude.json`,
33
+ * beside `~/.claude`, and only a relocated profile keeps it inside.
34
+ */
35
+ export function claudeTrustFile(env) {
36
+ const configured = env.CLAUDE_CONFIG_DIR;
37
+ return typeof configured === "string" && configured.length
38
+ ? join(configured, ".claude.json")
39
+ : join(env.HOME ?? homedir(), ".claude.json");
40
+ }
41
+ /**
42
+ * Every key whose accepted dialog lets a launch in `cwd` past Claude's trust
43
+ * dialog. The dialog gate walks up from the launch directory and stops at the
44
+ * git repo's top level, or walks to `/` outside a repository — so a trusted
45
+ * parent counts inside its own repo and never across a repo boundary. The
46
+ * repo's own trust key (the main checkout, for a linked worktree) is what
47
+ * Muster writes, so it counts too. See `workspaceTrustKey`.
48
+ */
49
+ async function trustKeys(cwd) {
50
+ const resolved = await realpath(cwd).catch(() => cwd);
51
+ let top;
52
+ try {
53
+ const shown = (await command("git", ["-C", resolved, "rev-parse", "--show-toplevel"], 2000)).trim();
54
+ if (shown)
55
+ top = await realpath(shown).catch(() => shown);
56
+ }
57
+ catch { }
58
+ const keys = new Set([cwd]);
59
+ for (let dir = resolved;; dir = dirname(dir)) {
60
+ keys.add(dir);
61
+ if (dir === top || dirname(dir) === dir)
62
+ break;
63
+ }
64
+ keys.add(await workspaceTrustKey(resolved));
65
+ return [...keys];
66
+ }
67
+ /**
68
+ * Whether Claude Code would let a session in `cwd` past its workspace-trust
69
+ * dialog, in the profile a launch would use: an accepted dialog on the
70
+ * directory, on an ancestor inside the same git repo, or on the repo's trust
71
+ * key. Checking the exact directory alone called every worktree and every
72
+ * subdirectory of a trusted repo untrusted, and blamed trust for a launch that
73
+ * had simply timed out (#68).
33
74
  *
34
75
  * This only ever READS. Muster does not accept the dialog on the operator's
35
76
  * behalf — `setWorkspaceTrust` writes that flag into an identity copy Muster
@@ -44,18 +85,20 @@ export function claudeConfigDir(env) {
44
85
  export async function claudeWorkspaceTrusted(env, cwd) {
45
86
  let raw;
46
87
  try {
47
- raw = await readFile(join(claudeConfigDir(env), ".claude.json"), "utf8");
88
+ raw = await readFile(claudeTrustFile(env), "utf8");
48
89
  }
49
90
  catch {
50
91
  return false;
51
92
  }
93
+ let projects;
52
94
  try {
53
- const data = JSON.parse(raw);
54
- return data.projects?.[cwd]?.hasTrustDialogAccepted === true;
95
+ projects =
96
+ JSON.parse(raw).projects ?? {};
55
97
  }
56
98
  catch {
57
99
  return false;
58
100
  }
101
+ return (await trustKeys(cwd)).some((key) => projects[key]?.hasTrustDialogAccepted === true);
59
102
  }
60
103
  /**
61
104
  * The message a Claude launch dies with when no session ever registered.
@@ -74,12 +117,12 @@ export async function claudeWorkspaceTrusted(env, cwd) {
74
117
  * it runs headless and never sees the dialog, so it succeeds where a session
75
118
  * hangs.
76
119
  */
77
- export function claudeRegistryDiagnostic(trusted, cwd, configDir, opts = {}) {
120
+ export function claudeRegistryDiagnostic(trusted, cwd, trustFile, opts = {}) {
78
121
  if (!trusted)
79
122
  return (`no Claude session registry found: workspace trust has not been ` +
80
- `accepted for ${cwd} in ${configDir} — accept it once with ` +
81
- `\`cd ${cwd} && claude\`, then retry (trust is per-directory and is ` +
82
- `not inherited from a parent directory)`);
123
+ `accepted for ${cwd} in ${trustFile} — accept it once with ` +
124
+ `\`cd ${cwd} && claude\`, then retry (a trusted parent directory ` +
125
+ `counts only inside the same git repository)`);
83
126
  if (opts.bypassWarningPending)
84
127
  return (`no Claude session registry found (${cwd} is trusted for this profile): ` +
85
128
  `this launch runs in bypass permissions, and Claude stops on its ` +
@@ -1,4 +1,4 @@
1
- import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { appendFile, copyFile, mkdir, readFile, writeFile, } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import TOML from "@iarna/toml";
4
4
  import { identityPath } from "./identity-store.js";
@@ -127,6 +127,15 @@ export async function setWorkspaceTrust(copyPath, cwd) {
127
127
  data.projects = projects;
128
128
  await writeFile(file, JSON.stringify(data), { mode: 0o600 });
129
129
  }
130
+ function parses(text) {
131
+ try {
132
+ TOML.parse(text);
133
+ return true;
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ }
130
139
  /**
131
140
  * Set Codex's directory-trust flag for one directory, inside a copy Muster just
132
141
  * created. Codex has its own gate, unrelated to Claude's: without a
@@ -145,8 +154,10 @@ export async function setWorkspaceTrust(copyPath, cwd) {
145
154
  * parses and is accepted, and the prompt still appears: the check reads
146
155
  * persisted config, so the entry has to be in the file.
147
156
  *
148
- * The user's real `~/.codex/config.toml` is never touched, for the same reason
149
- * `setWorkspaceTrust` leaves the live `~/.claude.json` alone.
157
+ * Also called on the user's own `~/.codex/config.toml`, for an
158
+ * `--options auto-approve-path` launch with no CODEX_HOME — the file that
159
+ * launch's Codex reads. That is why a new entry is appended rather than the
160
+ * file re-serialised.
150
161
  */
151
162
  export async function setCodexWorkspaceTrust(copyPath, cwd) {
152
163
  const file = join(copyPath, "config.toml");
@@ -177,6 +188,22 @@ export async function setCodexWorkspaceTrust(copyPath, cwd) {
177
188
  const existing = (projects[cwd] ?? {});
178
189
  if (existing.trust_level === "trusted")
179
190
  return;
191
+ // No entry yet: append one table, exactly as Codex itself does when you
192
+ // answer its dialog. This file may be the user's own `~/.codex/config.toml`
193
+ // (a launch with no CODEX_HOME), and re-serialising it would drop every
194
+ // comment and reorder every key in it. Not when the result would not parse
195
+ // — `projects` written as an inline table, under any quoting of its key,
196
+ // which a second `[projects.…]` header would redefine.
197
+ if (!(cwd in projects)) {
198
+ const lead = raw && !raw.endsWith("\n") ? "\n" : "";
199
+ const entry = `${lead}\n[projects.${JSON.stringify(cwd)}]\ntrust_level = "trusted"\n`;
200
+ if (parses((raw ?? "") + entry)) {
201
+ // ~/.codex itself may not exist on a machine that has never run Codex.
202
+ await mkdir(copyPath, { recursive: true, mode: 0o700 });
203
+ await appendFile(file, entry, { mode: 0o600 });
204
+ return;
205
+ }
206
+ }
180
207
  projects[cwd] = { ...existing, trust_level: "trusted" };
181
208
  data.projects = projects;
182
209
  // Re-serialised rather than appended, so an entry the template already
package/dist/on-exit.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * Kept out of run.ts so the environment contract can be tested without a
4
4
  * launch, since that contract is what every caller's script depends on.
5
5
  */
6
- import { mkdir, open, stat } from "node:fs/promises";
6
+ import { constants } from "node:fs";
7
+ import { access, mkdir, open, stat } from "node:fs/promises";
7
8
  import { dirname } from "node:path";
8
9
  import { spawn } from "node:child_process";
9
10
  /**
@@ -29,23 +30,44 @@ export function onExitEnv(entry, record) {
29
30
  MUSTER_RECORD: JSON.stringify(record),
30
31
  };
31
32
  }
33
+ /**
34
+ * Why a directory cannot be a command's cwd, or undefined when it can: it must
35
+ * be a directory and searchable, because a spawn into one that is not fails
36
+ * after the claim is spent. Still a check, not a lock — the directory can
37
+ * change between here and the spawn, and that race is left to the spawn's own
38
+ * error, which the result records.
39
+ */
40
+ async function enterable(dir) {
41
+ try {
42
+ if (!(await stat(dir)).isDirectory())
43
+ return "not a directory";
44
+ await access(dir, constants.X_OK);
45
+ return undefined;
46
+ }
47
+ catch (e) {
48
+ return e.message;
49
+ }
50
+ }
32
51
  /**
33
52
  * Where the command will run and where its output will go, settled before the
34
53
  * command is claimed, because a claim is spent whether or not it then starts.
35
54
  * Neither failure is allowed to cost the signal: a directory removed since the
36
- * launch falls back to `fallbackCwd`, and a log that cannot be opened leaves
37
- * the output discarded. Each fallback is reported, for the launch log.
55
+ * launch, or one that can no longer be entered, falls back to `fallbackCwd`
56
+ * (and that to `/`), and a log that cannot be opened leaves the output
57
+ * discarded. Each fallback is reported, for the launch log.
38
58
  */
39
59
  export async function prepareOnExit(opts) {
40
60
  const warnings = [];
41
61
  let cwd = opts.cwd;
42
- try {
43
- if (!(await stat(cwd)).isDirectory())
44
- throw new Error("not a directory");
45
- }
46
- catch (e) {
47
- warnings.push(`cwd ${cwd} unusable (${e.message}); ran in ${opts.fallbackCwd}`);
62
+ const unusable = await enterable(cwd);
63
+ if (unusable) {
64
+ warnings.push(`cwd ${cwd} unusable (${unusable}); ran in ${opts.fallbackCwd}`);
48
65
  cwd = opts.fallbackCwd;
66
+ const fallback = await enterable(cwd);
67
+ if (fallback) {
68
+ warnings.push(`fallback ${cwd} unusable (${fallback}); ran in /`);
69
+ cwd = "/";
70
+ }
49
71
  }
50
72
  try {
51
73
  await mkdir(dirname(opts.logPath), { recursive: true, mode: 0o700 });
package/dist/run.js CHANGED
@@ -19,7 +19,7 @@ import { assertOptionsSupported, screenWorkspace } from "./options.js";
19
19
  import { workspaceTrustKey } from "./identity/workspace-key.js";
20
20
  import { processRef, isSame, stopTree, delay, descendants, ownedGroup, groupAlive, } from "./identity/processes.js";
21
21
  import { resolveCodex } from "./identity/codex.js";
22
- import { claudeConfigDir, claudeRegistryDiagnostic, claudeWorkspaceTrusted, resolveClaude, } from "./identity/claude.js";
22
+ import { claudeTrustFile, claudeRegistryDiagnostic, claudeWorkspaceTrusted, resolveClaude, } from "./identity/claude.js";
23
23
  import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
24
24
  import { codexReachable, threadRejection } from "./reach/codex.js";
25
25
  import { claudeReachable } from "./reach/claude.js";
@@ -133,6 +133,20 @@ export function failedLaunchId(e) {
133
133
  ? e[FAILED_LAUNCH]
134
134
  : undefined;
135
135
  }
136
+ /**
137
+ * Where an agent keeps its trust records when no configuration home is named,
138
+ * from the HOME it is spawned with. Codex only: its default `config.toml` is
139
+ * written by Codex just when you change a setting, and its trust entry is
140
+ * appended without rewriting the file. Claude's default is `~/.claude.json`,
141
+ * which every running session rewrites continuously, and a read-modify-write
142
+ * there could undo theirs — so a Claude launch with no CLAUDE_CONFIG_DIR still
143
+ * writes nothing, as before.
144
+ */
145
+ function defaultConfigHome(runtime, env) {
146
+ return runtime === "codex"
147
+ ? join(env.HOME ?? homedir(), ".codex")
148
+ : undefined;
149
+ }
136
150
  function configHomeVar(runtime) {
137
151
  return AGENTS[runtime].configHomeVar ?? undefined;
138
152
  }
@@ -550,8 +564,17 @@ export class Muster {
550
564
  // Written before the agent starts and into the SAME dir the child will open.
551
565
  // An identity launch has already had its copy trusted above, so this is a
552
566
  // no-op there rather than a second write.
553
- if (req.options.includes("auto-approve-path") && configHome)
554
- await AGENTS[req.runtime].trustWorkspace?.(configHome, await workspaceTrustKey(req.cwd));
567
+ //
568
+ // With no configuration home named, the agent reads its default one, and
569
+ // that is where the record has to go. Skipping it — as this did — made the
570
+ // option a silent no-op for every Codex launch without CODEX_HOME: the
571
+ // launch then sat on Codex's trust dialog and timed out with "no
572
+ // descendant runtime identity found".
573
+ if (req.options.includes("auto-approve-path")) {
574
+ const trustHome = configHome ?? defaultConfigHome(req.runtime, runtimeEnv);
575
+ if (trustHome)
576
+ await AGENTS[req.runtime].trustWorkspace?.(trustHome, await workspaceTrustKey(req.cwd));
577
+ }
555
578
  const resolveEnv = this.resolutionEnv(req.runtime, configHome);
556
579
  let bridgePath;
557
580
  if (req.runtime === "claude" && prepared.servers.length) {
@@ -874,7 +897,7 @@ export class Muster {
874
897
  // runs headless and never sees the dialog, so refusing an untrusted
875
898
  // directory up front would break task launches that work today.
876
899
  if (req.runtime === "claude" && diagnostic === neverRegistered)
877
- diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeConfigDir(resolveEnv), {
900
+ diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeTrustFile(resolveEnv), {
878
901
  bypassWarningPending: req.kind === "session" &&
879
902
  permissions.permissions === "bypass" &&
880
903
  !req.options.includes("accept-bypass-warning"),
@@ -1013,6 +1036,12 @@ export class Muster {
1013
1036
  .catch(() => { });
1014
1037
  throw e;
1015
1038
  }
1039
+ finally {
1040
+ // The readiness connection is a `codex app-server` child of THIS
1041
+ // process. Left open, its pipes keep the event loop alive, and the CLI
1042
+ // that launched a Codex session never exits after printing the record.
1043
+ await rpc?.close();
1044
+ }
1016
1045
  }
1017
1046
  /**
1018
1047
  * The answer to a request we have already seen. Never a new launch: a repeat
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {