@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/LICENSE +1 -1
- package/README.md +110 -1048
- package/TINCAN_LICENSE +1 -1
- package/dist/agents.js +47 -0
- package/dist/claude-policy.js +25 -0
- package/dist/codex-policy.js +19 -1
- package/dist/enforcement.js +2 -1
- package/dist/guard.js +10 -19
- package/dist/help.js +4 -0
- package/dist/identity/workspace-key.js +59 -0
- package/dist/mcp-tools.js +5 -0
- package/dist/mcp.js +0 -44
- package/dist/muster.js +1 -0
- package/dist/options.js +63 -0
- package/dist/refresh/index.js +66 -0
- package/dist/run.js +53 -81
- package/dist/stop/opencode.js +32 -0
- package/package.json +1 -1
package/dist/run.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { stat, readFile,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
+
}
|