@brutalsystems/muster 0.5.0 → 0.7.0
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 +69 -0
- package/README.md +146 -28
- package/dist/codex-policy.js +39 -13
- package/dist/config.js +3 -0
- package/dist/format.js +6 -0
- package/dist/guard.js +18 -8
- package/dist/hosts/terminal.js +7 -6
- package/dist/hosts/tmux.js +12 -14
- package/dist/identity/opencode.js +86 -0
- package/dist/mcp-bridge.js +91 -0
- package/dist/mcp.js +207 -0
- package/dist/muster.js +22 -4
- package/dist/naming.js +3 -2
- package/dist/opencode-http.js +241 -0
- package/dist/opencode-policy.js +382 -0
- package/dist/reach/opencode.js +17 -0
- package/dist/run.js +130 -18
- package/dist/task-worker.js +2 -0
- package/package.json +5 -2
- package/test/fixtures/canonical-id.json +2 -2
package/CONTRACT_PROVENANCE.md
CHANGED
|
@@ -38,6 +38,75 @@ with the keys `slugify`, `suffix`, `assign`, `resolve` in that order is:
|
|
|
38
38
|
`a92936ef4b713641d29da3fbafccab0e6da79a54e27bfea3c6a779e801a925b3`.
|
|
39
39
|
Both revisions produce this hash.
|
|
40
40
|
|
|
41
|
+
## Privacy remediation edit — 2026-09-20
|
|
42
|
+
|
|
43
|
+
`test/fixtures/canonical-id.json` originally copied a real internal project
|
|
44
|
+
codename as an example `rawName`/`display`/`canonicalId` value. Tin Can made
|
|
45
|
+
the same change to its own copy and asked Muster to match; both projects
|
|
46
|
+
renamed the same two example strings to a neutral placeholder
|
|
47
|
+
(`billing-api`).
|
|
48
|
+
|
|
49
|
+
This is a **local text edit to the frozen 0.1.0 copy**, not a re-sync with
|
|
50
|
+
Tin Can's current upstream fixture. By the time of this edit, Tin Can's
|
|
51
|
+
fixture had moved to `tincan_version: 0.5.2` and gained real new content —
|
|
52
|
+
`opencode` suffix rules, new fixture cases, and new `CANONICAL_ID.md`
|
|
53
|
+
sections describing opencode session-id behavior. Muster's OpenCode
|
|
54
|
+
integration explicitly defers Tin Can/address-format work (see
|
|
55
|
+
`RELEASE_NOTES.md`), so adopting that upstream fixture wholesale would claim
|
|
56
|
+
verification Muster does not have. Per this document's own earlier
|
|
57
|
+
guidance — "their full-file hashes ... must not be compared to an entire
|
|
58
|
+
newer upstream fixture to infer a format change" — only the two leaked
|
|
59
|
+
strings were changed; every other byte of the 0.1.0-vintage fixture is
|
|
60
|
+
unchanged, including its 31 cases and `tincan_version: 0.1.0`.
|
|
61
|
+
|
|
62
|
+
New hash after this edit:
|
|
63
|
+
|
|
64
|
+
| File | SHA-256 |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `test/fixtures/canonical-id.json` | `974cef9e06370a562cef588f1cd6d5c1d83b49afdb64808487759d6280bffff2` |
|
|
67
|
+
|
|
68
|
+
`CANONICAL_ID.md`'s hash is unchanged (`8cf4312d6af1c4d85ec0641d8739bec261be2feb35dd16bdd9d8be2b89551915`)
|
|
69
|
+
— the codename never appeared there.
|
|
70
|
+
|
|
71
|
+
Adopting Tin Can's opencode naming rules, if Muster's OpenCode/Tin Can
|
|
72
|
+
integration is ever un-deferred, is a separate, deliberate compatibility
|
|
73
|
+
check — not something this edit performs.
|
|
74
|
+
|
|
75
|
+
The `at commit fbaaea5842fd4a5c86849d7f51c8e16b8683b058` provenance line
|
|
76
|
+
above may no longer resolve in Tin Can's current history: Tin Can applied
|
|
77
|
+
the same codename remediation via a history rewrite (`git filter-repo
|
|
78
|
+
--replace-text`, force-pushed), which changes the hash of every commit
|
|
79
|
+
whose content or ancestry it touched. This is a historical acquisition
|
|
80
|
+
record, not a claim that the commit is still reachable.
|
|
81
|
+
|
|
82
|
+
## Contract baseline moved to 0.5.2 — 2026-09-20
|
|
83
|
+
|
|
84
|
+
`@brutalsystems/tincan@0.2.0`, this document's verified baseline, was
|
|
85
|
+
withdrawn from the npm registry as part of Tin Can's own codename
|
|
86
|
+
remediation (every version through 0.5.1 carried the same leaked string
|
|
87
|
+
0.7.0's fixture did, in Tin Can's README and fixture). Withdrawn version
|
|
88
|
+
numbers cannot be republished. This is a **registry availability problem,
|
|
89
|
+
not a format change** — Tin Can diffed `src/naming.ts` between 0.2.0 and
|
|
90
|
+
0.5.2 and found `slugify`, `suffixOf`, `assignNames`, and `resolvePeer`
|
|
91
|
+
byte-identical; the only source change is the `RuntimeName` union widening
|
|
92
|
+
to add `'opencode'`. `test/fixtures/canonical-id.json` gained new opencode
|
|
93
|
+
cases with every pre-existing expectation unchanged.
|
|
94
|
+
|
|
95
|
+
Muster's own contract suite was run against a real installed
|
|
96
|
+
`@brutalsystems/tincan@0.5.2` and passed all four cases before this
|
|
97
|
+
re-baseline was made.
|
|
98
|
+
|
|
99
|
+
`0.3.0` through `0.5.1` were never verified by Muster's contract suite (see
|
|
100
|
+
"Upgrade boundary" below) and have also been withdrawn from the registry.
|
|
101
|
+
The verified, currently-available set is exactly `{0.5.2}`.
|
|
102
|
+
|
|
103
|
+
One caveat for anyone reconstructing a baseline from Tin Can's git history
|
|
104
|
+
rather than a published artifact: Tin Can's git tags predating 2026-09-20
|
|
105
|
+
no longer match what npm ever served at that version number, because the
|
|
106
|
+
history-rewrite in the section above applies retroactively to every commit,
|
|
107
|
+
tagged ones included. Git content at an old tag is not evidence of what a
|
|
108
|
+
withdrawn npm artifact actually contained.
|
|
109
|
+
|
|
41
110
|
## Upgrade boundary: listing and state
|
|
42
111
|
|
|
43
112
|
The upstream release notes report that 0.3.0 expands
|
package/README.md
CHANGED
|
@@ -2,21 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
[](./LICENSE)
|
|
4
4
|
|
|
5
|
-
Muster launches instructed Codex
|
|
5
|
+
Muster launches instructed Codex, Claude Code, or OpenCode agents on this machine. A
|
|
6
6
|
`session` returns an address only after its runtime is reachable. A `task`
|
|
7
7
|
runs once, captures output, and never advertises a peer address.
|
|
8
8
|
|
|
9
9
|
**Live launch verification:** Codex and Claude both launched and answered their
|
|
10
|
-
initial prompts without terminal interaction.
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
initial prompts without terminal interaction. OpenCode 1.18.31 passed the
|
|
11
|
+
loopback API and local-provider protocol checks. Claude requires a directory
|
|
12
|
+
already trusted by the operator. The automated suite uses fake runtimes, not
|
|
13
|
+
real models.
|
|
13
14
|
|
|
14
15
|
## Build and run locally
|
|
15
16
|
|
|
16
|
-
Requires Node 22.12+ (tested with 24.16), TypeScript 5,
|
|
17
|
-
Code 2.1.267
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
Requires Node 22.12+ (tested with 24.16), TypeScript 5, Codex 0.155.1, Claude
|
|
18
|
+
Code 2.1.267, and OpenCode 1.18.31 or newer. Newer OpenCode releases must retain
|
|
19
|
+
the CLI and loopback HTTP API used by Muster. Terminal drivers use tmux or
|
|
20
|
+
node-pty. Process discovery currently requires a POSIX host with `ps` and
|
|
21
|
+
`lsof`; the terminal interface itself is platform-neutral. The macOS Terminal
|
|
22
|
+
driver is an unavailable v1 stub.
|
|
20
23
|
|
|
21
24
|
Before launching a Claude session, open Claude normally in the target directory
|
|
22
25
|
and complete its workspace-trust review yourself, then exit that setup session.
|
|
@@ -29,7 +32,10 @@ npm ci
|
|
|
29
32
|
npm run build
|
|
30
33
|
node dist/muster.js run codex --prompt 'Review the authentication flow'
|
|
31
34
|
node dist/muster.js run claude --prompt 'Summarize the project' --host tmux
|
|
35
|
+
node dist/muster.js run opencode --prompt 'Review this project'
|
|
36
|
+
node dist/muster.js run opencode --prompt 'Build a plan' -- --model coldfire-local/qwen3-30b-a3b
|
|
32
37
|
node dist/muster.js run codex --kind task --prompt 'Explain the test layout'
|
|
38
|
+
node dist/muster.js run opencode --kind task --prompt 'Summarize the tests'
|
|
33
39
|
node dist/muster.js list
|
|
34
40
|
node dist/muster.js list --format human
|
|
35
41
|
node dist/muster.js list --kind task
|
|
@@ -64,11 +70,25 @@ red for failures or nonzero exit codes, and cyan for follow-up commands. IDs
|
|
|
64
70
|
and paths retain the normal text color. Set `NO_COLOR=1` to disable colors;
|
|
65
71
|
piped output and `TERM=dumb` are always uncolored. JSON never includes colors.
|
|
66
72
|
|
|
67
|
-
`run` accepts an optional `--` followed by runtime arguments. The normal
|
|
68
|
-
is `--model` / `-m`, plus Claude's `--effort
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
`run` accepts an optional `--` followed by runtime arguments. The normal
|
|
74
|
+
allowlist is `--model` / `-m`, plus Claude's `--effort` and OpenCode's `--agent`
|
|
75
|
+
and `--variant`. Unknown options, bundled short options, config injection and
|
|
76
|
+
raw permission overrides are refused, even with `allow_dangerous_flags`
|
|
77
|
+
enabled. Use Muster's normalized permission flags. Prompt strings are always
|
|
78
|
+
passed as one literal argument. Codex and Claude launches and OpenCode tasks put
|
|
79
|
+
that value after `--`; OpenCode sessions use the dedicated `--prompt` value.
|
|
80
|
+
|
|
81
|
+
OpenCode inherits the provider and model definitions from its normal user and
|
|
82
|
+
project configuration, so configured local OpenAI-compatible providers such as
|
|
83
|
+
`coldfire-local/qwen3-30b-a3b` remain available. Muster never rewrites those
|
|
84
|
+
files. Put runtime options after `--`, as in the local-model example above.
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
muster run opencode --prompt 'Review this project'
|
|
88
|
+
muster run opencode --prompt 'Build a plan' -- --model coldfire-local/qwen3-30b-a3b
|
|
89
|
+
muster run opencode --kind task --prompt 'Summarize the tests'
|
|
90
|
+
muster run opencode --mcp tincan --permissions auto --sandbox workspace-write --prompt 'Coordinate'
|
|
91
|
+
```
|
|
72
92
|
|
|
73
93
|
## Terminal lifetime
|
|
74
94
|
|
|
@@ -77,6 +97,7 @@ Add `--open` to display the launched session in a new Terminal.app window on mac
|
|
|
77
97
|
```sh
|
|
78
98
|
muster run codex --open --prompt "Review this project" --format human
|
|
79
99
|
muster run claude --open --terminal ghostty --prompt "Review this project" --format human
|
|
100
|
+
muster run opencode --open --terminal iterm2 --prompt "Review this project" --format human
|
|
80
101
|
muster run codex --open --terminal iterm2 --prompt "Review this project" --format human
|
|
81
102
|
```
|
|
82
103
|
|
|
@@ -91,7 +112,10 @@ the resolved `terminal` (a launch event, not a live window-status check).
|
|
|
91
112
|
Closing the Terminal window leaves the session running; use `muster stop <id>`
|
|
92
113
|
to stop it. Terminal.app and iTerm2 may require macOS Automation permission.
|
|
93
114
|
Ghostty opens a separate app instance that exits when its last window closes;
|
|
94
|
-
|
|
115
|
+
that instance disables saved-window restoration so previous tabs and windows
|
|
116
|
+
are not duplicated. It uses the [Ghostty macOS launch interface](https://github.com/ghostty-org/ghostty/discussions/9221).
|
|
117
|
+
Terminal.app creates the requested window before activation
|
|
118
|
+
to avoid also opening a default shell window on startup.
|
|
95
119
|
iTerm2 uses its [documented scripting interface](https://iterm2.com/documentation-scripting.html).
|
|
96
120
|
If the launch request to the viewer fails,
|
|
97
121
|
Muster stops the launched session and returns an error.
|
|
@@ -99,9 +123,10 @@ Muster stops the launched session and returns an error.
|
|
|
99
123
|
Auto-selection tries tmux, then pty. Every peer includes `host`, `capabilities`
|
|
100
124
|
and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
|
|
101
125
|
|
|
102
|
-
- **tmux:**
|
|
103
|
-
server.
|
|
104
|
-
|
|
126
|
+
- **tmux:** each agent has its own session on the dedicated `muster` tmux
|
|
127
|
+
server. Opening or exiting one agent does not switch another agent's viewer.
|
|
128
|
+
Watchable and attachable; survives the CLI or MCP server exiting.
|
|
129
|
+
Use the returned attach hint to reconnect to that agent.
|
|
105
130
|
- **pty:** not watchable or attachable. The CLI prints the peer record and stays
|
|
106
131
|
running to own the terminal. Ctrl-C stops it. MCP-owned pty sessions stop when
|
|
107
132
|
the MCP server disconnects. There is no persistent pty daemon.
|
|
@@ -112,6 +137,16 @@ and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
|
|
|
112
137
|
or startup failure cleans the process tree and host window, logs failure, and
|
|
113
138
|
returns an error instead of a peer record.
|
|
114
139
|
|
|
140
|
+
Each OpenCode session owns a separate HTTP server bound to `127.0.0.1` on a
|
|
141
|
+
Muster-selected port. Muster verifies that the listener belongs to the launched
|
|
142
|
+
process tree, stores its `server_url` and durable `session_id`, and never attaches
|
|
143
|
+
to an existing personal OpenCode server. `list` refreshes the session through
|
|
144
|
+
that endpoint; `stop` aborts it best-effort and still performs authoritative
|
|
145
|
+
process-tree cleanup. The URL is local control metadata, not a public address.
|
|
146
|
+
OpenCode sessions otherwise use the same tmux/pty, viewer, list, stop, and human
|
|
147
|
+
output behavior described above; OpenCode tasks use the same detached output
|
|
148
|
+
and exit-status lifecycle as other tasks.
|
|
149
|
+
|
|
115
150
|
## Configuration and permissions
|
|
116
151
|
|
|
117
152
|
Muster reads `~/.muster/config.toml` once at startup and **never writes it**.
|
|
@@ -124,6 +159,7 @@ max_concurrent = 4
|
|
|
124
159
|
permissions = "deny"
|
|
125
160
|
sandbox = "read-only"
|
|
126
161
|
allow_dangerous_flags = false
|
|
162
|
+
default_mcp = []
|
|
127
163
|
```
|
|
128
164
|
|
|
129
165
|
Choose `sandbox = "workspace-write"` yourself when agents should edit files.
|
|
@@ -141,11 +177,11 @@ muster run claude --permissions auto --sandbox workspace-write \
|
|
|
141
177
|
--prompt 'Implement the change' --format human
|
|
142
178
|
```
|
|
143
179
|
|
|
144
|
-
| Muster permissions | Codex translation
|
|
145
|
-
|
|
146
|
-
| `deny` (default)
|
|
147
|
-
| `auto`
|
|
148
|
-
| `bypass`
|
|
180
|
+
| Muster permissions | Codex translation | Claude Code translation | OpenCode translation |
|
|
181
|
+
| ------------------ | ---------------------------- | ----------------------------------- | ------------------------------------------------------------------------------- |
|
|
182
|
+
| `deny` (default) | Approval policy `never` | Permission mode `dontAsk` | Deny write, shell, task, external-directory, web, and unselected MCP operations |
|
|
183
|
+
| `auto` | `--approve-for-me` | Permission mode `auto` | `--auto` with explicit deny rules |
|
|
184
|
+
| `bypass` | Bypass approvals and sandbox | Permission mode `bypassPermissions` | Allow supported operations |
|
|
149
185
|
|
|
150
186
|
`deny` refuses actions that would require approval; it does not prohibit tools
|
|
151
187
|
already allowed by the sandbox or permission rules. `auto` delegates permission
|
|
@@ -170,12 +206,18 @@ launch settings, not a continuous attestation of remote policy or user changes.
|
|
|
170
206
|
See [Codex auto-review](https://learn.chatgpt.com/docs/sandboxing/auto-review) and
|
|
171
207
|
[Claude permission modes](https://code.claude.com/docs/en/permission-modes).
|
|
172
208
|
|
|
209
|
+
OpenCode permissions are tool policy, not an OS-level filesystem sandbox.
|
|
210
|
+
Muster's read-only OpenCode policy denies editing, shell, subagent,
|
|
211
|
+
external-directory, web, and MCP operations except explicitly selected MCP
|
|
212
|
+
tools; it does not claim kernel-enforced isolation. Auto still requires
|
|
213
|
+
`workspace-write`, and bypass still requires authorized `full-access`.
|
|
214
|
+
|
|
173
215
|
The concurrency cap is shared by separate CLI/MCP processes, including pending
|
|
174
216
|
launches. Settings apply to both kinds; tasks are read-only by default.
|
|
175
217
|
|
|
176
218
|
Codex defaults to an explicit sandbox and never-ask approval policy. Muster enumerates
|
|
177
|
-
and explicitly disables inherited MCP servers,
|
|
178
|
-
|
|
219
|
+
and explicitly disables inherited MCP servers, enables only the selected
|
|
220
|
+
operator definitions, then verifies the effective server selection. Hooks, plugins, app connectors, automatic skill-MCP installation
|
|
179
221
|
and external notifications are disabled for the child. Unknown MCP names that
|
|
180
222
|
cannot be addressed safely are refused.
|
|
181
223
|
|
|
@@ -189,9 +231,17 @@ arrive after startup; this v1 is not an enterprise policy-enforcement layer.
|
|
|
189
231
|
Claude permissions and its command sandbox are different mechanisms; neither
|
|
190
232
|
claim implies that every external tool is OS-sandboxed.
|
|
191
233
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
234
|
+
OpenCode keeps normal provider/model configuration but launches with `--pure`,
|
|
235
|
+
which disables inherited external plugins. Before launch, Muster inspects the
|
|
236
|
+
effective OpenCode configuration, disables every inherited MCP server and tool
|
|
237
|
+
family, enables only the selected Muster definitions, and fails closed if a
|
|
238
|
+
higher-precedence managed policy prevents that isolation from being proven.
|
|
239
|
+
|
|
240
|
+
These child restrictions prevent automatically propagating spawn authority.
|
|
241
|
+
External tools are enabled only through the operator configuration and launch
|
|
242
|
+
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. OpenCode
|
|
244
|
+
delivery remains deferred as documented under Tin Can compatibility.
|
|
195
245
|
No user-level runtime configuration is rewritten, and workspace-trust dialogs
|
|
196
246
|
are never accepted automatically.
|
|
197
247
|
|
|
@@ -202,6 +252,69 @@ requester, runtime, kind and host. Registry and task outputs also live under
|
|
|
202
252
|
closed: verify no Muster operation is running before removing that directory.
|
|
203
253
|
There is no automatic time-based lock theft.
|
|
204
254
|
|
|
255
|
+
## Optional tools for launched agents
|
|
256
|
+
|
|
257
|
+
MCP servers are optional. Muster does not require or install Tin Can (or any
|
|
258
|
+
other MCP server). Register only servers you want launched agents to use in
|
|
259
|
+
`~/.muster/config.toml`. For example, to use an already-installed Tin Can:
|
|
260
|
+
|
|
261
|
+
```toml
|
|
262
|
+
# Optional personal defaults for sessions. Omit to keep launches MCP-free.
|
|
263
|
+
default_mcp = ["tincan"]
|
|
264
|
+
|
|
265
|
+
[mcp_servers.tincan]
|
|
266
|
+
command = "tincan"
|
|
267
|
+
tools = ["peers", "send_peer", "message_log"]
|
|
268
|
+
required = false
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Then any runtime can use it:
|
|
272
|
+
|
|
273
|
+
```sh
|
|
274
|
+
muster run codex --mcp tincan --prompt 'Coordinate with the other agent'
|
|
275
|
+
muster run claude --mcp tincan --prompt 'Coordinate with the other agent'
|
|
276
|
+
muster run opencode --mcp tincan --permissions auto --sandbox workspace-write \
|
|
277
|
+
--prompt 'Coordinate'
|
|
278
|
+
muster run codex --no-mcp --prompt 'Work without external tools'
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`--mcp NAME` is repeatable and **replaces** personal defaults. `--no-mcp` clears
|
|
282
|
+
them and cannot be combined with `--mcp`. MCP `run` takes `mcp: ["tincan"]`;
|
|
283
|
+
`mcp: []` disables all servers. Omitted selection uses `default_mcp` for sessions.
|
|
284
|
+
Tasks have no defaults, but accept an explicit selection.
|
|
285
|
+
|
|
286
|
+
Definitions support either `command` plus optional `args`, `env`, and `env_vars`
|
|
287
|
+
for stdio, or `url` plus optional `bearer_token_env_var` for Streamable HTTP.
|
|
288
|
+
`tools` is a required nonempty list of exact tool names. Use `env_vars` and
|
|
289
|
+
`bearer_token_env_var` to reference credentials in the launch environment;
|
|
290
|
+
credentials are not written to launch logs. HTTP OAuth setup shared across
|
|
291
|
+
runtimes, plugin/app tools, MCP resources and prompts are outside this interface.
|
|
292
|
+
Server and tool names must contain only letters, digits, underscores or hyphens.
|
|
293
|
+
Relative stdio commands run from the requested working directory; prefer an
|
|
294
|
+
absolute executable path or a command on PATH.
|
|
295
|
+
|
|
296
|
+
Before agent launch, Muster initializes each selected server and checks its tool
|
|
297
|
+
catalog. No tool is called during preflight. An explicit `--mcp` selection is
|
|
298
|
+
required: a missing server, missing credential, or missing configured tool fails
|
|
299
|
+
that launch. Defaults are optional unless their definition sets `required = true`;
|
|
300
|
+
an unavailable optional default is skipped with `mcp_warnings`. Each preflight is
|
|
301
|
+
bounded by `startup_timeout_sec` (default 10, maximum 60) and the launch deadline.
|
|
302
|
+
JSON results and `list` report selected logical names in `mcp`; human output shows
|
|
303
|
+
`MCP:` and any warnings. These describe launch configuration, not continuous
|
|
304
|
+
server health.
|
|
305
|
+
|
|
306
|
+
Codex receives isolated definitions, using a fresh runtime name when an inherited
|
|
307
|
+
server has the same name, and its native tool allowlist. Claude receives a private
|
|
308
|
+
Muster stdio connection that filters both tool discovery and calls; newly added
|
|
309
|
+
upstream tools cannot bypass the selection. Only selected tools are preapproved.
|
|
310
|
+
OpenCode disables inherited definitions and tool families in its verified
|
|
311
|
+
per-launch overlay, then enables only selected definitions. It also uses
|
|
312
|
+
`--pure`, so inherited plugins do not enter the child process.
|
|
313
|
+
Claude connection specifications are stored with owner-only permissions under
|
|
314
|
+
`~/.muster/mcp`, removed on stop, task completion, or when `list` observes an ended
|
|
315
|
+
session. MCP server access is separate from filesystem sandbox permissions: a
|
|
316
|
+
selected external tool may have its own write or network capabilities.
|
|
317
|
+
|
|
205
318
|
## MCP installation
|
|
206
319
|
|
|
207
320
|
Installed deliberately, in the one session that should hold spawn authority —
|
|
@@ -242,6 +355,11 @@ Codex `idle` means reachable and not known to be busy, not guaranteed free.
|
|
|
242
355
|
The querying app-server can report `notLoaded` for a live thread; Muster maps
|
|
243
356
|
that to `idle`. Claude state comes from its session registry.
|
|
244
357
|
|
|
358
|
+
OpenCode peers expose a durable `session_id` and loopback-only `server_url` so a
|
|
359
|
+
future Tin Can adapter has a stable integration seam. Tin Can does not yet send
|
|
360
|
+
to OpenCode through Muster: choosing native OpenCode messaging versus A2A, and
|
|
361
|
+
updating Tin Can for that transport, are explicitly deferred follow-up work.
|
|
362
|
+
|
|
245
363
|
## Verification
|
|
246
364
|
|
|
247
365
|
```sh
|
|
@@ -261,7 +379,7 @@ For reproducible verification, install that exact release outside Muster:
|
|
|
261
379
|
|
|
262
380
|
```sh
|
|
263
381
|
contract_dir=$(mktemp -d)
|
|
264
|
-
npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.2
|
|
382
|
+
npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.5.2
|
|
265
383
|
MUSTER_TINCAN_BIN="$contract_dir/node_modules/.bin/tincan" npm run test:contract
|
|
266
384
|
```
|
|
267
385
|
|
package/dist/codex-policy.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import TOML from "@iarna/toml";
|
|
4
|
+
import { codexMcpConfig } from "./mcp.js";
|
|
3
5
|
const exec = promisify(execFile);
|
|
4
|
-
export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000) {
|
|
6
|
+
export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000, selected = []) {
|
|
5
7
|
const args = [
|
|
6
8
|
"--disable",
|
|
7
9
|
"hooks",
|
|
@@ -17,24 +19,48 @@ export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000) {
|
|
|
17
19
|
"notify=[]",
|
|
18
20
|
];
|
|
19
21
|
async function list(extra) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await exec("codex", [...extra, "mcp", "list", "--json"], {
|
|
24
|
+
env,
|
|
25
|
+
cwd,
|
|
26
|
+
timeout: Math.max(1, Math.min(4000, deadline - Date.now())),
|
|
27
|
+
maxBuffer: 1024 * 1024,
|
|
28
|
+
});
|
|
29
|
+
const value = JSON.parse(stdout);
|
|
30
|
+
if (!Array.isArray(value))
|
|
31
|
+
throw new Error("Cannot verify effective Codex MCP configuration");
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
28
35
|
throw new Error("Cannot verify effective Codex MCP configuration");
|
|
29
|
-
|
|
36
|
+
}
|
|
30
37
|
}
|
|
31
|
-
|
|
38
|
+
const inherited = await list(args);
|
|
39
|
+
const usedNames = new Set(inherited.map((s) => s.name));
|
|
40
|
+
for (const server of inherited) {
|
|
32
41
|
if (typeof server.name !== "string" ||
|
|
33
42
|
!/^[a-zA-Z0-9_-]+$/.test(server.name))
|
|
34
43
|
throw new Error("Cannot safely disable an inherited MCP server with a non-simple name");
|
|
35
44
|
args.push("-c", `mcp_servers.${server.name}.enabled=false`);
|
|
36
45
|
}
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
const expected = [];
|
|
47
|
+
for (const server of selected) {
|
|
48
|
+
let name = server.name;
|
|
49
|
+
let suffix = 0;
|
|
50
|
+
// Config layers merge nested maps. A fresh key prevents inherited env,
|
|
51
|
+
// credentials or per-tool policy from surviving the requested definition.
|
|
52
|
+
while (usedNames.has(name))
|
|
53
|
+
name = `muster_${server.name}_${++suffix}`;
|
|
54
|
+
usedNames.add(name);
|
|
55
|
+
expected.push(name);
|
|
56
|
+
args.push("-c", `mcp_servers.${name}=${TOML.stringify.value(codexMcpConfig(server))}`);
|
|
57
|
+
}
|
|
58
|
+
const enabled = (await list(args))
|
|
59
|
+
.filter((s) => s.enabled !== false)
|
|
60
|
+
.map((s) => s.name)
|
|
61
|
+
.sort();
|
|
62
|
+
expected.sort();
|
|
63
|
+
if (JSON.stringify(enabled) !== JSON.stringify(expected))
|
|
64
|
+
throw new Error("Inherited Codex MCP servers remain enabled or requested servers are missing; refusing launch");
|
|
39
65
|
return args;
|
|
40
66
|
}
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import TOML from "@iarna/toml";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { mcpName, mcpServerSchema } from "./mcp.js";
|
|
6
7
|
export const configSchema = z
|
|
7
8
|
.object({
|
|
8
9
|
host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
|
|
@@ -17,6 +18,8 @@ export const configSchema = z
|
|
|
17
18
|
"danger-full-access",
|
|
18
19
|
])
|
|
19
20
|
.default("read-only"),
|
|
21
|
+
default_mcp: z.array(mcpName).default([]),
|
|
22
|
+
mcp_servers: z.record(mcpName, mcpServerSchema).default({}),
|
|
20
23
|
allow_dangerous_flags: z.boolean().default(false),
|
|
21
24
|
})
|
|
22
25
|
.strict();
|
package/dist/format.js
CHANGED
|
@@ -35,11 +35,17 @@ function recordHuman(record, color) {
|
|
|
35
35
|
];
|
|
36
36
|
if (record.canonical_id)
|
|
37
37
|
lines.push(field("Address", clean(record.canonical_id)));
|
|
38
|
+
if (record.runtime === "opencode" && record.server_url)
|
|
39
|
+
lines.push(field("Control URL", clean(record.server_url)));
|
|
38
40
|
lines.push(field("Directory", clean(record.cwd)));
|
|
39
41
|
if (record.permissions)
|
|
40
42
|
lines.push(field("Permissions", clean(record.permissions)));
|
|
41
43
|
if (record.sandbox)
|
|
42
44
|
lines.push(field("Sandbox", clean(record.sandbox)));
|
|
45
|
+
if (record.mcp)
|
|
46
|
+
lines.push(field("MCP", record.mcp.length ? record.mcp.map(clean).join(", ") : "none"));
|
|
47
|
+
for (const warning of record.mcp_warnings ?? [])
|
|
48
|
+
lines.push(field("MCP warning", clean(warning)));
|
|
43
49
|
if (record.host)
|
|
44
50
|
lines.push(field("Host", `${clean(record.host)}${record.host === "pty" ? " (not watchable or attachable)" : ""}`));
|
|
45
51
|
if (record.terminal_opened)
|
package/dist/guard.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { mcpName, claudeMcpConfig } from "./mcp.js";
|
|
3
4
|
export const runSchema = z
|
|
4
5
|
.object({
|
|
5
|
-
runtime: z.enum(["codex", "claude"]),
|
|
6
|
+
runtime: z.enum(["codex", "claude", "opencode"]),
|
|
6
7
|
prompt: z
|
|
7
8
|
.string()
|
|
8
9
|
.refine((s) => s.trim().length > 0, "prompt must not be blank"),
|
|
@@ -17,6 +18,7 @@ export const runSchema = z
|
|
|
17
18
|
terminal: z.enum(["auto", "terminal", "iterm2", "ghostty"]).optional(),
|
|
18
19
|
permissions: z.enum(["auto", "deny", "bypass"]).optional(),
|
|
19
20
|
sandbox: z.enum(["read-only", "workspace-write", "full-access"]).optional(),
|
|
21
|
+
mcp: z.array(mcpName).optional(),
|
|
20
22
|
args: z.array(z.string()).default([]),
|
|
21
23
|
})
|
|
22
24
|
.strict();
|
|
@@ -35,12 +37,16 @@ export function resolvePermissions(req, config) {
|
|
|
35
37
|
throw new Error("--permissions bypass requires --sandbox full-access");
|
|
36
38
|
return { permissions, sandbox };
|
|
37
39
|
}
|
|
38
|
-
function
|
|
40
|
+
export function runtimeArgs(req) {
|
|
39
41
|
const result = [];
|
|
42
|
+
const allowed = req.runtime === "claude"
|
|
43
|
+
? ["--model", "-m", "--effort"]
|
|
44
|
+
: req.runtime === "opencode"
|
|
45
|
+
? ["--model", "-m", "--agent", "--variant"]
|
|
46
|
+
: ["--model", "-m"];
|
|
40
47
|
for (let i = 0; i < req.args.length; i++) {
|
|
41
48
|
const arg = req.args[i], flag = arg.split("=")[0].toLowerCase().replaceAll("_", "-");
|
|
42
|
-
if (!
|
|
43
|
-
(flag === "--effort" && req.runtime !== "claude"))
|
|
49
|
+
if (!allowed.includes(flag))
|
|
44
50
|
throw new Error(`Runtime option refused: ${arg}`);
|
|
45
51
|
if (arg.includes("="))
|
|
46
52
|
result.push(arg);
|
|
@@ -53,8 +59,8 @@ function extras(req) {
|
|
|
53
59
|
}
|
|
54
60
|
return result;
|
|
55
61
|
}
|
|
56
|
-
export function launchArgs(req, config) {
|
|
57
|
-
const extra =
|
|
62
|
+
export function launchArgs(req, config, mcp = [], bridgePath) {
|
|
63
|
+
const extra = runtimeArgs(req);
|
|
58
64
|
const policy = resolvePermissions(req, config);
|
|
59
65
|
if (req.runtime === "codex")
|
|
60
66
|
return [
|
|
@@ -89,7 +95,11 @@ export function launchArgs(req, config) {
|
|
|
89
95
|
disableAllHooks: true,
|
|
90
96
|
permissions: {
|
|
91
97
|
defaultMode: mode,
|
|
92
|
-
|
|
98
|
+
allow: mcp.flatMap((s) => s.config.tools.map((t) => `mcp__${s.name}__${t}`)),
|
|
99
|
+
deny: [
|
|
100
|
+
...(readonly ? ["Edit", "Write", "NotebookEdit"] : []),
|
|
101
|
+
...mcp.flatMap((s) => s.excludedTools.map((t) => `mcp__${s.name}__${t}`)),
|
|
102
|
+
],
|
|
93
103
|
additionalDirectories: [],
|
|
94
104
|
},
|
|
95
105
|
sandbox: {
|
|
@@ -112,7 +122,7 @@ export function launchArgs(req, config) {
|
|
|
112
122
|
"",
|
|
113
123
|
"--strict-mcp-config",
|
|
114
124
|
"--mcp-config",
|
|
115
|
-
|
|
125
|
+
JSON.stringify(claudeMcpConfig(mcp, bridgePath)),
|
|
116
126
|
"--settings",
|
|
117
127
|
JSON.stringify(settings),
|
|
118
128
|
...extra,
|
package/dist/hosts/terminal.js
CHANGED
|
@@ -56,22 +56,22 @@ export async function openTerminal(tmux, server, hostRef, deadline, terminal = "
|
|
|
56
56
|
"Ghostty",
|
|
57
57
|
"--args",
|
|
58
58
|
"--quit-after-last-window-closed=true",
|
|
59
|
+
// A separate instance otherwise restores the user's saved tabs/windows.
|
|
60
|
+
"--window-save-state=never",
|
|
59
61
|
"-e",
|
|
60
62
|
tmux,
|
|
61
63
|
"-L",
|
|
62
64
|
server,
|
|
63
65
|
"attach-session",
|
|
64
66
|
"-t",
|
|
65
|
-
"muster",
|
|
66
|
-
";",
|
|
67
|
-
"select-window",
|
|
68
|
-
"-t",
|
|
69
67
|
hostRef,
|
|
70
68
|
], timeout);
|
|
71
69
|
return;
|
|
72
70
|
}
|
|
73
71
|
const quoted = "'" + tmux.replaceAll("'", "'\\''") + "'";
|
|
74
|
-
const shellCommand = `${quoted} -L ${server} attach-session -t
|
|
72
|
+
const shellCommand = `${quoted} -L ${server} attach-session -t ${hostRef}`;
|
|
73
|
+
// Terminal must create the requested window before activation. Activating
|
|
74
|
+
// a cold app first also creates its default shell window.
|
|
75
75
|
await command("/usr/bin/osascript", [
|
|
76
76
|
"-e",
|
|
77
77
|
terminal === "iterm2"
|
|
@@ -83,8 +83,9 @@ export async function openTerminal(tmux, server, hostRef, deadline, terminal = "
|
|
|
83
83
|
end run`
|
|
84
84
|
: `on run argv
|
|
85
85
|
tell application "Terminal"
|
|
86
|
-
|
|
86
|
+
launch
|
|
87
87
|
do script (item 1 of argv)
|
|
88
|
+
activate
|
|
88
89
|
end tell
|
|
89
90
|
end run`,
|
|
90
91
|
shellCommand,
|
package/dist/hosts/tmux.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -52,13 +53,9 @@ export class TmuxHost {
|
|
|
52
53
|
file,
|
|
53
54
|
];
|
|
54
55
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
output = await this.call(["new-window", "-t", "muster", ...common], opts.deadline);
|
|
61
|
-
}
|
|
56
|
+
// Window selection is session-wide: sharing a session makes every
|
|
57
|
+
// attached terminal follow whichever agent was opened most recently.
|
|
58
|
+
const output = await this.call(["new-session", "-s", `muster-${randomUUID()}`, ...common], opts.deadline);
|
|
62
59
|
const [hostRef, pid] = output.trim().split("\t");
|
|
63
60
|
if (!hostRef || !/^@\d+$/.test(hostRef) || !Number(pid))
|
|
64
61
|
throw new Error("tmux returned an invalid window identity");
|
|
@@ -69,8 +66,7 @@ export class TmuxHost {
|
|
|
69
66
|
try {
|
|
70
67
|
const windows = await this.call([
|
|
71
68
|
"list-windows",
|
|
72
|
-
"-
|
|
73
|
-
"muster",
|
|
69
|
+
"-a",
|
|
74
70
|
"-F",
|
|
75
71
|
"#{window_id}\t#{pane_start_command}",
|
|
76
72
|
]);
|
|
@@ -87,14 +83,16 @@ export class TmuxHost {
|
|
|
87
83
|
try {
|
|
88
84
|
return (await this.call([
|
|
89
85
|
"list-windows",
|
|
90
|
-
"-
|
|
91
|
-
"muster",
|
|
86
|
+
"-a",
|
|
92
87
|
"-F",
|
|
93
|
-
"#{window_id}\t#{pane_pid}\t#{window_name}",
|
|
88
|
+
"#{window_id}\t#{pane_pid}\t#{window_name}\t#{session_name}",
|
|
94
89
|
]))
|
|
95
90
|
.trim()
|
|
96
91
|
.split("\n")
|
|
97
|
-
.filter(
|
|
92
|
+
.filter((l) => {
|
|
93
|
+
const session = l.split("\t")[3];
|
|
94
|
+
return (session === "muster" || /^muster-[0-9a-f-]{36}$/.test(session ?? ""));
|
|
95
|
+
})
|
|
98
96
|
.map((l) => {
|
|
99
97
|
const [hostRef, pid, label] = l.split("\t");
|
|
100
98
|
return { hostRef: hostRef, pid: Number(pid), label: label };
|
|
@@ -117,6 +115,6 @@ export class TmuxHost {
|
|
|
117
115
|
}
|
|
118
116
|
}
|
|
119
117
|
attachHint(hostRef) {
|
|
120
|
-
return `tmux -L ${this.server} attach-session -t
|
|
118
|
+
return `tmux -L ${this.server} attach-session -t ${hostRef}`;
|
|
121
119
|
}
|
|
122
120
|
}
|