@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.
- package/dist/identity/claude.js +61 -18
- package/dist/identity-copy.js +30 -3
- package/dist/on-exit.js +31 -9
- package/dist/run.js +33 -4
- package/package.json +1 -1
package/dist/identity/claude.js
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
* a trust decision read from a different
|
|
21
|
-
* records its session in would describe a profile nobody
|
|
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
|
-
*
|
|
30
|
-
* directory
|
|
31
|
-
*
|
|
32
|
-
|
|
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(
|
|
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
|
-
|
|
54
|
-
|
|
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,
|
|
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 ${
|
|
81
|
-
`\`cd ${cwd} && claude\`, then retry (
|
|
82
|
-
`
|
|
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 ` +
|
package/dist/identity-copy.js
CHANGED
|
@@ -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
|
-
*
|
|
149
|
-
* `
|
|
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 {
|
|
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
|
|
37
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 {
|
|
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
|
-
|
|
554
|
-
|
|
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,
|
|
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
|