@brutalsystems/muster 0.7.1 → 0.7.3
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/CONTRACT_PROVENANCE.md +111 -2
- package/README.md +68 -10
- package/dist/config.js +5 -0
- package/dist/guard.js +3 -0
- package/dist/muster.js +16 -1
- package/dist/opencode-policy.js +61 -14
- package/dist/plugins.js +34 -0
- package/dist/providers.js +81 -0
- package/dist/run.js +7 -2
- package/package.json +4 -2
package/CONTRACT_PROVENANCE.md
CHANGED
|
@@ -79,6 +79,115 @@ the same codename remediation via a history rewrite (`git filter-repo
|
|
|
79
79
|
whose content or ancestry it touched. This is a historical acquisition
|
|
80
80
|
record, not a claim that the commit is still reachable.
|
|
81
81
|
|
|
82
|
+
## Contract baseline moved to 0.6.0 — 2026-09-20
|
|
83
|
+
|
|
84
|
+
Tin Can 0.6.0 changes the endpoint it delivers to on the opencode leg, from the
|
|
85
|
+
v2 `/api/session/{id}/prompt` to the v1 `/session/{id}/prompt_async`, and the
|
|
86
|
+
change resolves the failure described in the 0.5.9 entry below. It is a minor
|
|
87
|
+
rather than a patch because `urgent` was removed as a capability: the v1 route
|
|
88
|
+
has no equivalent of v2's steer/queue distinction, so rather than accept it and
|
|
89
|
+
ignore it, Tin Can now reports urgent as unsupported on every runtime. Muster
|
|
90
|
+
never referenced that capability, so nothing here changes.
|
|
91
|
+
|
|
92
|
+
Muster's contract suite was run against a real installed
|
|
93
|
+
`@brutalsystems/tincan@0.6.0` and passed all four cases. Naming is untouched:
|
|
94
|
+
0.6.0's `canonical-id.json` was compared case by case with Muster's frozen copy
|
|
95
|
+
and every pre-existing case is present with an unchanged expectation, with the
|
|
96
|
+
same four additive opencode-specific cases introduced in 0.5.7.
|
|
97
|
+
|
|
98
|
+
Verified end to end rather than by the suite alone: a Muster-launched OpenCode
|
|
99
|
+
session in a terminal received a Tin Can message, ran the turn, and replied to
|
|
100
|
+
the sending Claude Code session. The OpenCode log for that session shows a
|
|
101
|
+
started loop and a resolved model with no `Failed to drain` line, which is the
|
|
102
|
+
signature the 0.5.9 entry documents as the failure.
|
|
103
|
+
|
|
104
|
+
One operational trap worth recording, because it makes a working setup look
|
|
105
|
+
broken: the OpenCode plugin is a **copy on disk** under
|
|
106
|
+
`~/.config/opencode/plugin/`. Updating the npm package does not update it, and
|
|
107
|
+
a stale plugin keeps posting to the old v2 route. The peer registry records
|
|
108
|
+
`plugin_version`; confirm it matches the installed package before trusting a
|
|
109
|
+
delivery result.
|
|
110
|
+
|
|
111
|
+
## Contract baseline moved to 0.5.9 — 2026-09-20
|
|
112
|
+
|
|
113
|
+
Re-pinned from `0.5.7`, skipping a separate `0.5.8` pass, because the `notice`
|
|
114
|
+
text moved twice in one afternoon while the underlying mechanism was being
|
|
115
|
+
isolated: 0.5.7 described it as never scheduled, 0.5.8 as a warmth problem,
|
|
116
|
+
0.5.9 states what was actually observed. Both earlier descriptions were wrong.
|
|
117
|
+
|
|
118
|
+
Muster's contract suite was run against a real installed
|
|
119
|
+
`@brutalsystems/tincan@0.5.9` and passed all four cases.
|
|
120
|
+
|
|
121
|
+
0.5.9 changes the `notice` text on `send_peer` results and widens it from idle
|
|
122
|
+
opencode peers to all opencode peers. It does not touch naming: 0.5.9's
|
|
123
|
+
`canonical-id.json` was compared case by case with Muster's frozen copy, and
|
|
124
|
+
every pre-existing case in `slugify`, `suffix`, `assign` and `resolve` is
|
|
125
|
+
present with an unchanged expectation, with the same four additive
|
|
126
|
+
opencode-specific cases 0.5.7 introduced.
|
|
127
|
+
|
|
128
|
+
The reason for the churn is worth recording, because Muster's own evidence
|
|
129
|
+
caused it. Muster reported that inbound Tin Can delivery to an idle
|
|
130
|
+
TUI-hosted opencode session was admitted but never scheduled, and 0.5.7's
|
|
131
|
+
notice encoded that mechanism. The opencode log disproves it: a session idle
|
|
132
|
+
since 20:00:13 was woken 68ms after admission at 20:01:13 and ran the drain,
|
|
133
|
+
which then failed with `ModelUnavailableError` on a local model. Fourteen of
|
|
134
|
+
the seventeen `Failed to drain` lines in that log name the same local model.
|
|
135
|
+
Admission does schedule execution, exactly as the endpoint documents; the
|
|
136
|
+
turn died afterwards. Muster's `opencode serve` control did not separate host
|
|
137
|
+
type from model resolution, because it used different credentials and failed on
|
|
138
|
+
an unrelated 401.
|
|
139
|
+
|
|
140
|
+
The mechanism was subsequently isolated, and it is neither scheduling nor model
|
|
141
|
+
warmth. In every TUI-hosted opencode session observed, the drain path failed to
|
|
142
|
+
resolve the session's model, while the TUI path resolved the same model in the
|
|
143
|
+
same process seconds either side of it:
|
|
144
|
+
|
|
145
|
+
20:42:30 stream providerID=fireworks-ai modelID=...qwen3p8-max (TUI turn)
|
|
146
|
+
20:42:54 ERROR Failed to drain Session
|
|
147
|
+
ModelUnavailableError: fireworks-ai/...qwen3p8-max (delivered)
|
|
148
|
+
|
|
149
|
+
That run was stock opencode 1.18.31 with no Muster involvement, no overlay, and
|
|
150
|
+
a globally authenticated provider, so it is not a Muster defect and not
|
|
151
|
+
specific to Muster-supplied providers.
|
|
152
|
+
|
|
153
|
+
Observed across 14 distinct TUI-hosted sessions and 20 failures, under two
|
|
154
|
+
unrelated providers (`muster-local` and `fireworks-ai`). No TUI-hosted drain
|
|
155
|
+
was observed to succeed. The contrasting case is an `opencode serve` session,
|
|
156
|
+
whose drain resolved its model and reached the provider, failing only on an
|
|
157
|
+
unrelated HTTP 401 — the model resolution itself worked there. That is what
|
|
158
|
+
localises this to TUI-hosted sessions. It is a consistent observation on
|
|
159
|
+
1.18.31, not a proof about every configuration.
|
|
160
|
+
|
|
161
|
+
What remains true for callers is narrower: a caller cannot confirm that an
|
|
162
|
+
opencode peer acted on a message, because a turn that dies after admission is
|
|
163
|
+
indistinguishable from outside from one that succeeded.
|
|
164
|
+
|
|
165
|
+
## Contract baseline moved to 0.5.7 — 2026-09-20
|
|
166
|
+
|
|
167
|
+
Re-pinned from `0.5.2` to `@brutalsystems/tincan@0.5.7`, deliberately rather
|
|
168
|
+
than by drift: 0.5.2 was three releases behind and no longer what anyone runs.
|
|
169
|
+
|
|
170
|
+
Muster's contract suite was run against a real installed
|
|
171
|
+
`@brutalsystems/tincan@0.5.7` and passed all four cases.
|
|
172
|
+
|
|
173
|
+
The intervening releases are additive on this contract. 0.5.6 added a `notes`
|
|
174
|
+
array to the `peers` result and changed tool description text; 0.5.7 added a
|
|
175
|
+
`notice` field on `send_peer` results for idle opencode peers, reporting that a
|
|
176
|
+
message is durably admitted but that execution cannot be confirmed on that leg.
|
|
177
|
+
Neither touches naming.
|
|
178
|
+
|
|
179
|
+
Verified against the artifact rather than assumed: 0.5.7's
|
|
180
|
+
`test/fixtures/canonical-id.json` was compared case by case with Muster's
|
|
181
|
+
frozen copy. Every pre-existing case in `slugify`, `suffix`, `assign` and
|
|
182
|
+
`resolve` is present with an unchanged expectation. 0.5.7 adds four cases, all
|
|
183
|
+
opencode-specific — one `suffix` case for opencode session ids, three `assign`
|
|
184
|
+
cases for opencode peers and slug collisions between them. Nothing was modified
|
|
185
|
+
or removed.
|
|
186
|
+
|
|
187
|
+
Muster's own fixture stays frozen at `tincan_version: 0.1.0` with its 31 cases.
|
|
188
|
+
That is the point of the design: frozen expectations are checked against a live
|
|
189
|
+
installed binary, so the suite detects drift rather than absorbing it.
|
|
190
|
+
|
|
82
191
|
## Contract baseline moved to 0.5.2 — 2026-09-20
|
|
83
192
|
|
|
84
193
|
`@brutalsystems/tincan@0.2.0`, this document's verified baseline, was
|
|
@@ -112,8 +221,8 @@ withdrawn npm artifact actually contained.
|
|
|
112
221
|
The upstream release notes report that 0.3.0 expands
|
|
113
222
|
Codex-hosted listings to both runtimes and 0.3.1 corrects Codex status decoding.
|
|
114
223
|
These releases have not been verified by Muster's contract suite. The tested
|
|
115
|
-
baseline
|
|
116
|
-
|
|
224
|
+
baseline is 0.5.7; README installation instructions pin it outside Muster's
|
|
225
|
+
dependency tree.
|
|
117
226
|
|
|
118
227
|
Inspection confirms the contract test does not assert total peer counts,
|
|
119
228
|
same-runtime exclusion, or Tin Can state. Its count assertion applies only to
|
package/README.md
CHANGED
|
@@ -12,6 +12,29 @@ loopback API and local-provider protocol checks. Claude requires a directory
|
|
|
12
12
|
already trusted by the operator. The automated suite uses fake runtimes, not
|
|
13
13
|
real models.
|
|
14
14
|
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install -g @brutalsystems/muster
|
|
19
|
+
muster --version
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Publishing a release does not touch an installed copy: `muster --version` keeps
|
|
23
|
+
reporting the old version until you update it.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm update -g @brutalsystems/muster
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If `which muster` resolves to a version-manager shim (for example
|
|
30
|
+
`~/.asdf/shims/muster`), run that update under the Node the shim resolves to and
|
|
31
|
+
reshim afterwards — `asdf reshim nodejs` — or the shim keeps pointing at the old
|
|
32
|
+
binary.
|
|
33
|
+
|
|
34
|
+
An MCP server starts once at session startup, so an agent session that already
|
|
35
|
+
has Muster loaded keeps running the old binary until that session restarts.
|
|
36
|
+
Updating on disk is not enough; restart the session too.
|
|
37
|
+
|
|
15
38
|
## Build and run locally
|
|
16
39
|
|
|
17
40
|
Requires Node 22.12+ (tested with 24.16), TypeScript 5, Codex 0.155.1, Claude
|
|
@@ -231,8 +254,14 @@ arrive after startup; this v1 is not an enterprise policy-enforcement layer.
|
|
|
231
254
|
Claude permissions and its command sandbox are different mechanisms; neither
|
|
232
255
|
claim implies that every external tool is OS-sandboxed.
|
|
233
256
|
|
|
234
|
-
OpenCode keeps normal provider/model configuration
|
|
235
|
-
which disables inherited external plugins.
|
|
257
|
+
OpenCode keeps normal provider/model configuration and launches with `--pure`
|
|
258
|
+
unless plugins are selected, which disables inherited external plugins.
|
|
259
|
+
Providers declared under `[opencode.provider]` and a model named by
|
|
260
|
+
`[opencode] model` or `--model` are supplied in the same verified overlay, so a
|
|
261
|
+
launch does not depend on the operator's own OpenCode configuration; provider
|
|
262
|
+
definitions merge with any the child resolves for itself rather than replacing
|
|
263
|
+
them. Secrets belong in `api_key_env_var`, read from the launching environment,
|
|
264
|
+
not in `config.toml`. Before launch, Muster inspects the
|
|
236
265
|
effective OpenCode configuration, disables every inherited MCP server and tool
|
|
237
266
|
family, enables only the selected Muster definitions, and fails closed if a
|
|
238
267
|
higher-precedence managed policy prevents that isolation from being proven.
|
|
@@ -240,8 +269,10 @@ higher-precedence managed policy prevents that isolation from being proven.
|
|
|
240
269
|
These child restrictions prevent automatically propagating spawn authority.
|
|
241
270
|
External tools are enabled only through the operator configuration and launch
|
|
242
271
|
selection described below. Codex and Claude runtime inboxes remain reachable
|
|
243
|
-
from an external Tin Can even when they have no MCP tools of their own.
|
|
244
|
-
|
|
272
|
+
from an external Tin Can even when they have no MCP tools of their own. An
|
|
273
|
+
OpenCode session is reachable as a Tin Can peer only when the Tin Can plugin is
|
|
274
|
+
selected with `--plugin`; the plugin, not the MCP server, is what advertises the
|
|
275
|
+
session to other runtimes.
|
|
245
276
|
No user-level runtime configuration is rewritten, and workspace-trust dialogs
|
|
246
277
|
are never accepted automatically.
|
|
247
278
|
|
|
@@ -308,8 +339,18 @@ server has the same name, and its native tool allowlist. Claude receives a priva
|
|
|
308
339
|
Muster stdio connection that filters both tool discovery and calls; newly added
|
|
309
340
|
upstream tools cannot bypass the selection. Only selected tools are preapproved.
|
|
310
341
|
OpenCode disables inherited definitions and tool families in its verified
|
|
311
|
-
per-launch overlay, then enables only selected definitions.
|
|
312
|
-
|
|
342
|
+
per-launch overlay, then enables only selected definitions. A launch that
|
|
343
|
+
selects MCP servers omits the wildcard `*` permission entry and names the
|
|
344
|
+
built-in tools it denies instead: in OpenCode a `*` entry suppresses MCP tools
|
|
345
|
+
outright, and an explicit allow for the tool does not restore them. The
|
|
346
|
+
built-in list is fixed, so a tool added by a future OpenCode release would not
|
|
347
|
+
be denied by it; launches that select no MCP server keep the wildcard. With no plugins
|
|
348
|
+
selected it also uses `--pure` and pins an empty plugin list, so inherited
|
|
349
|
+
plugins do not enter the child process. Selecting a plugin with `--plugin`
|
|
350
|
+
necessarily relaxes that: OpenCode merges project-local `.opencode/plugin`
|
|
351
|
+
discovery into any non-empty plugin list, so the child also loads whatever
|
|
352
|
+
plugins the target repository ships. Select plugins only for repositories
|
|
353
|
+
trusted with that.
|
|
313
354
|
Claude connection specifications are stored with owner-only permissions under
|
|
314
355
|
`~/.muster/mcp`, removed on stop, task completion, or when `list` observes an ended
|
|
315
356
|
session. MCP server access is separate from filesystem sandbox permissions: a
|
|
@@ -372,18 +413,18 @@ The ordinary suite requires tmux, node-pty, POSIX `ps`/`lsof`, and Python 3 for
|
|
|
372
413
|
real file locks in the fake Codex executable. It uses isolated runtime homes and
|
|
373
414
|
no model APIs. It skips the four explicitly invoked compatibility cases.
|
|
374
415
|
|
|
375
|
-
The verified contract baseline is **Tin Can 0.
|
|
416
|
+
The verified contract baseline is **Tin Can 0.6.0**. The suite spawns an installed binary as an MCP
|
|
376
417
|
subprocess, compares durable IDs, and verifies delivery to fake runtimes through
|
|
377
418
|
both terminal hosts. A missing binary or missing durable field fails the test.
|
|
378
419
|
For reproducible verification, install that exact release outside Muster:
|
|
379
420
|
|
|
380
421
|
```sh
|
|
381
422
|
contract_dir=$(mktemp -d)
|
|
382
|
-
npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.
|
|
423
|
+
npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.6.0
|
|
383
424
|
MUSTER_TINCAN_BIN="$contract_dir/node_modules/.bin/tincan" npm run test:contract
|
|
384
425
|
```
|
|
385
426
|
|
|
386
|
-
Verification uses the published 0.
|
|
427
|
+
Verification uses the published 0.6.0 release installed in an isolated
|
|
387
428
|
temporary directory. No Tin Can source edits are made.
|
|
388
429
|
Fixture version metadata alone does not indicate address-format drift: compare
|
|
389
430
|
the case arrays. Those arrays cover naming only, not peer-list membership or
|
|
@@ -403,6 +444,23 @@ real models.
|
|
|
403
444
|
MIT © 2026 Mike Williams. See [LICENSE](./LICENSE). Vendored Tin Can code
|
|
404
445
|
retains source attribution and its [MIT notice](./TINCAN_LICENSE).
|
|
405
446
|
|
|
447
|
+
Publishing is tag-driven and runs in GitHub Actions over OIDC trusted
|
|
448
|
+
publishing, with no stored npm token. A bare `git push` publishes nothing; a
|
|
449
|
+
version tag is what triggers `publish.yml`. That workflow validates a [RELEASE_NOTES.md](./RELEASE_NOTES.md) section for
|
|
450
|
+
the version being published, so write the notes under `## Unreleased` as you do
|
|
451
|
+
the work and commit them normally. Cutting the release is then one command:
|
|
452
|
+
|
|
453
|
+
```sh
|
|
454
|
+
git commit -am "<the change, including its notes under ## Unreleased>"
|
|
455
|
+
npm version patch -m "%s — <what changed>"
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`npm version` runs the `version` hook, which stamps `## Unreleased` into
|
|
459
|
+
`## <version> — <date>` and stages it, then bumps, commits and tags; the
|
|
460
|
+
`postversion` hook pushes the commit and tag together. If there is no
|
|
461
|
+
`## Unreleased` section, or it is empty, the stamp refuses — before a tag
|
|
462
|
+
exists, rather than in CI after one has been pushed.
|
|
463
|
+
|
|
406
464
|
[RELEASING.md](./RELEASING.md) covers versioning, package inspection, publication,
|
|
407
|
-
and release notes. Changes to the shared address format require an explicit
|
|
465
|
+
and release notes in full. Changes to the shared address format require an explicit
|
|
408
466
|
contract update; Muster never independently fixes the frozen naming behavior.
|
package/dist/config.js
CHANGED
|
@@ -4,6 +4,8 @@ import { join } from "node:path";
|
|
|
4
4
|
import TOML from "@iarna/toml";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { mcpName, mcpServerSchema } from "./mcp.js";
|
|
7
|
+
import { pluginName, pluginSchema } from "./plugins.js";
|
|
8
|
+
import { openCodeSectionSchema } from "./providers.js";
|
|
7
9
|
export const configSchema = z
|
|
8
10
|
.object({
|
|
9
11
|
host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
|
|
@@ -20,6 +22,9 @@ export const configSchema = z
|
|
|
20
22
|
.default("read-only"),
|
|
21
23
|
default_mcp: z.array(mcpName).default([]),
|
|
22
24
|
mcp_servers: z.record(mcpName, mcpServerSchema).default({}),
|
|
25
|
+
default_plugins: z.array(pluginName).default([]),
|
|
26
|
+
plugins: z.record(pluginName, pluginSchema).default({}),
|
|
27
|
+
opencode: openCodeSectionSchema,
|
|
23
28
|
allow_dangerous_flags: z.boolean().default(false),
|
|
24
29
|
})
|
|
25
30
|
.strict();
|
package/dist/guard.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { mcpName, claudeMcpConfig } from "./mcp.js";
|
|
4
|
+
import { pluginName } from "./plugins.js";
|
|
4
5
|
export const runSchema = z
|
|
5
6
|
.object({
|
|
6
7
|
runtime: z.enum(["codex", "claude", "opencode"]),
|
|
@@ -19,6 +20,8 @@ export const runSchema = z
|
|
|
19
20
|
permissions: z.enum(["auto", "deny", "bypass"]).optional(),
|
|
20
21
|
sandbox: z.enum(["read-only", "workspace-write", "full-access"]).optional(),
|
|
21
22
|
mcp: z.array(mcpName).optional(),
|
|
23
|
+
plugin: z.array(pluginName).optional(),
|
|
24
|
+
model: z.string().min(1).optional(),
|
|
22
25
|
args: z.array(z.string()).default([]),
|
|
23
26
|
})
|
|
24
27
|
.strict();
|
package/dist/muster.js
CHANGED
|
@@ -28,6 +28,12 @@ and --sandbox read-only|workspace-write|full-access before -- RUNTIME_OPTIONS.
|
|
|
28
28
|
terminal, iterm2, or ghostty, and requires --open.
|
|
29
29
|
--mcp NAME is repeatable and replaces configured session defaults; --no-mcp
|
|
30
30
|
clears them. Tasks use no MCP defaults; select servers explicitly.
|
|
31
|
+
--plugin NAME is repeatable and replaces configured session defaults;
|
|
32
|
+
--no-plugin clears them. OpenCode only: selected plugins load in the child,
|
|
33
|
+
which also admits any plugin the target repository ships.
|
|
34
|
+
--model PROVIDER/MODEL selects the OpenCode model, overriding the configured
|
|
35
|
+
[opencode] model. Providers defined under [opencode.provider] are supplied to
|
|
36
|
+
the child, so launches do not depend on the user's own OpenCode config.
|
|
31
37
|
run, list and stop default to JSON. output prints raw task text.
|
|
32
38
|
|
|
33
39
|
pty launches print their record and keep this process running. Ctrl-C stops the
|
|
@@ -199,15 +205,24 @@ async function main() {
|
|
|
199
205
|
sandbox: { type: "string" },
|
|
200
206
|
mcp: { type: "string", multiple: true },
|
|
201
207
|
"no-mcp": { type: "boolean" },
|
|
208
|
+
plugin: { type: "string", multiple: true },
|
|
209
|
+
model: { type: "string" },
|
|
210
|
+
"no-plugin": { type: "boolean" },
|
|
202
211
|
},
|
|
203
212
|
});
|
|
204
|
-
const { format = "json", "no-mcp": noMcp, ...values } = parsed;
|
|
213
|
+
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, ...values } = parsed;
|
|
205
214
|
if (noMcp !== undefined && command !== "run")
|
|
206
215
|
throw new Error("--no-mcp applies only to run");
|
|
207
216
|
if (noMcp && values.mcp)
|
|
208
217
|
throw new Error("--mcp and --no-mcp cannot be combined");
|
|
209
218
|
if (noMcp)
|
|
210
219
|
values.mcp = [];
|
|
220
|
+
if (noPlugin !== undefined && command !== "run")
|
|
221
|
+
throw new Error("--no-plugin applies only to run");
|
|
222
|
+
if (noPlugin && values.plugin)
|
|
223
|
+
throw new Error("--plugin and --no-plugin cannot be combined");
|
|
224
|
+
if (noPlugin)
|
|
225
|
+
values.plugin = [];
|
|
211
226
|
if (format !== "json" && format !== "human")
|
|
212
227
|
throw new Error("format must be json or human");
|
|
213
228
|
if (command === "output" && parsed.format !== undefined)
|
package/dist/opencode-policy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { resolvePermissions, runtimeArgs } from "./guard.js";
|
|
4
|
+
import { openCodeModel, openCodeProviders } from "./providers.js";
|
|
4
5
|
const exec = promisify(execFile);
|
|
5
6
|
const DEBUG_CONFIG_ERROR = "Cannot verify effective OpenCode MCP configuration";
|
|
6
7
|
const ISOLATION_ERROR = "OpenCode MCP isolation could not be verified; refusing launch";
|
|
@@ -96,15 +97,48 @@ function mcpPolicy(selected, inheritedMcpNames) {
|
|
|
96
97
|
}
|
|
97
98
|
return { effective, mcp, tools };
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* OpenCode's built-in tool ids, as reported by its own tool listing. Needed
|
|
102
|
+
* because a wildcard `*` permission entry suppresses MCP tools outright — an
|
|
103
|
+
* explicit allow for the tool does not rescue it — so a launch that selects
|
|
104
|
+
* MCP servers has to name the built-ins it denies instead of leaning on `*`.
|
|
105
|
+
* A built-in added by a future OpenCode release is not in this list and would
|
|
106
|
+
* not be denied by it; see the isolation note in the README.
|
|
107
|
+
*/
|
|
108
|
+
const OPENCODE_BUILTIN_TOOLS = [
|
|
109
|
+
"invalid",
|
|
110
|
+
"question",
|
|
111
|
+
"bash",
|
|
112
|
+
"read",
|
|
113
|
+
"glob",
|
|
114
|
+
"grep",
|
|
115
|
+
"edit",
|
|
116
|
+
"write",
|
|
117
|
+
"task",
|
|
118
|
+
"webfetch",
|
|
119
|
+
"todowrite",
|
|
120
|
+
"todoread",
|
|
121
|
+
"websearch",
|
|
122
|
+
"skill",
|
|
123
|
+
"apply_patch",
|
|
124
|
+
"patch",
|
|
125
|
+
"list",
|
|
126
|
+
"lsp",
|
|
127
|
+
"external_directory",
|
|
128
|
+
];
|
|
99
129
|
function permissions(req, config, tools) {
|
|
100
130
|
const policy = resolvePermissions(req, config);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
// A selected MCP server cannot coexist with a wildcard entry, so the
|
|
132
|
+
// built-ins are enumerated instead. Without one, `*` still backstops.
|
|
133
|
+
const selectsMcp = Object.values(tools).some((enabled) => enabled);
|
|
134
|
+
const fallback = policy.permissions === "bypass"
|
|
135
|
+
? "allow"
|
|
136
|
+
: policy.permissions === "auto"
|
|
137
|
+
? "ask"
|
|
138
|
+
: "deny";
|
|
139
|
+
const permission = selectsMcp && policy.permissions !== "bypass"
|
|
140
|
+
? Object.fromEntries(OPENCODE_BUILTIN_TOOLS.map((name) => [name, fallback]))
|
|
141
|
+
: { "*": fallback };
|
|
108
142
|
if (policy.permissions !== "bypass") {
|
|
109
143
|
for (const name of [
|
|
110
144
|
"read",
|
|
@@ -137,14 +171,27 @@ function permissions(req, config, tools) {
|
|
|
137
171
|
permission[pattern] = enabled ? "allow" : "deny";
|
|
138
172
|
return permission;
|
|
139
173
|
}
|
|
140
|
-
export function openCodeConfig(req, config, selected, inheritedMcpNames) {
|
|
174
|
+
export function openCodeConfig(req, config, selected, inheritedMcpNames, plugins = [], source = {}) {
|
|
141
175
|
const { mcp, tools } = mcpPolicy(selected, inheritedMcpNames);
|
|
176
|
+
const model = openCodeModel(req.model, config);
|
|
177
|
+
const provider = openCodeProviders(config, source);
|
|
142
178
|
return {
|
|
143
179
|
share: "disabled",
|
|
180
|
+
// Providers merge with whatever the child resolves for itself, so naming
|
|
181
|
+
// one here adds a definition rather than displacing the operator's.
|
|
182
|
+
...(Object.keys(provider).length > 0 && { provider }),
|
|
183
|
+
...(model !== undefined && { model }),
|
|
144
184
|
server: { hostname: "127.0.0.1" },
|
|
145
185
|
permission: permissions(req, config, tools),
|
|
146
186
|
tools,
|
|
147
187
|
mcp,
|
|
188
|
+
// Absent, OpenCode discovers plugins from the user and project plugin
|
|
189
|
+
// directories; `--pure` is what suppresses them. A non-empty array names
|
|
190
|
+
// the operator's selection but does NOT displace project-local discovery,
|
|
191
|
+
// so selecting any plugin also admits whatever the target repository
|
|
192
|
+
// ships under .opencode/plugin. An empty array is the isolated case and
|
|
193
|
+
// is paired with `--pure` at launch.
|
|
194
|
+
plugin: plugins.map((plugin) => plugin.url),
|
|
148
195
|
};
|
|
149
196
|
}
|
|
150
197
|
function mergeRecords(base, override) {
|
|
@@ -290,18 +337,18 @@ export async function openCodeMcpNames(env, cwd, deadline) {
|
|
|
290
337
|
throw new Error(DEBUG_CONFIG_ERROR);
|
|
291
338
|
return Object.keys(config.mcp);
|
|
292
339
|
}
|
|
293
|
-
export async function openCodeSessionLaunch(req, config, selected, inheritedMcpNames, port, env, cwd, deadline) {
|
|
340
|
+
export async function openCodeSessionLaunch(req, config, selected, inheritedMcpNames, port, env, cwd, deadline, plugins = [], source = {}) {
|
|
294
341
|
const extra = runtimeArgs(req);
|
|
295
342
|
const policy = resolvePermissions(req, config);
|
|
296
343
|
const overlay = {
|
|
297
|
-
...openCodeConfig(req, config, selected, inheritedMcpNames),
|
|
344
|
+
...openCodeConfig(req, config, selected, inheritedMcpNames, plugins, source),
|
|
298
345
|
server: { hostname: "127.0.0.1", port },
|
|
299
346
|
};
|
|
300
347
|
const patch = await verifyOverlay(overlay, selected, inheritedMcpNames, env, cwd, deadline);
|
|
301
348
|
return {
|
|
302
349
|
argv: [
|
|
303
350
|
"opencode",
|
|
304
|
-
"--pure",
|
|
351
|
+
...(plugins.length === 0 ? ["--pure"] : []),
|
|
305
352
|
"--hostname",
|
|
306
353
|
"127.0.0.1",
|
|
307
354
|
"--port",
|
|
@@ -316,10 +363,10 @@ export async function openCodeSessionLaunch(req, config, selected, inheritedMcpN
|
|
|
316
363
|
serverUrl: `http://127.0.0.1:${port}`,
|
|
317
364
|
};
|
|
318
365
|
}
|
|
319
|
-
export async function openCodeTaskLaunch(req, config, selected, inheritedMcpNames, env, cwd, deadline) {
|
|
366
|
+
export async function openCodeTaskLaunch(req, config, selected, inheritedMcpNames, env, cwd, deadline, plugins = [], source = {}) {
|
|
320
367
|
const extra = runtimeArgs(req);
|
|
321
368
|
const policy = resolvePermissions(req, config);
|
|
322
|
-
const overlay = openCodeConfig(req, config, selected, inheritedMcpNames);
|
|
369
|
+
const overlay = openCodeConfig(req, config, selected, inheritedMcpNames, plugins, source);
|
|
323
370
|
const patch = await verifyOverlay(overlay, selected, inheritedMcpNames, env, cwd, deadline);
|
|
324
371
|
return {
|
|
325
372
|
argv: [
|
|
@@ -327,7 +374,7 @@ export async function openCodeTaskLaunch(req, config, selected, inheritedMcpName
|
|
|
327
374
|
"run",
|
|
328
375
|
"--format",
|
|
329
376
|
"json",
|
|
330
|
-
"--pure",
|
|
377
|
+
...(plugins.length === 0 ? ["--pure"] : []),
|
|
331
378
|
...(policy.permissions === "auto" ? ["--auto"] : []),
|
|
332
379
|
...extra,
|
|
333
380
|
"--",
|
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
export const pluginName = z.string().regex(/^[a-zA-Z0-9_-]+$/);
|
|
6
|
+
export const pluginSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
path: z.string().min(1),
|
|
9
|
+
})
|
|
10
|
+
.strict();
|
|
11
|
+
/**
|
|
12
|
+
* OpenCode wants plugins as file URLs. Operator definitions are written as
|
|
13
|
+
* ordinary paths, so `~` and relative paths resolve here rather than in the
|
|
14
|
+
* launch path, where a wrong answer would be harder to see.
|
|
15
|
+
*/
|
|
16
|
+
function pluginUrl(path) {
|
|
17
|
+
const expanded = path.startsWith("~/")
|
|
18
|
+
? join(homedir(), path.slice(2))
|
|
19
|
+
: path;
|
|
20
|
+
return pathToFileURL(resolve(expanded)).href;
|
|
21
|
+
}
|
|
22
|
+
export function selectPlugins(req, config) {
|
|
23
|
+
// Personal defaults apply to sessions, not unattended one-shot tasks —
|
|
24
|
+
// the same rule selectMcp uses.
|
|
25
|
+
const names = req.plugin ?? (req.kind === "session" ? config.default_plugins : []);
|
|
26
|
+
return [...new Set(names)].map((name) => {
|
|
27
|
+
const plugin = Object.hasOwn(config.plugins, name)
|
|
28
|
+
? config.plugins[name]
|
|
29
|
+
: undefined;
|
|
30
|
+
if (!plugin)
|
|
31
|
+
throw new Error(`Unknown plugin: ${name}`);
|
|
32
|
+
return { name, url: pluginUrl(plugin.path) };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const providerName = z.string().regex(/^[a-zA-Z0-9_-]+$/);
|
|
3
|
+
const environmentName = z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/);
|
|
4
|
+
const modelId = z.string().min(1);
|
|
5
|
+
/**
|
|
6
|
+
* Deliberately thin. OpenCode's provider schema is far larger; Muster carries
|
|
7
|
+
* only what a launch needs to stand on its own, so the operator does not have
|
|
8
|
+
* to keep a matching definition in ~/.config/opencode. Per-model options are
|
|
9
|
+
* passed through verbatim because model capability flags (tool_call, limit)
|
|
10
|
+
* decide whether an agent can use tools at all.
|
|
11
|
+
*/
|
|
12
|
+
export const providerSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
npm: z.string().min(1).optional(),
|
|
15
|
+
name: z.string().min(1).optional(),
|
|
16
|
+
base_url: z.string().url().optional(),
|
|
17
|
+
api_key: z.string().min(1).optional(),
|
|
18
|
+
api_key_env_var: environmentName.optional(),
|
|
19
|
+
models: z
|
|
20
|
+
.union([
|
|
21
|
+
z.array(modelId),
|
|
22
|
+
z.record(modelId, z.record(z.string(), z.unknown())),
|
|
23
|
+
])
|
|
24
|
+
.default({}),
|
|
25
|
+
})
|
|
26
|
+
.strict()
|
|
27
|
+
.refine((p) => !(p.api_key !== undefined && p.api_key_env_var !== undefined), "api_key and api_key_env_var cannot be combined");
|
|
28
|
+
export const openCodeSectionSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
model: z.string().min(1).optional(),
|
|
31
|
+
provider: z.record(providerName, providerSchema).default({}),
|
|
32
|
+
})
|
|
33
|
+
.strict()
|
|
34
|
+
.default({});
|
|
35
|
+
function models(definition) {
|
|
36
|
+
return Array.isArray(definition.models)
|
|
37
|
+
? Object.fromEntries(definition.models.map((id) => [id, {}]))
|
|
38
|
+
: definition.models;
|
|
39
|
+
}
|
|
40
|
+
function apiKey(name, definition, source) {
|
|
41
|
+
if (definition.api_key !== undefined)
|
|
42
|
+
return definition.api_key;
|
|
43
|
+
if (definition.api_key_env_var === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
const value = source[definition.api_key_env_var];
|
|
46
|
+
// Failing here beats launching a session whose every turn dies on a 401.
|
|
47
|
+
if (value === undefined || value === "")
|
|
48
|
+
throw new Error(`Provider ${name} needs ${definition.api_key_env_var} in the environment`);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The provider/model half of the OpenCode overlay. Providers merge with any
|
|
53
|
+
* the child resolves for itself, so these definitions add to the operator's
|
|
54
|
+
* own configuration rather than replacing it.
|
|
55
|
+
*/
|
|
56
|
+
export function openCodeProviders(config, source) {
|
|
57
|
+
const entries = Object.entries(config.opencode.provider);
|
|
58
|
+
if (entries.length === 0)
|
|
59
|
+
return {};
|
|
60
|
+
return Object.fromEntries(entries.map(([name, definition]) => {
|
|
61
|
+
const key = apiKey(name, definition, source);
|
|
62
|
+
const options = {};
|
|
63
|
+
if (definition.base_url !== undefined)
|
|
64
|
+
options.baseURL = definition.base_url;
|
|
65
|
+
if (key !== undefined)
|
|
66
|
+
options.apiKey = key;
|
|
67
|
+
return [
|
|
68
|
+
name,
|
|
69
|
+
{
|
|
70
|
+
...(definition.npm !== undefined && { npm: definition.npm }),
|
|
71
|
+
...(definition.name !== undefined && { name: definition.name }),
|
|
72
|
+
...(Object.keys(options).length > 0 && { options }),
|
|
73
|
+
models: models(definition),
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
/** `--model` beats the configured default; OpenCode decides when neither is set. */
|
|
79
|
+
export function openCodeModel(requested, config) {
|
|
80
|
+
return requested ?? config.opencode.model;
|
|
81
|
+
}
|
package/dist/run.js
CHANGED
|
@@ -22,6 +22,7 @@ import { allocateLoopbackPort, OpenCodeHttp } from "./opencode-http.js";
|
|
|
22
22
|
import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCodeTaskLaunch, } from "./opencode-policy.js";
|
|
23
23
|
import { assignNames, resolvePeer, runtimeName, } from "./naming.js";
|
|
24
24
|
import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
|
|
25
|
+
import { selectPlugins } from "./plugins.js";
|
|
25
26
|
export class Muster {
|
|
26
27
|
config;
|
|
27
28
|
home;
|
|
@@ -68,6 +69,7 @@ export class Muster {
|
|
|
68
69
|
throw new Error("--open requires a tmux session on macOS");
|
|
69
70
|
const permissions = resolvePermissions(req, this.config);
|
|
70
71
|
const selectedMcp = selectMcp(req, this.config);
|
|
72
|
+
const selectedPlugins = req.runtime === "opencode" ? selectPlugins(req, this.config) : [];
|
|
71
73
|
let argv = [];
|
|
72
74
|
if (req.runtime === "opencode")
|
|
73
75
|
runtimeArgs(req);
|
|
@@ -103,6 +105,9 @@ export class Muster {
|
|
|
103
105
|
...(req.open ? { open: true, terminal } : {}),
|
|
104
106
|
args: req.args,
|
|
105
107
|
mcp: selectedMcp.map((s) => s.name),
|
|
108
|
+
...(selectedPlugins.length
|
|
109
|
+
? { plugins: selectedPlugins.map((p) => p.name) }
|
|
110
|
+
: {}),
|
|
106
111
|
...permissions,
|
|
107
112
|
});
|
|
108
113
|
const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, this.env, this.sourceEnv), req.cwd, deadline);
|
|
@@ -127,7 +132,7 @@ export class Muster {
|
|
|
127
132
|
else if (req.runtime === "opencode" && req.kind === "session") {
|
|
128
133
|
const port = await allocateLoopbackPort();
|
|
129
134
|
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
130
|
-
const launch = await openCodeSessionLaunch(req, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline);
|
|
135
|
+
const launch = await openCodeSessionLaunch(req, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
|
|
131
136
|
argv = launch.argv;
|
|
132
137
|
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
133
138
|
entry = await this.registry.update(entry.launchId, {
|
|
@@ -137,7 +142,7 @@ export class Muster {
|
|
|
137
142
|
}
|
|
138
143
|
else if (req.runtime === "opencode" && req.kind === "task") {
|
|
139
144
|
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
140
|
-
const launch = await openCodeTaskLaunch(req, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline);
|
|
145
|
+
const launch = await openCodeTaskLaunch(req, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
|
|
141
146
|
argv = launch.argv;
|
|
142
147
|
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
143
148
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brutalsystems/muster",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Launch instructed, reachable local agent sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
"pretest:contract": "npm run build",
|
|
19
19
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
20
20
|
"prepack": "npm run build",
|
|
21
|
-
"prepublishOnly": "npm test"
|
|
21
|
+
"prepublishOnly": "npm test",
|
|
22
|
+
"version": "node scripts/stamp-release-notes.mjs && git add RELEASE_NOTES.md",
|
|
23
|
+
"postversion": "git push origin HEAD --follow-tags"
|
|
22
24
|
},
|
|
23
25
|
"dependencies": {
|
|
24
26
|
"@iarna/toml": "^2.2.5",
|