@brutalsystems/muster 0.7.10 → 0.7.11

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
@@ -208,11 +208,61 @@ What a repeat returns depends on what the first attempt did:
208
208
  | Reachable session or running task | That session or task, unchanged |
209
209
  | Still launching | Refused — ask for its status instead |
210
210
  | Failed, exited or stopped | Refused, naming the recorded outcome |
211
+ | Outcome unknown | Refused, and told to inspect first (below) |
211
212
 
212
213
  A repeat never consumes launch capacity, including when the concurrency cap is
213
214
  full of the very launch being asked about. Omitting the key leaves behaviour
214
215
  exactly as it was: every request is its own launch.
215
216
 
217
+ ## When an outcome is unknown
218
+
219
+ If Muster dies between starting a process and recording it, the entry says
220
+ `unknown` rather than `failed`, and `muster list` reports it that way.
221
+
222
+ The distinction is not cosmetic. `failed` means nothing is running and retrying
223
+ is safe; `unknown` means a session may be running that Muster never got to
224
+ record, and relaunching would be how a second agent appears in the same
225
+ directory. A repeat under the same request key is refused for exactly that
226
+ reason.
227
+
228
+ Only the half of the window that provably never reached a spawn is still called
229
+ `failed` — an owner that died before Muster marked itself about to start a
230
+ process. Resolving an `unknown` entry is manual today: look for the session,
231
+ stop it if it is there, and launch again under a new key.
232
+
233
+ ## Where a launch may run
234
+
235
+ By default any directory the account can read is a legal target, which is the
236
+ behaviour Muster has always had. Two config settings narrow it:
237
+
238
+ ```toml
239
+ allowed_roots = ["/Users/you/Source"]
240
+
241
+ [projects]
242
+ muster = "/Users/you/Source/brutalsystems/muster"
243
+ ```
244
+
245
+ With either configured, a launch outside all of them is refused. A configured
246
+ project is a grant in its own right — naming a directory there permits it
247
+ without also permitting its parent.
248
+
249
+ ```sh
250
+ muster run codex --project muster --prompt "Review the auth flow"
251
+ ```
252
+
253
+ `--project NAME` (MCP: `project`) is used instead of `--cwd`; naming both is an
254
+ error rather than a precedence rule. A project name is also portable in a way an
255
+ absolute path is not, since the same name can point somewhere different on
256
+ another machine.
257
+
258
+ The check resolves symlinks before comparing, so a link inside an allowed root
259
+ that points elsewhere does not escape it, and compares path-relatively, so
260
+ `/work/project-other` is not treated as part of `/work/project`.
261
+
262
+ This constrains **where a session starts, not what it can then reach**. It is
263
+ not a sandbox: a launched agent still has whatever access the OS account has.
264
+ Permission modes and the sandbox settings below are the controls for that.
265
+
216
266
  ## Process ownership
217
267
 
218
268
  Muster identifies a launched process by its **pid paired with its start time**,
package/dist/config.js CHANGED
@@ -11,6 +11,9 @@ export const configSchema = z
11
11
  host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
12
12
  launch_timeout_sec: z.number().finite().positive().max(600).default(30),
13
13
  max_concurrent: z.number().int().positive().max(1000).default(4),
14
+ allowed_roots: z.array(z.string().min(1)).default([]),
15
+ projects: z.record(z.string().regex(/^[a-zA-Z0-9_-]+$/), z.string().min(1))
16
+ .default({}),
14
17
  permissions: z.enum(["auto", "deny", "bypass"]).default("deny"),
15
18
  sandbox: z
16
19
  .enum([
package/dist/format.js CHANGED
@@ -19,7 +19,7 @@ function recordHuman(record, color) {
19
19
  const paint = (value, code) => color ? `\u001b[${code}m${value}\u001b[0m` : value;
20
20
  const field = (label, value) => ` ${paint(label + ":", 90)} ${value}`;
21
21
  const badExit = typeof record.exit_code === "number" && record.exit_code !== 0;
22
- const stateColor = record.state === "failed" || badExit
22
+ const stateColor = record.state === "failed" || record.state === "unknown" || badExit
23
23
  ? 31
24
24
  : ["idle", "running"].includes(record.state ?? "")
25
25
  ? 32
package/dist/guard.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { createHash } from "node:crypto";
3
- import { resolve } from "node:path";
3
+ import { realpath } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve } from "node:path";
4
5
  import { mcpName, claudeMcpConfig } from "./mcp.js";
5
6
  import { pluginName } from "./plugins.js";
6
7
  export const runSchema = z
@@ -23,6 +24,10 @@ export const runSchema = z
23
24
  mcp: z.array(mcpName).optional(),
24
25
  plugin: z.array(pluginName).optional(),
25
26
  model: z.string().min(1).optional(),
27
+ project: z
28
+ .string()
29
+ .regex(/^[a-zA-Z0-9_-]+$/)
30
+ .optional(),
26
31
  requestKey: z.string().min(1).max(200).optional(),
27
32
  args: z.array(z.string()).default([]),
28
33
  })
@@ -42,6 +47,40 @@ export function resolvePermissions(req, config) {
42
47
  throw new Error("--permissions bypass requires --sandbox full-access");
43
48
  return { permissions, sandbox };
44
49
  }
50
+ /**
51
+ * Where a launch is allowed to run.
52
+ *
53
+ * Two properties do the work, and a prefix comparison has neither:
54
+ *
55
+ * - **Symlinks are resolved first.** A link inside an allowed root pointing
56
+ * anywhere else would otherwise be a way out of the root, and the path as
57
+ * written says nothing about where it lands.
58
+ * - **Containment is path-relative.** `/work/project-other` is not inside
59
+ * `/work/project`, though it shares every character of it.
60
+ *
61
+ * Configured project paths are roots in their own right: naming a directory as
62
+ * a project is the grant.
63
+ *
64
+ * With nothing configured this permits anything the account can reach, which is
65
+ * the behaviour Muster has always had. That is a choice, and the README says so
66
+ * — it is not a safe default inherited by accident.
67
+ */
68
+ export async function assertAllowedWorkspace(cwd, config) {
69
+ const roots = [...config.allowed_roots, ...Object.values(config.projects)];
70
+ if (!roots.length)
71
+ return;
72
+ const real = await realpath(cwd);
73
+ for (const root of roots) {
74
+ const resolved = await realpath(resolve(root)).catch(() => null);
75
+ if (!resolved)
76
+ continue;
77
+ const rel = relative(resolved, real);
78
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)))
79
+ return;
80
+ }
81
+ throw new Error(`cwd is outside every configured allowed root: ${cwd}. ` +
82
+ `Add a root or a project to config.toml, or launch somewhere already permitted.`);
83
+ }
45
84
  /**
46
85
  * What a request key is bound to, so the same key arriving with different
47
86
  * parameters can be refused rather than silently answered with the wrong
package/dist/muster.js CHANGED
@@ -37,6 +37,9 @@ which also admits any plugin the target repository ships.
37
37
  --model PROVIDER/MODEL selects the OpenCode model, overriding the configured
38
38
  [opencode] model. Providers defined under [opencode.provider] are supplied to
39
39
  the child, so launches do not depend on the user's own OpenCode config.
40
+ --project NAME launches in a directory named under [projects] in config.toml,
41
+ instead of --cwd; naming both is an error. When allowed_roots or [projects] are
42
+ configured, a launch outside all of them is refused.
40
43
  --request-key KEY names the launch request itself. Sending the same key again
41
44
  returns the launch it already produced rather than starting a second one, and
42
45
  sending it with different parameters is refused rather than answered.
@@ -129,6 +132,11 @@ async function main() {
129
132
  items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
130
133
  description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
131
134
  },
135
+ project: {
136
+ type: "string",
137
+ pattern: "^[a-zA-Z0-9_-]+$",
138
+ description: "A project named in config.toml, used instead of cwd. Naming both is an error.",
139
+ },
132
140
  requestKey: {
133
141
  type: "string",
134
142
  minLength: 1,
@@ -227,6 +235,7 @@ async function main() {
227
235
  model: { type: "string" },
228
236
  "no-plugin": { type: "boolean" },
229
237
  "request-key": { type: "string" },
238
+ project: { type: "string" },
230
239
  },
231
240
  });
232
241
  const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, ...values } = parsed;
package/dist/registry.js CHANGED
@@ -59,8 +59,23 @@ export class Registry {
59
59
  if (entry.status === "starting" &&
60
60
  !(await isSame(entry.owner)) &&
61
61
  !entry.root) {
62
- entry.status = "failed";
63
- entry.error = "launch owner exited before recording process";
62
+ // The owner died between reserving and recording a process. Whether
63
+ // one exists depends on how far it got, and the entry cannot say —
64
+ // so only the half that provably never reached a spawn is called
65
+ // failed. The other half is unknown, which is not the same claim:
66
+ // failed reads as safe to retry, and retrying a launch that did
67
+ // happen is how a second agent appears in the same directory.
68
+ if (entry.spawning) {
69
+ entry.status = "unknown";
70
+ entry.unknownSince ??= new Date().toISOString();
71
+ entry.error =
72
+ "launch owner exited after starting a process but before recording it; " +
73
+ "a session may be running untracked";
74
+ }
75
+ else {
76
+ entry.status = "failed";
77
+ entry.error = "launch owner exited before recording process";
78
+ }
64
79
  }
65
80
  if (["starting", "running"].includes(entry.status) &&
66
81
  entry.root &&
package/dist/run.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { stat, readFile, writeFile, mkdir, rm } from "node:fs/promises";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  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, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
7
+ import { runSchema, assertAllowedWorkspace, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
8
8
  import { Registry } from "./registry.js";
9
9
  import { LaunchLog } from "./log.js";
10
10
  import { hosts, selectHost } from "./hosts/index.js";
@@ -69,9 +69,25 @@ export class Muster {
69
69
  return operation.finally(() => this.active.delete(operation));
70
70
  }
71
71
  async launch(input, requester) {
72
+ // Read before parsing, because cwd defaults during it and a default is then
73
+ // indistinguishable from a directory the caller asked for. Naming both a
74
+ // project and a directory is a contradiction worth reporting rather than
75
+ // resolving by precedence.
76
+ if (typeof input === "object" &&
77
+ input !== null &&
78
+ "project" in input &&
79
+ input.cwd !== undefined)
80
+ throw new Error("use project or cwd, not both");
72
81
  const req = runSchema.parse(input);
82
+ if (req.project) {
83
+ const configured = this.config.projects[req.project];
84
+ if (!configured)
85
+ throw new Error(`Unknown project ${req.project}. Configured: ${Object.keys(this.config.projects).join(", ") || "none"}`);
86
+ req.cwd = resolve(configured);
87
+ }
73
88
  if (!(await stat(req.cwd)).isDirectory())
74
89
  throw new Error("cwd is not a directory");
90
+ await assertAllowedWorkspace(req.cwd, this.config);
75
91
  if (req.kind === "task" && req.host)
76
92
  throw new Error("host applies only to session launches");
77
93
  if (req.terminal && !req.open)
@@ -177,6 +193,9 @@ export class Muster {
177
193
  }
178
194
  if (this.closing)
179
195
  throw new Error("Launch cancelled: Muster is closing");
196
+ // From here a process may exist without the registry knowing it. See the
197
+ // recovery branch in Registry.transaction.
198
+ entry = await this.registry.update(entry.launchId, { spawning: true });
180
199
  if (req.kind === "task")
181
200
  return await this.startTask(entry, argv, runtimeEnv);
182
201
  const launched = await driver.launch({
@@ -347,6 +366,9 @@ export class Muster {
347
366
  const key = entry.requestKey;
348
367
  if (entry.status === "starting")
349
368
  throw new Error(`Request key ${key} is already launching (${entry.launchId}); ask for its status rather than launching again`);
369
+ if (entry.status === "unknown")
370
+ throw new Error(`Request key ${key} has an unknown outcome (${entry.launchId}): ${entry.error}. ` +
371
+ `Inspect before launching again — this is not a failure, and retrying it may leave two sessions`);
350
372
  if (entry.status !== "running")
351
373
  throw new Error(`Request key ${key} already resolved as ${entry.status}` +
352
374
  (entry.error ? `: ${entry.error}` : "") +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.10",
3
+ "version": "0.7.11",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {