@brutalsystems/muster 0.8.0 → 0.8.2
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/README.md +29 -3
- package/dist/duration.js +4 -2
- package/dist/format.js +9 -7
- package/dist/guard.js +7 -4
- package/dist/hosts/tmux.js +19 -1
- package/dist/identity/claude.js +65 -2
- package/dist/reap.js +4 -1
- package/dist/run.js +30 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,9 +55,22 @@ driver is an unavailable v1 stub.
|
|
|
55
55
|
|
|
56
56
|
Before launching a Claude session, open Claude normally in the target directory
|
|
57
57
|
and complete its workspace-trust review yourself, then exit that setup session.
|
|
58
|
-
This is a one-time prerequisite for each directory Claude requires you to trust
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
This is a one-time prerequisite for each directory Claude requires you to trust,
|
|
59
|
+
and trust is **not inherited**: accepting `~/Source/arm` says nothing about
|
|
60
|
+
`~/Source/arm/centrumx`, which still stops at its own dialog. Muster never
|
|
61
|
+
accepts trust prompts or changes trust settings. An untrusted directory blocks
|
|
62
|
+
startup; Muster times out and cleans up that launch.
|
|
63
|
+
|
|
64
|
+
A session sitting on that dialog has no registry record at all, so the timeout
|
|
65
|
+
used to look identical to a hung or crashed launch. Muster now reads
|
|
66
|
+
`hasTrustDialogAccepted` for the working directory out of the profile's
|
|
67
|
+
`.claude.json` when a Claude launch times out with nothing registered, and
|
|
68
|
+
either names the untrusted directory and the one-time fix or rules trust out
|
|
69
|
+
and points at the remaining consent screens — a login prompt, or the
|
|
70
|
+
confirmation Claude asks for the first time a profile uses bypass-permissions
|
|
71
|
+
mode. That read happens only on the failure path and only ever reads. A
|
|
72
|
+
`--kind task` launch into the same directory is the cheap discriminator by
|
|
73
|
+
hand: it runs headless and never sees the dialog.
|
|
61
74
|
|
|
62
75
|
```sh
|
|
63
76
|
npm ci
|
|
@@ -405,6 +418,19 @@ arrive after startup; this v1 is not an enterprise policy-enforcement layer.
|
|
|
405
418
|
Claude permissions and its command sandbox are different mechanisms; neither
|
|
406
419
|
claim implies that every external tool is OS-sandboxed.
|
|
407
420
|
|
|
421
|
+
**`workspace-write` does not fence Claude's Write tool, only its Bash.** This
|
|
422
|
+
is measured, not inferred — see "Write/Bash fence asymmetry" in
|
|
423
|
+
[PROBE_RESULTS.md](./PROBE_RESULTS.md). At `--level work` an agent's Bash write
|
|
424
|
+
outside the working tree is refused with `Operation not permitted`, while the
|
|
425
|
+
Write tool creates the same path successfully. The practical consequence is
|
|
426
|
+
that **an agent can create files outside the tree that it cannot then delete**,
|
|
427
|
+
because creation goes through Write and cleanup goes through Bash. No setting
|
|
428
|
+
Muster writes expresses the missing fence: the alternative, `deny`, blocks the
|
|
429
|
+
Write tool everywhere rather than by path, including inside the tree. Treat
|
|
430
|
+
`work` as "Bash is confined to the workspace", not "writes are". If that
|
|
431
|
+
distinction matters for a given launch, the containment that does hold for both
|
|
432
|
+
is `read`.
|
|
433
|
+
|
|
408
434
|
OpenCode keeps normal provider/model configuration and launches with `--pure`
|
|
409
435
|
unless plugins are selected, which disables inherited external plugins.
|
|
410
436
|
Providers declared under `[opencode.provider]` and a model named by
|
package/dist/duration.js
CHANGED
|
@@ -11,8 +11,10 @@ export function parseDuration(value, label) {
|
|
|
11
11
|
const raw = value.trim();
|
|
12
12
|
if (raw.toLowerCase() === "off")
|
|
13
13
|
return "off";
|
|
14
|
-
const match = /^(\d+)([smh])
|
|
15
|
-
const seconds = match
|
|
14
|
+
const match = /^(\d+)([smh])$/i.exec(raw);
|
|
15
|
+
const seconds = match
|
|
16
|
+
? Number(match[1]) * UNITS[match[2].toLowerCase()]
|
|
17
|
+
: 0;
|
|
16
18
|
if (!match || seconds <= 0)
|
|
17
19
|
throw new Error(`${label} must be a duration like 90s, 30m, 4h, or off (got ${JSON.stringify(value)})`);
|
|
18
20
|
return seconds;
|
package/dist/format.js
CHANGED
|
@@ -15,6 +15,15 @@ function argument(value) {
|
|
|
15
15
|
export function humanColorEnabled(isTTY, env) {
|
|
16
16
|
return isTTY === true && env.NO_COLOR === undefined && env.TERM !== "dumb";
|
|
17
17
|
}
|
|
18
|
+
/** Rendered from seconds rather than rounded to minutes: parseDuration accepts
|
|
19
|
+
* 45s, and "1m" for a 45s limit misreports it. */
|
|
20
|
+
function duration(seconds) {
|
|
21
|
+
return seconds % 3600 === 0
|
|
22
|
+
? `${seconds / 3600}h`
|
|
23
|
+
: seconds % 60 === 0
|
|
24
|
+
? `${seconds / 60}m`
|
|
25
|
+
: `${seconds}s`;
|
|
26
|
+
}
|
|
18
27
|
function recordHuman(record, color) {
|
|
19
28
|
const paint = (value, code) => color ? `\u001b[${code}m${value}\u001b[0m` : value;
|
|
20
29
|
const field = (label, value) => ` ${paint(label + ":", 90)} ${value}`;
|
|
@@ -54,13 +63,6 @@ function recordHuman(record, color) {
|
|
|
54
63
|
: record.model_source === "config"
|
|
55
64
|
? " (configured default)"
|
|
56
65
|
: ""}`));
|
|
57
|
-
// Rendered from seconds rather than rounded to minutes: parseDuration accepts
|
|
58
|
-
// 45s, and "1m" for a 45s limit misreports it.
|
|
59
|
-
const duration = (seconds) => seconds % 3600 === 0
|
|
60
|
-
? `${seconds / 3600}h`
|
|
61
|
-
: seconds % 60 === 0
|
|
62
|
-
? `${seconds / 60}m`
|
|
63
|
-
: `${seconds}s`;
|
|
64
66
|
if (record.idle_timeout)
|
|
65
67
|
lines.push(field("Idle timeout", duration(record.idle_timeout)));
|
|
66
68
|
if (record.ttl)
|
package/dist/guard.js
CHANGED
|
@@ -265,17 +265,20 @@ export const DEFAULT_IDLE_TIMEOUT = 1800;
|
|
|
265
265
|
* under `host = "pty"` in config and then never arm it.
|
|
266
266
|
*/
|
|
267
267
|
export function resolveLifecycle(req, config, reapable) {
|
|
268
|
-
const one = (flag, configured, label, fallback) => {
|
|
268
|
+
const one = (flag, configured, label, configLabel, fallback) => {
|
|
269
269
|
// `off` asks for nothing, so it is accepted even where a lifecycle cannot
|
|
270
270
|
// be honoured: a wrapper that always passes it defensively must still be
|
|
271
271
|
// able to launch a pty session.
|
|
272
272
|
if (flag !== undefined && !reapable && flag.trim().toLowerCase() !== "off")
|
|
273
273
|
throw new Error(`${label} applies to tmux sessions, which outlive the process that ` +
|
|
274
274
|
`launched them; this launch is ${req.kind === "task" ? "a task" : `hosted on ${req.host ?? "the configured host"}`}`);
|
|
275
|
+
// Each source names ITSELF in the error: a bad config value reported as
|
|
276
|
+
// "--idle-timeout must be ..." sends the reader looking for a flag they
|
|
277
|
+
// never typed.
|
|
275
278
|
const chosen = flag !== undefined
|
|
276
279
|
? parseDuration(flag, label)
|
|
277
280
|
: configured !== undefined
|
|
278
|
-
? parseDuration(configured,
|
|
281
|
+
? parseDuration(configured, configLabel)
|
|
279
282
|
: undefined;
|
|
280
283
|
if (!reapable)
|
|
281
284
|
return null;
|
|
@@ -284,8 +287,8 @@ export function resolveLifecycle(req, config, reapable) {
|
|
|
284
287
|
return chosen === "off" ? null : chosen;
|
|
285
288
|
};
|
|
286
289
|
return {
|
|
287
|
-
idleTimeout: one(req.idleTimeout, config.session.idle_timeout, "--idle-timeout", DEFAULT_IDLE_TIMEOUT),
|
|
288
|
-
ttl: one(req.ttl, config.session.ttl, "--ttl", null),
|
|
290
|
+
idleTimeout: one(req.idleTimeout, config.session.idle_timeout, "--idle-timeout", "[session] idle_timeout", DEFAULT_IDLE_TIMEOUT),
|
|
291
|
+
ttl: one(req.ttl, config.session.ttl, "--ttl", "[session] ttl", null),
|
|
289
292
|
};
|
|
290
293
|
}
|
|
291
294
|
export function runtimeArgs(req) {
|
package/dist/hosts/tmux.js
CHANGED
|
@@ -57,6 +57,19 @@ export function parseWindowList(output) {
|
|
|
57
57
|
label: label.join(FIELD),
|
|
58
58
|
}));
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* One shell command line for `run-shell`.
|
|
62
|
+
*
|
|
63
|
+
* Two escapes, not one. POSIX single-quoting keeps the shell from splitting an
|
|
64
|
+
* argument, and `#` is doubled because tmux expands the command as a FORMAT
|
|
65
|
+
* before any shell sees it: an unescaped `#{pane_pid}` inside a path is
|
|
66
|
+
* silently replaced, and quoting cannot prevent that.
|
|
67
|
+
*/
|
|
68
|
+
export function tmuxCommand(argv) {
|
|
69
|
+
return argv
|
|
70
|
+
.map((a) => `'${a.replaceAll("'", `'\\''`).replaceAll("#", "##")}'`)
|
|
71
|
+
.join(" ");
|
|
72
|
+
}
|
|
60
73
|
export class TmuxHost {
|
|
61
74
|
server;
|
|
62
75
|
id = "tmux";
|
|
@@ -86,6 +99,11 @@ export class TmuxHost {
|
|
|
86
99
|
capabilities() {
|
|
87
100
|
return { watchable: true, attachable: true, survivesParentExit: true };
|
|
88
101
|
}
|
|
102
|
+
/** The tmux server this host talks to, so a caller needing it in an armed
|
|
103
|
+
* command does not reach in with a structural cast. */
|
|
104
|
+
socketName() {
|
|
105
|
+
return this.server;
|
|
106
|
+
}
|
|
89
107
|
/** Null when the window is gone — the caller stops rather than guesses. */
|
|
90
108
|
async sessionState(hostRef) {
|
|
91
109
|
try {
|
|
@@ -113,7 +131,7 @@ export class TmuxHost {
|
|
|
113
131
|
"-b",
|
|
114
132
|
"-d",
|
|
115
133
|
String(Math.max(1, Math.round(seconds))),
|
|
116
|
-
argv
|
|
134
|
+
tmuxCommand(argv),
|
|
117
135
|
]);
|
|
118
136
|
}
|
|
119
137
|
async launch(opts) {
|
package/dist/identity/claude.js
CHANGED
|
@@ -13,10 +13,73 @@ import { descendants } from "./processes.js";
|
|
|
13
13
|
* reporting anything useful.
|
|
14
14
|
*/
|
|
15
15
|
export function claudeSessionsDir(env) {
|
|
16
|
+
return join(claudeConfigDir(env), "sessions");
|
|
17
|
+
}
|
|
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. */
|
|
22
|
+
export function claudeConfigDir(env) {
|
|
16
23
|
const configured = env.CLAUDE_CONFIG_DIR;
|
|
17
24
|
if (typeof configured === "string" && configured.length)
|
|
18
|
-
return
|
|
19
|
-
return join(env.HOME ?? homedir(), ".claude"
|
|
25
|
+
return configured;
|
|
26
|
+
return join(env.HOME ?? homedir(), ".claude");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
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.
|
|
33
|
+
*
|
|
34
|
+
* This only ever READS. Muster does not accept the dialog on the operator's
|
|
35
|
+
* behalf — `setWorkspaceTrust` writes that flag into an identity copy Muster
|
|
36
|
+
* itself created, never into the live `~/.claude.json`, and this must not
|
|
37
|
+
* become a way around that.
|
|
38
|
+
*
|
|
39
|
+
* Every failure answers `false`, because the only caller is a diagnostic on a
|
|
40
|
+
* launch that has already failed. A missing, unreadable or malformed file
|
|
41
|
+
* means Muster cannot say trust was accepted, which is exactly what `false`
|
|
42
|
+
* reports here; throwing would replace a useful message with a worse one.
|
|
43
|
+
*/
|
|
44
|
+
export async function claudeWorkspaceTrusted(env, cwd) {
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = await readFile(join(claudeConfigDir(env), ".claude.json"), "utf8");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const data = JSON.parse(raw);
|
|
54
|
+
return data.projects?.[cwd]?.hasTrustDialogAccepted === true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The message a Claude launch dies with when no session ever registered.
|
|
62
|
+
*
|
|
63
|
+
* A session sitting on a consent screen has no registry record at all, so the
|
|
64
|
+
* timeout looks identical to a hung or crashed launch. Several unrelated
|
|
65
|
+
* screens produce it — workspace trust, login, the first use of
|
|
66
|
+
* bypass-permissions mode — and the old message guessed at two of them
|
|
67
|
+
* together. Trust is the one Muster can actually check, so it is either named
|
|
68
|
+
* or ruled out; the rest are listed only once it has been ruled out.
|
|
69
|
+
*
|
|
70
|
+
* A `--kind task` launch into the same directory is the cheap discriminator:
|
|
71
|
+
* it runs headless and never sees the dialog, so it succeeds where a session
|
|
72
|
+
* hangs.
|
|
73
|
+
*/
|
|
74
|
+
export function claudeRegistryDiagnostic(trusted, cwd, configDir) {
|
|
75
|
+
if (!trusted)
|
|
76
|
+
return (`no Claude session registry found: workspace trust has not been ` +
|
|
77
|
+
`accepted for ${cwd} in ${configDir} — accept it once with ` +
|
|
78
|
+
`\`cd ${cwd} && claude\`, then retry (trust is per-directory and is ` +
|
|
79
|
+
`not inherited from a parent directory)`);
|
|
80
|
+
return (`no Claude session registry found (${cwd} is trusted, so check the ` +
|
|
81
|
+
`terminal for a login prompt, or for the one-time confirmation Claude ` +
|
|
82
|
+
`asks for the first time a profile uses bypass-permissions mode)`);
|
|
20
83
|
}
|
|
21
84
|
export async function resolveClaude(root, env, deadline) {
|
|
22
85
|
const dir = claudeSessionsDir(env);
|
package/dist/reap.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Registry } from "./registry.js";
|
|
2
|
-
|
|
2
|
+
// The floor is low so a short limit is honoured rather than rounded up: a 45s
|
|
3
|
+
// ttl behind a 30s floor fires at up to 75s. A session with a limit that short
|
|
4
|
+
// does not live long enough for the extra polls to matter.
|
|
5
|
+
const MIN_POLL = 5, MAX_POLL = 300;
|
|
3
6
|
/**
|
|
4
7
|
* Derived from the SHORTER active expiry: a 4h idle timeout paired with a 2m
|
|
5
8
|
* ttl must not poll on the 4h scale, or the ttl overshoots by minutes.
|
package/dist/run.js
CHANGED
|
@@ -16,7 +16,7 @@ import { LaunchLog } from "./log.js";
|
|
|
16
16
|
import { hosts, selectHost } from "./hosts/index.js";
|
|
17
17
|
import { processRef, isSame, stopTree, delay, descendants, ownedGroup, } from "./identity/processes.js";
|
|
18
18
|
import { resolveCodex } from "./identity/codex.js";
|
|
19
|
-
import { resolveClaude } from "./identity/claude.js";
|
|
19
|
+
import { claudeConfigDir, claudeRegistryDiagnostic, claudeWorkspaceTrusted, resolveClaude, } from "./identity/claude.js";
|
|
20
20
|
import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
|
|
21
21
|
import { codexReachable, threadRejection } from "./reach/codex.js";
|
|
22
22
|
import { claudeReachable } from "./reach/claude.js";
|
|
@@ -451,8 +451,8 @@ export class Muster {
|
|
|
451
451
|
// armed before the window is recorded would poll a target the registry
|
|
452
452
|
// cannot name. `schedule` is optional on the host interface, so pty and
|
|
453
453
|
// macos-terminal opt out by not implementing it.
|
|
454
|
-
if (driver?.schedule && (lifecycle.idleTimeout ?? lifecycle.ttl))
|
|
455
|
-
await driver
|
|
454
|
+
if (driver?.schedule && (lifecycle.idleTimeout ?? lifecycle.ttl)) {
|
|
455
|
+
const armed = await driver
|
|
456
456
|
.schedule(pollInterval(lifecycle), [
|
|
457
457
|
process.execPath,
|
|
458
458
|
// ../dist/muster.js, not ./muster.js: this resolves from src/run.ts
|
|
@@ -463,17 +463,33 @@ export class Muster {
|
|
|
463
463
|
"--home",
|
|
464
464
|
this.home,
|
|
465
465
|
"--socket",
|
|
466
|
-
driver.
|
|
466
|
+
driver.socketName?.() ?? "muster",
|
|
467
467
|
entry.launchId,
|
|
468
468
|
])
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
469
|
+
.then(() => true, () => false);
|
|
470
|
+
// A lifecycle that could not be armed must not fail the launch — the
|
|
471
|
+
// session is up and usable, and the orphan risk is what we had before.
|
|
472
|
+
// But it must not be REPORTED either: a record promising an expiry
|
|
473
|
+
// nothing will honour is worse than one promising none, because there
|
|
474
|
+
// is no way to tell from the outside.
|
|
475
|
+
if (!armed) {
|
|
476
|
+
lifecycle.idleTimeout = null;
|
|
477
|
+
lifecycle.ttl = null;
|
|
478
|
+
entry = await this.registry.update(entry.launchId, {
|
|
479
|
+
idleTimeout: null,
|
|
480
|
+
ttl: null,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
472
484
|
let diagnostic = req.runtime === "claude"
|
|
473
485
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
474
486
|
: req.runtime === "opencode"
|
|
475
487
|
? "no OpenCode session matched the launch endpoint, cwd, and creation window"
|
|
476
488
|
: "no descendant runtime identity found";
|
|
489
|
+
// Kept so the throw below can tell "nothing ever registered" from a
|
|
490
|
+
// diagnosis the loop actually earned. Only the first case can be about a
|
|
491
|
+
// consent screen; anything later means a session existed.
|
|
492
|
+
const neverRegistered = diagnostic;
|
|
477
493
|
// `resolveEnv`, not `hostEnv`: this connection is how the thread
|
|
478
494
|
// `resolveCodex` found gets READ — both its `rejectionOf` screen, where a
|
|
479
495
|
// non-null answer is fatal, and `codexReachable` at readiness. An
|
|
@@ -608,6 +624,13 @@ export class Muster {
|
|
|
608
624
|
}
|
|
609
625
|
await delay(Math.min(250, Math.max(0, deadline - Date.now())));
|
|
610
626
|
}
|
|
627
|
+
// Resolved here rather than before the launch: this reads the operator's
|
|
628
|
+
// live `.claude.json`, and a launch that is going to work should not pay
|
|
629
|
+
// for it. Failing closed is also wrong before a launch — `--kind task`
|
|
630
|
+
// runs headless and never sees the dialog, so refusing an untrusted
|
|
631
|
+
// directory up front would break task launches that work today.
|
|
632
|
+
if (req.runtime === "claude" && diagnostic === neverRegistered)
|
|
633
|
+
diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeConfigDir(resolveEnv));
|
|
611
634
|
throw new Error(`${diagnostic} after ${this.config.launch_timeout_sec}s`);
|
|
612
635
|
}
|
|
613
636
|
catch (e) {
|