@brutalsystems/muster 1.2.0 → 1.2.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/README.md CHANGED
@@ -76,14 +76,14 @@ muster stop <id>
76
76
  ```
77
77
 
78
78
  `--level` names a containment pair. `read` can look but not change; `work` can
79
- edit its working directory; `open` removes the sandbox and needs
80
- `allow_dangerous_flags` in config.
81
-
82
- | Level | The agent asks before acting | What it may touch |
83
- | ------ | ---------------------------- | ----------------- |
84
- | `read` | always | nothing on disk |
85
- | `work` | no | its cwd |
86
- | `open` | no | everything |
79
+ edit its working directory, except muster's own home (`~/.muster`); `open`
80
+ removes the sandbox and needs `allow_dangerous_flags` in config.
81
+
82
+ | Level | The agent asks before acting | What it may touch |
83
+ | ------ | ---------------------------- | --------------------------- |
84
+ | `read` | always | nothing on disk |
85
+ | `work` | no | its cwd, except `~/.muster` |
86
+ | `open` | no | everything |
87
87
 
88
88
  Claude asks to trust a directory the first time. That is a one-time review you
89
89
  do yourself — or `--options auto-approve-path`, which records it for you after
package/dist/guard.js CHANGED
@@ -2,7 +2,9 @@ import { z } from "zod";
2
2
  import { createHash } from "node:crypto";
3
3
  import { readFileSync, realpathSync } from "node:fs";
4
4
  import { realpath } from "node:fs/promises";
5
+ import { homedir } from "node:os";
5
6
  import { isAbsolute, join, relative, resolve } from "node:path";
7
+ import TOML from "@iarna/toml";
6
8
  import { claudeConfigDir } from "./identity/claude.js";
7
9
  import { mcpName } from "./mcp.js";
8
10
  import { claudeMcpConfig } from "./claude-policy.js";
@@ -11,6 +13,7 @@ import { AGENTS, MODEL_FLAGS } from "./agents.js";
11
13
  import { LAUNCH_OPTIONS } from "./options.js";
12
14
  import { RequesterConfigError } from "./requester-policy.js";
13
15
  import { parseDuration } from "./duration.js";
16
+ import { assertModelDeclared } from "./providers.js";
14
17
  export const runSchema = z
15
18
  .object({
16
19
  runtime: z.enum(["codex", "claude", "opencode"]),
@@ -134,6 +137,82 @@ export async function assertAllowedWorkspace(cwd, config) {
134
137
  throw new Error(`cwd is outside every configured allowed root: ${cwd}. ` +
135
138
  `Add a root or a project to config.toml, or launch somewhere already permitted.`);
136
139
  }
140
+ /**
141
+ * Where muster keeps its own state when a caller does not say: the same
142
+ * default `Muster.create` uses, so `launchArgs` called on its own protects the
143
+ * home a real launch would.
144
+ */
145
+ export function defaultMusterHome(env) {
146
+ return join(env.HOME ?? homedir(), ".muster");
147
+ }
148
+ /**
149
+ * True when a launch at this pair can write under its cwd: every
150
+ * workspace-write launch. Read-only writes nothing, and full-access (`open`)
151
+ * is unrestricted by definition and gated by `allow_dangerous_flags` instead.
152
+ */
153
+ function writesWorkspace(policy) {
154
+ return policy.sandbox === "workspace-write";
155
+ }
156
+ /**
157
+ * Refuses a launch that would hand a runtime write access to muster's own home
158
+ * when that runtime cannot be told to leave the home alone (#44).
159
+ *
160
+ * The home holds the machine's policy: `allow_dangerous_flags`, the requester
161
+ * grants, and the MCP server commands muster runs for every later launch. An
162
+ * agent that can write it can widen the policy that constrains it.
163
+ *
164
+ * Claude and Codex take a path deny in what muster generates for them, so they
165
+ * are protected there and never refused here. OpenCode's permissions are tool
166
+ * policy: an `edit` rule can name a path, but a shell command cannot be
167
+ * confined to one, and `bash` is what `work` grants it. So for OpenCode the
168
+ * one guarantee is a cwd that does not contain the home — the home is then an
169
+ * external directory, which `work` already denies. "Overlaps" means the cwd is
170
+ * the home, inside it, or an ancestor of it (such as `~`), compared as real
171
+ * paths so a symlink on either side cannot hide it.
172
+ */
173
+ export function assertMusterHomeProtectable(runtime, cwd, policy, home) {
174
+ if (runtime !== "opencode" || !writesWorkspace(policy))
175
+ return;
176
+ // `deny` with workspace-write already denies OpenCode's edit and shell
177
+ // tools, so it can write nothing anywhere.
178
+ if (policy.permissions === "deny")
179
+ return;
180
+ // Both spellings of each side. Real paths alone miss a home reached through
181
+ // a symlink: `~/.muster` linking elsewhere is not inside `~` once resolved,
182
+ // yet writing through `~/.muster` lands in it.
183
+ const cwds = writeRootSpellings(resolve(cwd));
184
+ const homes = writeRootSpellings(resolve(home));
185
+ if (cwds.some((c) => homes.some((h) => contains(h, c) || contains(c, h))))
186
+ throw new Error(`refusing an opencode ${policy.sandbox} launch in ${cwd}: it contains or ` +
187
+ `is inside muster's own home (${home}), which holds the config every ` +
188
+ `later launch obeys. OpenCode's permissions are tool policy and cannot ` +
189
+ `keep its shell out of one folder, so launch it in a folder that does ` +
190
+ `not contain muster's home, or at --level read.`);
191
+ }
192
+ /** Whether `inner` is `outer` or below it, by path segments, not characters. */
193
+ function contains(outer, inner) {
194
+ const rel = relative(outer, inner);
195
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
196
+ }
197
+ /**
198
+ * The Codex arguments that make muster's home read-only inside a writable
199
+ * workspace. A permissions profile is the only Codex config that can carve a
200
+ * read-only path out of a writable root, and a `--sandbox` flag overrides a
201
+ * profile, so a workspace-write launch selects this profile instead of passing
202
+ * `--sandbox workspace-write`. `:workspace` is the built-in profile that flag
203
+ * names, so everything else about the sandbox is the same.
204
+ */
205
+ function codexWorkspaceProfile(home) {
206
+ const filesystem = Object.fromEntries(writeRootSpellings(home).map((p) => [p, "read"]));
207
+ return [
208
+ "-c",
209
+ 'default_permissions="muster_work"',
210
+ "-c",
211
+ 'permissions.muster_work.extends=":workspace"',
212
+ "-c",
213
+ `permissions.muster_work.filesystem=${TOML.stringify.value(filesystem)}`,
214
+ ];
215
+ }
137
216
  /**
138
217
  * What a request key is bound to, so the same key arriving with different
139
218
  * parameters can be refused rather than silently answered with the wrong
@@ -222,6 +301,15 @@ function modelInArgs(args) {
222
301
  * by luck — which is the whole point of recording it at all.
223
302
  */
224
303
  export function resolveModel(req, config) {
304
+ const resolved = nameModel(req, config);
305
+ // Every route to a model — --model, args, [opencode] model — passes here,
306
+ // and this runs before anything is reserved, so an undeclared one leaves no
307
+ // record behind.
308
+ if (resolved !== null && req.runtime === "opencode")
309
+ assertModelDeclared(resolved.model, config);
310
+ return resolved;
311
+ }
312
+ function nameModel(req, config) {
225
313
  const inArgs = modelInArgs(req.args);
226
314
  if (req.model !== undefined && inArgs !== undefined)
227
315
  throw new Error(`Model named twice: --model ${req.model} and ${inArgs} in runtime options. Name it once.`);
@@ -422,7 +510,9 @@ export function resolveTerminal(req, config) {
422
510
  return req.terminal;
423
511
  return config.terminal === "auto" ? "terminal" : config.terminal;
424
512
  }
425
- export function launchArgs(req, config, mcp = [], bridgePath, env = process.env) {
513
+ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env,
514
+ /** Muster's own home, write-protected at workspace-write (#44). */
515
+ musterHome = defaultMusterHome(env)) {
426
516
  // Before `runtimeArgs`, so this function owns every model error on its own
427
517
  // rather than by its caller's discipline: `Muster.launch` happens to resolve
428
518
  // first, but `launchArgs` is exported and an input carrying both a model
@@ -444,20 +534,32 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
444
534
  : policy.permissions === "bypass"
445
535
  ? ["--dangerously-bypass-approvals-and-sandbox"]
446
536
  : [
447
- "--sandbox",
448
- policy.sandbox === "full-access"
449
- ? "danger-full-access"
450
- : policy.sandbox,
537
+ // Workspace-write is spelled as the profile below instead: the
538
+ // flag would override it.
539
+ ...(writesWorkspace(policy)
540
+ ? []
541
+ : [
542
+ "--sandbox",
543
+ policy.sandbox === "full-access"
544
+ ? "danger-full-access"
545
+ : policy.sandbox,
546
+ ]),
451
547
  ...(req.kind === "session"
452
548
  ? ["--ask-for-approval", "never"]
453
549
  : ["-c", 'approval_policy="never"']),
454
550
  ]),
551
+ ...(writesWorkspace(policy) ? codexWorkspaceProfile(musterHome) : []),
455
552
  ...model,
456
553
  ...extra,
457
554
  "--",
458
555
  req.prompt,
459
556
  ];
