@brutalsystems/muster 0.7.19 → 0.7.20
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 +7 -2
- package/dist/format.js +6 -0
- package/dist/guard.js +103 -10
- package/dist/help.js +4 -1
- package/dist/mcp-tools.js +1 -1
- package/dist/opencode-policy.js +11 -3
- package/dist/providers.js +0 -4
- package/dist/run.js +36 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,7 +105,10 @@ piped output and `TERM=dumb` are always uncolored. JSON never includes colors.
|
|
|
105
105
|
|
|
106
106
|
`run` accepts an optional `--` followed by runtime arguments. The normal
|
|
107
107
|
allowlist is `--model` / `-m`, plus Claude's `--effort` and OpenCode's `--agent`
|
|
108
|
-
and `--variant`.
|
|
108
|
+
and `--variant`. For Claude and Codex a model named there is *consumed* rather
|
|
109
|
+
than forwarded — it reaches the child through the same path as `--model`, so it
|
|
110
|
+
appears once — and naming a model both ways is refused rather than resolved by
|
|
111
|
+
precedence. Unknown options, bundled short options, config injection and
|
|
109
112
|
raw permission overrides are refused, even with `allow_dangerous_flags`
|
|
110
113
|
enabled. Use Muster's normalized permission flags. Prompt strings are always
|
|
111
114
|
passed as one literal argument. Codex and Claude launches and OpenCode tasks put
|
|
@@ -396,7 +399,9 @@ claim implies that every external tool is OS-sandboxed.
|
|
|
396
399
|
OpenCode keeps normal provider/model configuration and launches with `--pure`
|
|
397
400
|
unless plugins are selected, which disables inherited external plugins.
|
|
398
401
|
Providers declared under `[opencode.provider]` and a model named by
|
|
399
|
-
`[opencode] model` or `--model` are supplied in the same verified overlay
|
|
402
|
+
`[opencode] model` or `--model` are supplied in the same verified overlay (a
|
|
403
|
+
model named after `--` instead rides the child's argv and is left out of the
|
|
404
|
+
overlay, so it is delivered once rather than twice), so a
|
|
400
405
|
launch does not depend on the operator's own OpenCode configuration; provider
|
|
401
406
|
definitions merge with any the child resolves for itself rather than replacing
|
|
402
407
|
them. Secrets belong in `api_key_env_var`, read from the launching environment,
|
package/dist/format.js
CHANGED
|
@@ -38,10 +38,16 @@ function recordHuman(record, color) {
|
|
|
38
38
|
if (record.runtime === "opencode" && record.server_url)
|
|
39
39
|
lines.push(field("Control URL", clean(record.server_url)));
|
|
40
40
|
lines.push(field("Directory", clean(record.cwd)));
|
|
41
|
+
if (record.identity)
|
|
42
|
+
lines.push(field("Identity", clean(record.identity)));
|
|
41
43
|
if (record.permissions)
|
|
42
44
|
lines.push(field("Permissions", clean(record.permissions)));
|
|
43
45
|
if (record.sandbox)
|
|
44
46
|
lines.push(field("Sandbox", clean(record.sandbox)));
|
|
47
|
+
// Only when known: a null means the child chose its own model, and "-" in a
|
|
48
|
+
// human listing would read as a value rather than an absence.
|
|
49
|
+
if (record.model)
|
|
50
|
+
lines.push(field("Model", clean(record.model)));
|
|
45
51
|
if (record.mcp)
|
|
46
52
|
lines.push(field("MCP", record.mcp.length ? record.mcp.map(clean).join(", ") : "none"));
|
|
47
53
|
for (const warning of record.mcp_warnings ?? [])
|
package/dist/guard.js
CHANGED
|
@@ -156,30 +156,121 @@ export function paramsFingerprint(req, permissions, mcp, plugins) {
|
|
|
156
156
|
]))
|
|
157
157
|
.digest("hex");
|
|
158
158
|
}
|
|
159
|
+
const MODEL_FLAGS = ["--model", "-m"];
|
|
160
|
+
/**
|
|
161
|
+
* The flag half of any pass-through arg, lowercased with `_` mapped to `-`.
|
|
162
|
+
* Extracted from `runtimeArgs`, which already normalised this way inline, and
|
|
163
|
+
* now shared with the resolver so the two cannot disagree about whether a
|
|
164
|
+
* given spelling names a model: one deciding a model was named while the other
|
|
165
|
+
* forwards it is exactly how a model reaches the child's argv twice.
|
|
166
|
+
*/
|
|
167
|
+
export function normaliseFlag(arg) {
|
|
168
|
+
return arg.split("=")[0].toLowerCase().replaceAll("_", "-");
|
|
169
|
+
}
|
|
170
|
+
/** A model named inside the runtime pass-through, if one is there. */
|
|
171
|
+
function modelInArgs(args) {
|
|
172
|
+
const found = [];
|
|
173
|
+
for (let i = 0; i < args.length; i++) {
|
|
174
|
+
const arg = args[i];
|
|
175
|
+
if (!MODEL_FLAGS.includes(normaliseFlag(arg)))
|
|
176
|
+
continue;
|
|
177
|
+
if (arg.includes("=")) {
|
|
178
|
+
const value = arg.slice(arg.indexOf("=") + 1);
|
|
179
|
+
// `--model=` names a model and supplies none. Accepting it would deliver
|
|
180
|
+
// nothing and record `model: null` — "the child chose" — which is the
|
|
181
|
+
// accepted-and-silently-ignored behaviour this feature exists to remove.
|
|
182
|
+
if (!value)
|
|
183
|
+
throw new Error(`Missing value for ${arg}`);
|
|
184
|
+
found.push(value);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const value = args[i + 1];
|
|
188
|
+
// A bare flag with no usable value is left alone: `runtimeArgs` raises
|
|
189
|
+
// "Missing value for --model" on the same input and words it well.
|
|
190
|
+
if (value && !value.startsWith("-"))
|
|
191
|
+
found.push(value);
|
|
192
|
+
}
|
|
193
|
+
// Taking the first would let OpenCode forward both to a child whose last flag
|
|
194
|
+
// wins, leaving the run record naming a model the child did not run.
|
|
195
|
+
if (found.length > 1)
|
|
196
|
+
throw new Error(`Model named twice in runtime options: ${found.join(" and ")}. Name it once.`);
|
|
197
|
+
return found[0];
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The one place a launch's model is decided. `launchArgs`, the registry write
|
|
201
|
+
* and the OpenCode overlay all call this, so what is recorded on the run and
|
|
202
|
+
* what is handed to the child are one computation rather than two that agree
|
|
203
|
+
* by luck — which is the whole point of recording it at all.
|
|
204
|
+
*/
|
|
205
|
+
export function resolveModel(req, config) {
|
|
206
|
+
const inArgs = modelInArgs(req.args);
|
|
207
|
+
if (req.model !== undefined && inArgs !== undefined)
|
|
208
|
+
throw new Error(`Model named twice: --model ${req.model} and ${inArgs} in runtime options. Name it once.`);
|
|
209
|
+
const named = req.model ?? inArgs;
|
|
210
|
+
if (named !== undefined) {
|
|
211
|
+
// OpenCode's native form IS provider/model; claude and codex take a bare
|
|
212
|
+
// id, and a slash there is a mistake worth catching at this boundary
|
|
213
|
+
// rather than in the child's argument parser.
|
|
214
|
+
if (req.runtime !== "opencode" && named.includes("/"))
|
|
215
|
+
throw new Error(`--model for ${req.runtime} takes a bare model id, not PROVIDER/MODEL (got ${named})`);
|
|
216
|
+
return {
|
|
217
|
+
model: named,
|
|
218
|
+
source: "request",
|
|
219
|
+
origin: req.model !== undefined ? "flag" : "args",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const configured = req.runtime === "opencode" ? config.opencode.model : undefined;
|
|
223
|
+
return configured === undefined
|
|
224
|
+
? null
|
|
225
|
+
: { model: configured, source: "config", origin: "config" };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Per runtime: which pass-through options ride to the child, and which are
|
|
229
|
+
* recognised but delivered by `resolveModel` instead. A consumed flag must not
|
|
230
|
+
* be refused — naming a model in `args` is still a supported way to ask, and
|
|
231
|
+
* it is the only way that worked before `--model` reached these runtimes.
|
|
232
|
+
* OpenCode is deliberately absent from `consumed`: it already forwards a model
|
|
233
|
+
* from `args` onto its argv and `test/opencode-policy.test.ts` pins that.
|
|
234
|
+
*/
|
|
235
|
+
const RUNTIME_OPTIONS = {
|
|
236
|
+
claude: { forwarded: ["--effort"], consumed: MODEL_FLAGS },
|
|
237
|
+
codex: { forwarded: [], consumed: MODEL_FLAGS },
|
|
238
|
+
opencode: {
|
|
239
|
+
forwarded: ["--model", "-m", "--agent", "--variant"],
|
|
240
|
+
consumed: [],
|
|
241
|
+
},
|
|
242
|
+
};
|
|
159
243
|
export function runtimeArgs(req) {
|
|
160
244
|
const result = [];
|
|
161
|
-
const
|
|
162
|
-
? ["--model", "-m", "--effort"]
|
|
163
|
-
: req.runtime === "opencode"
|
|
164
|
-
? ["--model", "-m", "--agent", "--variant"]
|
|
165
|
-
: ["--model", "-m"];
|
|
245
|
+
const { forwarded, consumed } = RUNTIME_OPTIONS[req.runtime];
|
|
166
246
|
for (let i = 0; i < req.args.length; i++) {
|
|
167
|
-
const arg = req.args[i], flag = arg
|
|
168
|
-
|
|
247
|
+
const arg = req.args[i], flag = normaliseFlag(arg);
|
|
248
|
+
const keep = forwarded.includes(flag);
|
|
249
|
+
if (!keep && !consumed.includes(flag))
|
|
169
250
|
throw new Error(`Runtime option refused: ${arg}`);
|
|
170
|
-
if (arg.includes("="))
|
|
171
|
-
|
|
251
|
+
if (arg.includes("=")) {
|
|
252
|
+
if (keep)
|
|
253
|
+
result.push(arg);
|
|
254
|
+
}
|
|
172
255
|
else {
|
|
256
|
+
// The value is stepped over for a consumed flag exactly as for a
|
|
257
|
+
// forwarded one, so a missing value is still an error and the next
|
|
258
|
+
// option is not mistaken for this one's value.
|
|
173
259
|
const value = req.args[++i];
|
|
174
260
|
if (!value || value.startsWith("-"))
|
|
175
261
|
throw new Error(`Missing value for ${arg}`);
|
|
176
|
-
|
|
262
|
+
if (keep)
|
|
263
|
+
result.push(arg, value);
|
|
177
264
|
}
|
|
178
265
|
}
|
|
179
266
|
return result;
|
|
180
267
|
}
|
|
181
268
|
export function launchArgs(req, config, mcp = [], bridgePath) {
|
|
182
269
|
const extra = runtimeArgs(req);
|
|
270
|
+
const resolved = resolveModel(req, config);
|
|
271
|
+
// OpenCode never reaches here — `run.ts` branches it to the OpenCode launch
|
|
272
|
+
// builders, which carry the model in their config overlay.
|
|
273
|
+
const model = resolved ? ["--model", resolved.model] : [];
|
|
183
274
|
const policy = resolvePermissions(req, config);
|
|
184
275
|
if (req.runtime === "codex")
|
|
185
276
|
return [
|
|
@@ -200,6 +291,7 @@ export function launchArgs(req, config, mcp = [], bridgePath) {
|
|
|
200
291
|
? ["--ask-for-approval", "never"]
|
|
201
292
|
: ["-c", 'approval_policy="never"']),
|
|
202
293
|
]),
|
|
294
|
+
...model,
|
|
203
295
|
...extra,
|
|
204
296
|
"--",
|
|
205
297
|
req.prompt,
|
|
@@ -244,6 +336,7 @@ export function launchArgs(req, config, mcp = [], bridgePath) {
|
|
|
244
336
|
JSON.stringify(claudeMcpConfig(mcp, bridgePath)),
|
|
245
337
|
"--settings",
|
|
246
338
|
JSON.stringify(settings),
|
|
339
|
+
...model,
|
|
247
340
|
...extra,
|
|
248
341
|
"--",
|
|
249
342
|
req.prompt,
|
package/dist/help.js
CHANGED
|
@@ -55,7 +55,10 @@ export const COMMANDS = [
|
|
|
55
55
|
["--no-mcp", "launch with no MCP servers"],
|
|
56
56
|
["--plugin NAME", "repeatable; replaces configured defaults (OpenCode)"],
|
|
57
57
|
["--no-plugin", "launch with no plugins"],
|
|
58
|
-
[
|
|
58
|
+
[
|
|
59
|
+
"--model MODEL",
|
|
60
|
+
"model for the launch: a bare id for claude and codex, PROVIDER/MODEL for opencode",
|
|
61
|
+
],
|
|
59
62
|
[
|
|
60
63
|
"--request-key KEY",
|
|
61
64
|
"name the request; sending it again returns the same launch",
|
package/dist/mcp-tools.js
CHANGED
|
@@ -80,7 +80,7 @@ export const MCP_TOOLS = [
|
|
|
80
80
|
model: {
|
|
81
81
|
type: "string",
|
|
82
82
|
minLength: 1,
|
|
83
|
-
description: "PROVIDER/MODEL,
|
|
83
|
+
description: "Model for the launch. A bare model id for claude and codex; PROVIDER/MODEL for opencode, where it overrides the configured [opencode] model. Recorded on the run and reported by `list`.",
|
|
84
84
|
},
|
|
85
85
|
identity: {
|
|
86
86
|
type: "string",
|
package/dist/opencode-policy.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import { resolvePermissions, runtimeArgs } from "./guard.js";
|
|
4
|
-
import {
|
|
3
|
+
import { resolveModel, resolvePermissions, runtimeArgs, } from "./guard.js";
|
|
4
|
+
import { openCodeProviders } from "./providers.js";
|
|
5
5
|
const exec = promisify(execFile);
|
|
6
6
|
const DEBUG_CONFIG_ERROR = "Cannot verify effective OpenCode MCP configuration";
|
|
7
7
|
const ISOLATION_ERROR = "OpenCode MCP isolation could not be verified; refusing launch";
|
|
@@ -173,7 +173,15 @@ function permissions(req, config, tools) {
|
|
|
173
173
|
}
|
|
174
174
|
export function openCodeConfig(req, config, selected, inheritedMcpNames, plugins = [], source = {}) {
|
|
175
175
|
const { mcp, tools } = mcpPolicy(selected, inheritedMcpNames);
|
|
176
|
-
|
|
176
|
+
// An `args`-named model is already forwarded onto the child's argv by
|
|
177
|
+
// `runtimeArgs`, so it must not also enter the verified overlay: that would
|
|
178
|
+
// deliver one model by two routes and rewrite a config the caller never
|
|
179
|
+
// changed. The overlay carries only what `--model` or `[opencode] model`
|
|
180
|
+
// named, exactly as before this resolver existed.
|
|
181
|
+
const resolved = resolveModel(req, config);
|
|
182
|
+
const model = resolved && resolved.origin !== "args"
|
|
183
|
+
? resolved.model
|
|
184
|
+
: config.opencode.model;
|
|
177
185
|
const provider = openCodeProviders(config, source);
|
|
178
186
|
return {
|
|
179
187
|
share: "disabled",
|
package/dist/providers.js
CHANGED
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, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
|
|
7
|
+
import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, resolveModel, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, 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";
|
|
@@ -37,6 +37,12 @@ function taskHandle(entry, pid) {
|
|
|
37
37
|
mcp: entry.mcp ?? [],
|
|
38
38
|
...(entry.mcp_warnings ? { mcp_warnings: entry.mcp_warnings } : {}),
|
|
39
39
|
...(entry.enforcement ? { enforcement: entry.enforcement } : {}),
|
|
40
|
+
...(entry.identity ? { identity: entry.identity } : {}),
|
|
41
|
+
// `run` returns this, while `list` builds its own record: a field added to
|
|
42
|
+
// one and not the other makes the two disagree about the same launch.
|
|
43
|
+
...(entry.model !== undefined
|
|
44
|
+
? { model: entry.model, model_source: entry.modelSource ?? null }
|
|
45
|
+
: {}),
|
|
40
46
|
id: entry.id,
|
|
41
47
|
runtime: entry.runtime,
|
|
42
48
|
state: "running",
|
|
@@ -226,6 +232,10 @@ export class Muster {
|
|
|
226
232
|
const selectedMcp = selectMcp(effective, this.config);
|
|
227
233
|
const selectedPlugins = req.runtime === "opencode" ? selectPlugins(effective, this.config) : [];
|
|
228
234
|
let argv = [];
|
|
235
|
+
// Resolved here, at the seam that already exists to refuse a bad launch
|
|
236
|
+
// before anything is reserved: a malformed model must not leave a
|
|
237
|
+
// registry entry behind.
|
|
238
|
+
const model = resolveModel(effective, this.config);
|
|
229
239
|
if (req.runtime === "opencode")
|
|
230
240
|
runtimeArgs(effective);
|
|
231
241
|
else
|
|
@@ -247,6 +257,13 @@ export class Muster {
|
|
|
247
257
|
cwd: req.cwd,
|
|
248
258
|
...permissions,
|
|
249
259
|
enforcement,
|
|
260
|
+
model: model?.model ?? null,
|
|
261
|
+
modelSource: model?.source ?? null,
|
|
262
|
+
// Recorded here rather than only at the patch below, because `list`
|
|
263
|
+
// now publishes it: an entry that dies between this line and that one
|
|
264
|
+
// would otherwise report no identity, which reads as "ran on the
|
|
265
|
+
// ambient environment" for a launch that named an account.
|
|
266
|
+
...(identityName ? { identity: identityName } : {}),
|
|
250
267
|
}, this.config.max_concurrent, req.requestKey
|
|
251
268
|
? {
|
|
252
269
|
requestKey: req.requestKey,
|
|
@@ -519,6 +536,10 @@ export class Muster {
|
|
|
519
536
|
pid: identity.pid,
|
|
520
537
|
host: driver.id,
|
|
521
538
|
capabilities: driver.capabilities(),
|
|
539
|
+
// The RESOLVED name, matching what the entry records and what
|
|
540
|
+
// `list` projects: a launch result and a listing must not
|
|
541
|
+
// disagree about which account this ran as.
|
|
542
|
+
...(identityName ? { identity: identityName } : {}),
|
|
522
543
|
attach_hint: driver.attachHint(launched.hostRef),
|
|
523
544
|
};
|
|
524
545
|
if (req.open) {
|
|
@@ -859,6 +880,13 @@ export class Muster {
|
|
|
859
880
|
// From the entry, not the peer: a listing must report the same fact a
|
|
860
881
|
// launch result does, and an entry predating the field has none.
|
|
861
882
|
...(e.enforcement ? { enforcement: e.enforcement } : {}),
|
|
883
|
+
...(e.identity ? { identity: e.identity } : {}),
|
|
884
|
+
// Absent on an entry written before the field, which is a different
|
|
885
|
+
// fact from an explicit null: that one means Muster resolved nothing
|
|
886
|
+
// and the child chose. Both are more useful than a guess.
|
|
887
|
+
...(e.model !== undefined
|
|
888
|
+
? { model: e.model, model_source: e.modelSource ?? null }
|
|
889
|
+
: {}),
|
|
862
890
|
id: e.id,
|
|
863
891
|
runtime: e.peer?.runtime ?? e.runtime,
|
|
864
892
|
state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
|
|
@@ -879,6 +907,13 @@ export class Muster {
|
|
|
879
907
|
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
880
908
|
: {}),
|
|
881
909
|
...(e.enforcement ? { enforcement: e.enforcement } : {}),
|
|
910
|
+
...(e.identity ? { identity: e.identity } : {}),
|
|
911
|
+
// Absent on an entry written before the field, which is a different
|
|
912
|
+
// fact from an explicit null: that one means Muster resolved nothing
|
|
913
|
+
// and the child chose. Both are more useful than a guess.
|
|
914
|
+
...(e.model !== undefined
|
|
915
|
+
? { model: e.model, model_source: e.modelSource ?? null }
|
|
916
|
+
: {}),
|
|
882
917
|
id: e.id,
|
|
883
918
|
runtime: e.runtime,
|
|
884
919
|
cwd: e.cwd,
|