@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 +8 -8
- package/dist/guard.js +117 -6
- package/dist/help.js +1 -1
- package/dist/hosts/tmux.js +38 -0
- package/dist/identity/opencode.js +48 -2
- package/dist/log.js +18 -6
- package/dist/opencode-http.js +9 -0
- package/dist/providers.js +24 -0
- package/dist/run.js +12 -4
- package/dist/task-worker.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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"],
|
package/dist/hosts/tmux.js
CHANGED
|
@@ -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
|
-
|
|
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]) =>
|
|
83
|
-
? [
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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");
|
package/dist/opencode-http.js
CHANGED
|
@@ -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 });
|
package/dist/task-worker.js
CHANGED