460
557
  const readonly = policy.sandbox === "read-only";
558
+ // Muster's own home, under both spellings, wherever the launch can write its
559
+ // cwd: the cwd write grant covers the home when the cwd contains it (#44).
560
+ const protectedHome = writesWorkspace(policy)
561
+ ? writeRootSpellings(musterHome)
562
+ : [];
461
563
  const mode = policy.permissions === "auto"
462
564
  ? "auto"
463
565
  : policy.permissions === "bypass"
@@ -486,6 +588,12 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
486
588
  allow: mcp.flatMap((s) => s.config.tools.map((t) => `mcp__${s.name}__${t}`)),
487
589
  deny: [
488
590
  ...(readonly ? ["Edit", "Write", "NotebookEdit"] : []),
591
+ // The in-process file tools run outside the OS sandbox, so the
592
+ // denyWrite below does not reach them. `//` is Claude's spelling of
593
+ // an absolute path, and an `Edit(path)` rule covers every
594
+ // file-editing tool: Claude 2.1.293 warns that `Write(path)` is never
595
+ // matched by its file permission checks.
596
+ ...protectedHome.map((dir) => `Edit(/${dir}/**)`),
489
597
  ...mcp.flatMap((s) => s.excludedTools.map((t) => `mcp__${s.name}__${t}`)),
490
598
  ],
491
599
  additionalDirectories: [],
@@ -503,7 +611,10 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
503
611
  // command exited 1 (#41) — and a deny beats an allow, so the temp dir
504
612
  // cannot be allowed back. Both spellings of a symlinked cwd, so this
505
613
  // does not rest on Claude normalising the path.
506
- denyWrite: readonly ? writeRootSpellings(req.cwd) : [],
614
+ // At workspace-write, muster's own home is denied instead (#44): it
615
+ // holds the policy every later launch obeys, and a cwd that contains
616
+ // it (such as `~`) would otherwise make it writable.
617
+ denyWrite: readonly ? writeRootSpellings(req.cwd) : protectedHome,
507
618
  // The agent's own memory lives under the config dir, outside the cwd
508
619
  // that Claude Code defaults the write root to, so Bash writes to it
509
620
  // failed with EPERM while the in-process file tools succeeded (#80).
package/dist/help.js CHANGED
@@ -53,7 +53,7 @@ export const COMMANDS = [
53
53
  ],
54
54
  [
55
55
  "--level LEVEL",
56
- "read, work, or open: a legal permissions and sandbox pair",
56
+ "read, work, or open: a legal permissions and sandbox pair; work writes the launch folder except muster's own home",
57
57
  ],
58
58
  ["--permissions MODE", "auto, deny, or bypass"],
59
59
  ["--sandbox MODE", "read-only, workspace-write, or full-access"],
@@ -82,6 +82,43 @@ export function titleOptions(session, title) {
82
82
  ...set("set-titles", "on"),
83
83
  ];
84
84
  }
85
+ /**
86
+ * What muster's server needs in place of a config file. It runs with `-f
87
+ * /dev/null` so a developer's ~/.tmux.conf cannot change how muster's sessions
88
+ * behave, which leaves tmux's own defaults: the scroll wheel never reaches the
89
+ * pane's history, the history is 2000 lines, and Shift+Enter arrives as plain
90
+ * Enter, so it submits Claude Code's prompt instead of starting a new line
91
+ * (#57).
92
+ *
93
+ * These go at the front of `new-session`'s command list, not after it:
94
+ * `history-limit` is read when a pane is created, so a value set once the
95
+ * window exists never reaches it. Every launch sets them again, which is what
96
+ * brings a server started by an older muster up to date.
97
+ *
98
+ * `extkeys` tells tmux the outer terminal understands extended keys, so it
99
+ * asks the terminal for them; without it `extended-keys` has nothing to pass
100
+ * on. It is set at a fixed index rather than appended (`-a`), so a long-lived
101
+ * server does not gain another copy with every launch. `xterm*` matches the
102
+ * TERM Ghostty, iTerm2 and Terminal.app report; a terminal that does not
103
+ * support the request ignores it.
104
+ *
105
+ * `-q` because these name options newer than some tmux still installed
106
+ * (`extended-keys-format` arrived in 3.5, `terminal-features` in 3.2), and an
107
+ * unknown option is an error that would abort the whole command list and with
108
+ * it the launch. An older tmux launches as before, without the option.
109
+ */
110
+ export const SERVER_OPTIONS = [
111
+ ["-g", "mouse", "on"],
112
+ ["-g", "history-limit", "50000"],
113
+ ["-s", "extended-keys", "always"],
114
+ ["-s", "extended-keys-format", "csi-u"],
115
+ ["-s", "terminal-features[90]", "xterm*:extkeys"],
116
+ ];
117
+ /** `SERVER_OPTIONS` as `;`-terminated `set-option` commands, to lead a
118
+ * command list. */
119
+ export function serverOptions() {
120
+ return SERVER_OPTIONS.flatMap((o) => ["set-option", "-q", ...o, ";"]);
121
+ }
85
122
  /**
86
123
  * One shell command line for `run-shell`.
87
124
  *
@@ -196,6 +233,7 @@ export class TmuxHost {
196
233
  // command list. Targeted by session name rather than relying on "current",
197
234
  // which is not meaningful for a detached session.
198
235
  const output = await this.call([
236
+ ...serverOptions(),
199
237
  "new-session",
200
238
  "-s",
201
239
  session,
@@ -29,7 +29,48 @@ export async function openCodeEndpointOwnedBy(rootPid, serverUrl, deadline) {
29
29
  return false;
30
30
  }
31
31
  }
32
- export async function resolveOpenCode(rootPid, serverUrl, cwd, createdAfter, deadline) {
32
+ /** The text of the session's first user message, or undefined before it has one. */
33
+ function firstUserText(messages) {
34
+ const first = messages.find((message) => message.info.role === "user");
35
+ if (!first)
36
+ return undefined;
37
+ return first.parts
38
+ .filter((part) => part.type === "text" && part.text !== undefined)
39
+ .map((part) => part.text)
40
+ .join("");
41
+ }
42
+ async function carriesPrompt(client, id, prompt, deadline) {
43
+ try {
44
+ const text = firstUserText(await client.messages(id, deadline));
45
+ return text !== undefined && text.trim() === prompt.trim();
46
+ }
47
+ catch (error) {
48
+ // One unreadable candidate — deleted, or another process's half-written
49
+ // session — is not a match, and must not stall discovery of the rest.
50
+ if (error instanceof OpenCodeProtocolError)
51
+ return false;
52
+ throw error;
53
+ }
54
+ }
55
+ /**
56
+ * Finds the session this launch's OpenCode created.
57
+ *
58
+ * `/session` reads the project database every OpenCode process on the machine
59
+ * shares, so directory and creation window alone can match a stranger's
60
+ * session. Two signals tie a candidate to this launch:
61
+ *
62
+ * - presence in `/session/status`, which is the endpoint process's in-memory
63
+ * map of its own non-idle sessions; or
64
+ * - for a candidate absent from that map, a first user message equal to the
65
+ * prompt this launch submitted.
66
+ *
67
+ * The second exists because OpenCode deletes a session from the status map the
68
+ * moment it goes idle (#2): a first turn that finishes before discovery left
69
+ * the session invisible, and the launch timed out. Without `prompt`, an absent
70
+ * candidate is never claimed. Every candidate that passes either signal counts
71
+ * toward the ambiguity check.
72
+ */
73
+ export async function resolveOpenCode(rootPid, serverUrl, cwd, createdAfter, deadline, prompt) {
33
74
  const resolvedCwd = await realpath(cwd);
34
75
  const client = new OpenCodeHttp(serverUrl);
35
76
  let ownershipProven = false;
@@ -55,13 +96,18 @@ export async function resolveOpenCode(rootPid, serverUrl, cwd, createdAfter, dea
55
96
  const statuses = candidates.length
56
97
  ? await client.statuses(deadline)
57
98
  : {};
99
+ const matches = [];
100
+ for (const session of candidates)
101
+ if (Object.prototype.hasOwnProperty.call(statuses, session.id) ||
102
+ (prompt !== undefined &&
103
+ (await carriesPrompt(client, session.id, prompt, deadline))))
104
+ matches.push(session);
58
105
  if (!(await processRef(rootPid)))
59
106
  return;
60
107
  if (!(await openCodeEndpointOwnedBy(rootPid, serverUrl, deadline))) {
61
108
  ownershipProven = false;
62
109
  continue;
63
110
  }
64
- const matches = candidates.filter((session) => Object.prototype.hasOwnProperty.call(statuses, session.id));
65
111
  if (matches.length > 1)
66
112
  throw new Error("Multiple OpenCode sessions matched the launch endpoint, cwd, and creation window");
67
113
  const match = matches[0];
package/dist/log.js CHANGED
@@ -38,6 +38,16 @@ export const METADATA_RECORD_KEEP = [
38
38
  "mcp_warnings",
39
39
  "plugins",
40
40
  ];
41
+ /**
42
+ * What an `error` becomes in metadata mode. An error is free text, and some of
43
+ * it is prompt-derived: a tmux error quotes the host's own output, and the
44
+ * window name is a slice of the prompt (#26). Free text cannot be allowlisted
45
+ * field by field the way a record is, so the only safe projection of the
46
+ * string is a constant. The key is kept rather than dropped so a metadata line
47
+ * still says a launch failed with an error, which an absent key would not;
48
+ * the text stays in the registry entry, which is not this log.
49
+ */
50
+ export const ERROR_WITHHELD = "withheld (metadata-only log)";
41
51
  /**
42
52
  * `requester` is the one nested object passed through whole: the spec keeps the
43
53
  * tuple and its label, and a label is supplied by the receiver's own config,
@@ -79,12 +89,14 @@ export class LaunchLog {
79
89
  ? data
80
90
  : Object.fromEntries(Object.entries(data)
81
91
  .filter(([k]) => !METADATA_DROP.includes(k))
82
- .map(([k, v]) => isRecord(v) && !PASS_THROUGH.includes(k)
83
- ? [
84
- k,
85
- Object.fromEntries(Object.entries(v).filter(([field]) => METADATA_RECORD_KEEP.includes(field))),
86
- ]
87
- : [k, v]));
92
+ .map(([k, v]) => k === "error" && v !== undefined
93
+ ? [k, ERROR_WITHHELD]
94
+ : isRecord(v) && !PASS_THROUGH.includes(k)
95
+ ? [
96
+ k,
97
+ Object.fromEntries(Object.entries(v).filter(([field]) => METADATA_RECORD_KEEP.includes(field))),
98
+ ]
99
+ : [k, v]));
88
100
  const file = await open(path, "a", 0o600);
89
101
  try {
90
102
  await file.writeFile(JSON.stringify({ at: new Date().toISOString(), ...body }) + "\n");
@@ -14,6 +14,12 @@ const sessionSchema = z.object({
14
14
  updated: z.number(),
15
15
  }),
16
16
  });
17
+ // Only what identity needs: who wrote each message and its text parts. Every
18
+ // other field, and every non-text part, passes through unread.
19
+ const messageSchema = z.object({
20
+ info: z.object({ role: z.string() }).passthrough(),
21
+ parts: z.array(z.object({ type: z.string(), text: z.string().optional() }).passthrough()),
22
+ });
17
23
  const statusSchema = z.discriminatedUnion("type", [
18
24
  z.object({ type: z.literal("idle") }),
19
25
  z.object({ type: z.literal("busy") }),
@@ -125,6 +131,9 @@ export class OpenCodeHttp {
125
131
  session(id, deadline) {
126
132
  return this.requestJson("GET", `/session/${encodeURIComponent(id)}`, deadline, sessionSchema);
127
133
  }
134
+ messages(id, deadline) {
135
+ return this.requestJson("GET", `/session/${encodeURIComponent(id)}/message`, deadline, z.array(messageSchema));
136
+ }
128
137
  statuses(deadline) {
129
138
  return this.requestJson("GET", "/session/status", deadline, z.record(statusSchema));
130
139
  }
package/dist/providers.js CHANGED
@@ -37,6 +37,30 @@ function models(definition) {
37
37
  ? Object.fromEntries(definition.models.map((id) => [id, {}]))
38
38
  : definition.models;
39
39
  }
40
+ /**
41
+ * Refuses an OpenCode model whose provider muster declares but whose id that
42
+ * provider does not list (#52). OpenCode answers that launch with a bare
43
+ * "Unexpected server error", and nothing in it points at the missing entry.
44
+ *
45
+ * Only providers muster itself declares are checked. A built-in provider
46
+ * (anthropic, openai) or one from the user's own OpenCode configuration never
47
+ * appears here, and is OpenCode's to resolve: refusing it would break launches
48
+ * that work today. A model with no slash is likewise left for OpenCode.
49
+ */
50
+ export function assertModelDeclared(model, config) {
51
+ const slash = model.indexOf("/");
52
+ if (slash <= 0)
53
+ return;
54
+ const provider = model.slice(0, slash);
55
+ const id = model.slice(slash + 1);
56
+ const definition = Object.hasOwn(config.opencode.provider, provider)
57
+ ? config.opencode.provider[provider]
58
+ : undefined;
59
+ if (definition === undefined || Object.hasOwn(models(definition), id))
60
+ return;
61
+ throw new Error(`model ${id} is not declared under [opencode.provider.${provider}.models]; ` +
62
+ `add [opencode.provider.${provider}.models.${JSON.stringify(id)}] to ~/.muster/config.toml`);
63
+ }
40
64
  function apiKey(name, definition, source) {
41
65
  if (definition.api_key !== undefined)
42
66
  return definition.api_key;
package/dist/run.js CHANGED
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawn } from "node:child_process";
6
6
  import { loadConfig } from "./config.js";
7
- import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, resolveLifecycle, resolveTitle, sanitiseTitle, resolveModel, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, resolveTerminal, runtimeArgs, } from "./guard.js";
7
+ import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, resolveLifecycle, resolveTitle, sanitiseTitle, resolveModel, assertMusterHomeProtectable, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, resolveTerminal, runtimeArgs, } from "./guard.js";
8
8
  import { enforcementGrade } from "./enforcement.js";
9
9
  import { LOCAL } from "./requester.js";
10
10
  import { assertEnforcementMeetsMinimum, assertLevelWithinCeiling, assertMayAct, resolveIdentityForRequest, resolveRequesterPolicy, RequesterPolicyRefusal, } from "./requester-policy.js";
@@ -264,6 +264,10 @@ export class Muster {
264
264
  // Refused here, with the other checks that cost nothing to fail: an option
265
265
  // the runtime cannot honour must not leave a registry entry behind.
266
266
  assertOptionsSupported(req.runtime, req.options, permissions);
267
+ // A runtime that cannot be told to leave muster's home alone is not
268
+ // handed a cwd that contains it (#44). Refused here, before anything is
269
+ // reserved, like the other checks that cost nothing to fail.
270
+ assertMusterHomeProtectable(req.runtime, req.cwd, permissions, this.home);
267
271
  const enforcement = enforcementGrade(req.runtime, permissions);
268
272
  assertEnforcementMeetsMinimum(req.runtime, enforcement, policy);
269
273
  // Composed here, before any process of any kind is started — the version
@@ -281,7 +285,7 @@ export class Muster {
281
285
  if (req.runtime === "opencode")
282
286
  runtimeArgs(effective);
283
287
  else
284
- argv = launchArgs(effective, this.config, [], undefined, this.sourceEnv);
288
+ argv = launchArgs(effective, this.config, [], undefined, this.sourceEnv, this.home);
285
289
  const driver = req.kind === "session"
286
290
  ? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
287
291
  : undefined;
@@ -475,7 +479,10 @@ export class Muster {
475
479
  // `runtimeEnv`, not the host's: an identity launch relocates
476
480
  // CLAUDE_CONFIG_DIR to the per-launch copy, and the sandbox must grant
477
481
  // the dir the child will actually open, not the one Muster runs under.
478
- argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv);
482
+ argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv,
483
+ // Muster's own home, not one derived from `runtimeEnv`: an identity
484
+ // launch may relocate the agent's HOME, but not where muster lives.
485
+ this.home);
479
486
  if (req.runtime === "codex") {
480
487
  codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
481
488
  argv.splice(1, 0, ...codexPolicy);
@@ -662,7 +669,7 @@ export class Muster {
662
669
  }
663
670
  })
664
671
  : req.runtime === "opencode"
665
- ? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
672
+ ? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline, req.prompt)
666
673
  : await resolveClaude(root.pid, resolveEnv, deadline);
667
674
  if (identity) {
668
675
  diagnostic =
@@ -942,6 +949,7 @@ export class Muster {
942
949
  exitPath,
943
950
  startPath,
944
951
  logHome: this.home,
952
+ logMode,
945
953
  launchId: entry.launchId,
946
954
  mcpConfigPath: entry.mcpConfigPath,
947
955
  }), { mode: 0o600 });
@@ -41,7 +41,7 @@ async function finish(code, signal, error) {
41
41
  exit_code: code,
42
42
  signal,
43
43
  error,
44
- });
44
+ }, spec.logMode);
45
45
  }
46
46
  finally {
47
47
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {