@brutalsystems/muster 0.1.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.
@@ -0,0 +1,168 @@
1
+ # Tin Can — Address Format
2
+
3
+ > **Normative for `@brutalsystems/tincan` 0.1.0.**
4
+ >
5
+ > This describes what Tin Can does today, precisely enough for another tool to
6
+ > produce addresses Tin Can will resolve. It is a write-down of shipped
7
+ > behaviour, not a design. Where the behaviour is wrong, it is marked **Known
8
+ > defect** and preserved anyway — see [Known defects](#known-defects).
9
+ >
10
+ > The executable form is [`test/fixtures/canonical-id.json`](test/fixtures/canonical-id.json),
11
+ > asserted by `test/canonical-id.test.ts`. Copy that fixture rather than
12
+ > reimplementing from prose.
13
+ >
14
+ > **The implementation is authoritative.** If this document and Tin Can
15
+ > disagree, Tin Can is right and this document has a bug. The fixture is the
16
+ > regression net that keeps them together.
17
+
18
+ ## The pieces
19
+
20
+ An address is built from two inputs:
21
+
22
+ | Runtime | Name source | Id source |
23
+ |---|---|---|
24
+ | `codex` | thread title, or absent | `thread_id` |
25
+ | `claude-code` | session name from the registry | `session_id` |
26
+
27
+ Neither name is guaranteed unique, and neither is stable: **a name belongs to a
28
+ process and dies with it.** Key durable records on the id, never on the address.
29
+
30
+ ## Slugify
31
+
32
+ Applied to the runtime-supplied name.
33
+
34
+ 1. Lowercase the whole string.
35
+ 2. Replace every run of characters outside `[a-z0-9]` with a single `-`.
36
+ 3. Strip leading and trailing `-`.
37
+
38
+ Consequences worth stating, because they are easy to get wrong:
39
+
40
+ - **Non-ASCII is a separator, not transliterated.** `café ☕ time` → `caf-time`.
41
+ - Runs collapse: `auth/refactor (v2)` → `auth-refactor-v2`.
42
+ - Digits survive: `123` → `123`.
43
+ - **The result may be empty.** `???` → `""`, `""` → `""`.
44
+
45
+ An empty slug is replaced by the literal `thread`. This applies both to a peer
46
+ with no name at all and to a peer whose name slugifies away — which is where a
47
+ [known defect](#known-defects) lives.
48
+
49
+ ## Suffix
50
+
51
+ Applied to the id.
52
+
53
+ 1. Remove every character outside `[0-9a-f]`, case-insensitively.
54
+ 2. Take the **last three** characters of what remains.
55
+ 3. Lowercase.
56
+
57
+ **The last three, not the first.** Codex thread ids are UUIDv7: the leading hex
58
+ is a timestamp, so every thread live on a machine at the same time shares its
59
+ first characters. A leading suffix disambiguates nothing. This has been
60
+ specified wrongly before — verify against the fixture.
61
+
62
+ - `01a0b9b4-a33e-7ab1-80a0-bb715504a0fb` → `0fb`
63
+ - `5af69d42-2214-41d9-b13f-9c3177eb60ce` → `0ce`
64
+ - Uppercase hex is kept, then lowered: `...ABC` → `abc`
65
+ - Fewer than three hex characters yields a shorter suffix: `z-9` → `9`
66
+ - No hex at all yields an empty suffix: `zzzz` → `""`
67
+
68
+ ## Forms
69
+
70
+ Three forms exist. Two are emitted; all three are accepted as input.
71
+
72
+ | Form | Shape | Emitted |
73
+ |---|---|---|
74
+ | **Display** | `auth-refactor` | by `peers`, when the slug does not collide |
75
+ | **Qualified** | `auth-refactor.7f3` | by `peers`, only on collision |
76
+ | **Canonical** | `codex:auth-refactor.7f3` | as `canonical_id`, always |
77
+
78
+ The separator is `.` so that an address needs no shell quoting.
79
+
80
+ `canonical_id` is always fully qualified, whether or not there is a collision.
81
+ It is the form to record in logs and to pass between tools.
82
+
83
+ ## Collision
84
+
85
+ A peer is suffixed in its **display** form when either:
86
+
87
+ - another peer in the same listing has the same slug, or
88
+ - the peer has no name (`rawName === null`), which is always suffixed.
89
+
90
+ Otherwise the display form is the bare slug. Only colliding peers are suffixed;
91
+ peers that do not collide keep their bare names in the same listing.
92
+
93
+ ## Resolution
94
+
95
+ Input is trimmed and lowercased, then matched in two passes.
96
+
97
+ **Pass 1 — exact.** Matches if the input equals the peer's display form, its
98
+ qualified form, or its canonical id. One match resolves. More than one is
99
+ refused as ambiguous.
100
+
101
+ **Pass 2 — prefix**, only if pass 1 found nothing. Matches if the peer's slug or
102
+ its qualified form *starts with* the input. One match resolves. More than one is
103
+ refused as ambiguous.
104
+
105
+ If neither pass matches, the result is refused as unknown.
106
+
107
+ - **Case-insensitive** throughout. `AUTH-REFACTOR.7F3` resolves.
108
+ - **Input is trimmed.** Surrounding whitespace is ignored.
109
+ - **There is no minimum prefix length.** A one-character prefix resolves if it
110
+ is unambiguous. The empty string is a prefix of everything — see
111
+ [known defects](#known-defects).
112
+ - **No tie-breaking.** Ambiguity is always refused, never guessed.
113
+
114
+ ### Refusal shape
115
+
116
+ | Reason | `candidates` contains |
117
+ |---|---|
118
+ | `ambiguous` | every matching peer, in **qualified** form |
119
+ | `unknown` | every peer in the listing, in **display** form |
120
+
121
+ ## Known defects
122
+
123
+ Preserved in 0.1.0 by decision, not by oversight. They are in the fixture so
124
+ that a second implementation matches Tin Can exactly, including where Tin Can is
125
+ wrong. **Do not "correct" them independently** — that produces addresses that
126
+ resolve in one tool and fail silently in the other.
127
+
128
+ **1. `canonical_id` is not unique.** Two peers whose slugs match *and* whose ids
129
+ share their last three hex characters produce the same `canonical_id`. Tin Can
130
+ then refuses to resolve either, listing two identical candidates — a refusal
131
+ that tells the caller to disambiguate using a string that does not
132
+ disambiguate. The peers are unaddressable until one exits.
133
+
134
+ Probability is roughly 1 in 4096 *given* a slug collision, and slug collisions
135
+ are not rare: Codex titles threads from their first prompt, so two sessions
136
+ started from similar prompts collide readily.
137
+
138
+ **Consumers must therefore treat `canonical_id` as a display and correlation
139
+ aid, not a primary key.** Key on `thread_id` / `session_id`.
140
+
141
+ **2. A name that slugifies to empty is inconsistent with an unnamed peer.**
142
+ Alone in a listing, a peer named `???` displays as `thread` with no suffix,
143
+ while a peer with no name displays as `thread.<suffix>`. Both slug to `thread`,
144
+ so when they appear together they collide and both are suffixed — the
145
+ inconsistency only shows when each is alone.
146
+
147
+ **3. The empty string resolves when exactly one peer exists.** Because the empty
148
+ string is a prefix of every slug, `resolve("")` against a single-peer listing
149
+ returns that peer rather than refusing. With two or more peers it is refused as
150
+ ambiguous, as expected.
151
+
152
+ ## Changing this format
153
+
154
+ The address format is a contract between Tin Can and tools built against it. A
155
+ change to slugify, suffix derivation, collision handling, or resolution produces
156
+ addresses that resolve in one tool and fail silently in the other — no error,
157
+ just a message delivered nowhere.
158
+
159
+ So:
160
+
161
+ - **A change to any behaviour described here is a breaking change**, and ships
162
+ as a major version under semver.
163
+ - Update this document and the fixture in the same commit as the code.
164
+ - Adding cases to the fixture is not a breaking change. Changing an existing
165
+ expected value is.
166
+
167
+ Fixing the known defects above is therefore a deliberate, coordinated release —
168
+ not a bug fix to be slipped in.
@@ -0,0 +1,53 @@
1
+ # Tin Can contract provenance
2
+
3
+ Copied verbatim from `https://github.com/BrutalSystems/tincan` at commit
4
+ `fbaaea5842fd4a5c86849d7f51c8e16b8683b058` on 2026-09-19.
5
+ The contract describes `@brutalsystems/tincan` version `0.1.0`.
6
+
7
+ | File | SHA-256 |
8
+ | --- | --- |
9
+ | `CANONICAL_ID.md` | `8cf4312d6af1c4d85ec0641d8739bec261be2feb35dd16bdd9d8be2b89551915` |
10
+ | `test/fixtures/canonical-id.json` | `47fb41fe62efaab6d9ecc7a77623fb2761f79829faf8bcd67c6e54bc4414b751` |
11
+
12
+ Both copies were verified byte-for-byte against the clean source checkout.
13
+ The fixture contains 31 cases: 8 slugify, 6 suffix, 7 assignment, 10 resolution.
14
+
15
+ Tin Can's fixture runner compares refusal candidates after sorting both arrays;
16
+ candidate ordering is not asserted by the fixture. Preserve duplicate candidates.
17
+ CLI runtime `claude` maps to address runtime `claude-code`.
18
+
19
+ Known defects are intentionally preserved: non-unique canonical IDs,
20
+ unnamed versus empty-slug display differences, and empty-input resolution.
21
+ See the normative document for exact behavior.
22
+
23
+ ## Compatibility check against Tin Can 0.2.0
24
+
25
+ On 2026-09-19, verified source commit
26
+ `3f13fdcc9ac1246b2e5707b6a44f0cf8d207899f`: Claude `peers` now emits
27
+ `session_id`; Codex emits `thread_id`. The integration test must compare those
28
+ durable fields directly, in addition to verifying message delivery.
29
+
30
+ The four fixture case arrays are identical to our 0.1.0 copy. The upstream
31
+ fixture version is now 0.2.0; that metadata change is not a naming change.
32
+ Our original frozen files remain unchanged. Their full-file hashes above
33
+ detect local edits; they must not be compared to an entire newer upstream
34
+ fixture to infer a format change.
35
+
36
+ For case comparison, SHA-256 of UTF-8 `JSON.stringify` applied to an object
37
+ with the keys `slugify`, `suffix`, `assign`, `resolve` in that order is:
38
+ `a92936ef4b713641d29da3fbafccab0e6da79a54e27bfea3c6a779e801a925b3`.
39
+ Both revisions produce this hash.
40
+
41
+ ## Upgrade boundary: listing and state
42
+
43
+ The upstream release notes report that 0.3.0 expands
44
+ Codex-hosted listings to both runtimes and 0.3.1 corrects Codex status decoding.
45
+ These releases have not been verified by Muster's contract suite. The tested
46
+ baseline remains exactly 0.2.0; README installation instructions pin it outside
47
+ Muster's dependency tree.
48
+
49
+ Inspection confirms the contract test does not assert total peer counts,
50
+ same-runtime exclusion, or Tin Can state. Its count assertion applies only to
51
+ the matching canonical ID in the isolated test environment. Naming hashes do
52
+ not cover listing membership, self-exclusion, or state semantics. A future
53
+ upgrade must test these behaviors separately where relied upon.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mike Williams
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # Muster
2
+
3
+ [![license](https://img.shields.io/github/license/BrutalSystems/muster)](./LICENSE)
4
+
5
+ Muster launches instructed Codex or Claude Code agents on this machine. A
6
+ `session` returns an address only after its runtime is reachable. A `task`
7
+ runs once, captures output, and never advertises a peer address.
8
+
9
+ **Live launch verification:** Codex and Claude both launched and answered their
10
+ initial prompts without terminal interaction. Claude requires a directory
11
+ already trusted by the operator. The automated suite uses fake runtimes,
12
+ not real models.
13
+
14
+ ## Build and run locally
15
+
16
+ Requires Node 22.12+ (tested with 24.16), TypeScript 5, and Codex 0.155.1 / Claude
17
+ Code 2.1.267. Terminal drivers use tmux or node-pty. Process discovery currently
18
+ requires a POSIX host with `ps` and `lsof`; the terminal interface itself is
19
+ platform-neutral. The macOS Terminal driver is an unavailable v1 stub.
20
+
21
+ Before launching a Claude session, open Claude normally in the target directory
22
+ and complete its workspace-trust review yourself, then exit that setup session.
23
+ This is a one-time prerequisite for each directory Claude requires you to trust.
24
+ Muster never accepts trust prompts or changes trust settings. An untrusted
25
+ directory can block startup; Muster times out and cleans up that launch.
26
+
27
+ ```sh
28
+ npm ci
29
+ npm run build
30
+ node dist/muster.js run codex --prompt 'Review the authentication flow'
31
+ node dist/muster.js run claude --prompt 'Summarize the project' --host tmux
32
+ node dist/muster.js run codex --kind task --prompt 'Explain the test layout'
33
+ node dist/muster.js list
34
+ node dist/muster.js list --kind task
35
+ node dist/muster.js output RUN_ID
36
+ node dist/muster.js stop THREAD_OR_SESSION_OR_RUN_ID
37
+ ```
38
+
39
+ `--prompt` is required and cannot be blank. `--cwd` defaults to the current
40
+ working directory. `--kind` defaults to `session`. All commands except `output`
41
+ emit JSON; `output` prints the captured task output. `stop` also accepts an
42
+ unambiguous peer name or canonical address, refusing ambiguity with candidates.
43
+ Use the durable ID to stop a session whose runtime has renamed it.
44
+
45
+ `run` accepts an optional `--` followed by runtime arguments. The normal allowlist
46
+ is `--model` / `-m`, plus Claude's `--effort`. Unknown options, bundled short
47
+ options, config injection and permission overrides are refused. Prompt strings
48
+ are passed as a single literal argument after the runtime's option terminator.
49
+
50
+ ## Terminal lifetime
51
+
52
+ Auto-selection tries tmux, then pty. Every peer includes `host`, `capabilities`
53
+ and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
54
+
55
+ - **tmux:** a window in the dedicated `muster` session on the `muster` tmux
56
+ server. Watchable and attachable; survives the CLI or MCP server exiting.
57
+ Use the returned attach hint, or `tmux -L muster attach -t muster`.
58
+ - **pty:** not watchable or attachable. The CLI prints the peer record and stays
59
+ running to own the terminal. Ctrl-C stops it. MCP-owned pty sessions stop when
60
+ the MCP server disconnects. There is no persistent pty daemon.
61
+ - **task:** a per-run worker captures stdout/stderr and exit status after the
62
+ launching CLI exits. It supervises one task, with no retry or restart behavior.
63
+
64
+ `list` refreshes live session metadata and shows ended runs distinctly. A timeout
65
+ or startup failure cleans the process tree and host window, logs failure, and
66
+ returns an error instead of a peer record.
67
+
68
+ ## Configuration and permissions
69
+
70
+ Muster reads `~/.muster/config.toml` once at startup and **never writes it**.
71
+ Missing configuration uses these defaults:
72
+
73
+ ```toml
74
+ host = "auto"
75
+ launch_timeout_sec = 30
76
+ max_concurrent = 4
77
+ sandbox = "read-only"
78
+ allow_dangerous_flags = false
79
+ ```
80
+
81
+ Choose `sandbox = "workspace-write"` yourself when agents should edit files.
82
+ `danger-full-access` additionally requires `allow_dangerous_flags = true`.
83
+ The concurrency cap is shared by separate CLI/MCP processes, including pending
84
+ launches. Settings apply to both kinds; tasks are read-only by default.
85
+
86
+ Codex gets an explicit sandbox and never-ask approval policy. Muster enumerates
87
+ and explicitly disables inherited MCP servers, then verifies the effective
88
+ configuration. Hooks, plugins, app connectors, automatic skill-MCP installation
89
+ and external notifications are disabled for the child. Unknown MCP names that
90
+ cannot be addressed safely are refused.
91
+
92
+ Claude's built-in tools default to enabled. Its command sandbox is enabled,
93
+ requires availability, and forbids unsandboxed retries. File-writing tools and
94
+ sandbox writes are denied in read-only mode; permissions use `dontAsk` so an
95
+ unattended child does not auto-grant escalations. User/project settings and MCP
96
+ servers are not inherited. Detected managed policy is refused because the CLI
97
+ cannot prove that inline settings override it. Enterprise remote policy can
98
+ arrive after startup; this v1 is not an enterprise policy-enforcement layer.
99
+ Claude permissions and its command sandbox are different mechanisms; neither
100
+ claim implies that every external tool is OS-sandboxed.
101
+
102
+ These child restrictions deliberately prevent propagating spawn authority.
103
+ Custom MCP tools—including Tin Can—are not automatically injected into the
104
+ child. The child's runtime inbox remains reachable from an external Tin Can.
105
+ No user-level runtime configuration is rewritten, and workspace-trust dialogs
106
+ are never accepted automatically.
107
+
108
+ Launch intent is fsynced to `~/.muster/launches.jsonl` before a runtime starts;
109
+ ready/failure outcomes follow. The log contains the **full prompt**, cwd,
110
+ requester, runtime, kind and host. Registry and task outputs also live under
111
+ `~/.muster`, with private file permissions. An abandoned `registry.lock` fails
112
+ closed: verify no Muster operation is running before removing that directory.
113
+ There is no automatic time-based lock theft.
114
+
115
+ ## MCP installation
116
+
117
+ Installed deliberately, in the one session that should hold spawn authority —
118
+ never at user scope.
119
+
120
+ Muster exposes `run`, `list`, `stop`, and `output` over stdio. No arguments or
121
+ `mcp` starts the server. Diagnostics go to stderr, never protocol stdout.
122
+ Schemas match the CLI (`args` is the array of optional runtime arguments).
123
+
124
+ For a single Codex session, use per-invocation configuration:
125
+
126
+ ```sh
127
+ codex -c 'mcp_servers.muster.command="node"' \
128
+ -c 'mcp_servers.muster.args=["/absolute/path/to/muster/dist/muster.js","mcp"]'
129
+ ```
130
+
131
+ For a single Claude session:
132
+
133
+ ```sh
134
+ claude --mcp-config '{"mcpServers":{"muster":{"command":"node","args":["/absolute/path/to/muster/dist/muster.js","mcp"]}}}'
135
+ ```
136
+
137
+ Do not add Muster to `~/.codex/config.toml`, Claude's user-scope MCP registry,
138
+ or another shared configuration that grants launch authority to every agent.
139
+
140
+ ## Tin Can compatibility
141
+
142
+ Muster has no Tin Can build or runtime dependency. Each tool implements the
143
+ written address contract independently. Vendored mechanics carry source-commit
144
+ headers and the original MIT license in `TINCAN_LICENSE`.
145
+
146
+ Addresses belong to running sessions and can expire or collide. Store
147
+ `thread_id` / `session_id`, and re-resolve through Tin Can's `peers` before
148
+ sending instead of caching a launch address. Canonical IDs are not unique keys.
149
+ The frozen cases intentionally preserve Tin Can's known naming defects.
150
+
151
+ Codex `idle` means reachable and not known to be busy, not guaranteed free.
152
+ The querying app-server can report `notLoaded` for a live thread; Muster maps
153
+ that to `idle`. Claude state comes from its session registry.
154
+
155
+ ## Verification
156
+
157
+ ```sh
158
+ npm test
159
+ npm run build
160
+ npm run test:contract
161
+ ```
162
+
163
+ The ordinary suite requires tmux, node-pty, POSIX `ps`/`lsof`, and Python 3 for
164
+ real file locks in the fake Codex executable. It uses isolated runtime homes and
165
+ no model APIs. It skips the four explicitly invoked compatibility cases.
166
+
167
+ The verified contract baseline is **Tin Can 0.2.0**. The suite spawns an installed binary as an MCP
168
+ subprocess, compares durable IDs, and verifies delivery to fake runtimes through
169
+ both terminal hosts. A missing binary or missing durable field fails the test.
170
+ For reproducible verification, install that exact release outside Muster:
171
+
172
+ ```sh
173
+ contract_dir=$(mktemp -d)
174
+ npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.2.0
175
+ MUSTER_TINCAN_BIN="$contract_dir/node_modules/.bin/tincan" npm run test:contract
176
+ ```
177
+
178
+ Verification uses the published 0.2.0 release installed in an isolated
179
+ temporary directory. No Tin Can source edits are made.
180
+ Fixture version metadata alone does not indicate address-format drift: compare
181
+ the case arrays. Those arrays cover naming only, not peer-list membership or
182
+ state semantics. Upgrades require separate compatibility verification; passing
183
+ fixture hashes alone does not establish compatibility. The tests locate the
184
+ launched peer by canonical ID and compare its durable ID; they do not assert a
185
+ total peer count, exclude same-runtime peers, or assert Tin Can's busy state.
186
+ `CONTRACT_PROVENANCE.md` records both types of integrity checks.
187
+
188
+ One-time live probes are separate from the automated suite. Both runtimes
189
+ launched and answered an initial prompt with the versions listed above; Claude
190
+ used a directory already trusted by its operator. `npm test` does not run
191
+ real models.
192
+
193
+ ## License and releases
194
+
195
+ MIT © 2026 Mike Williams. See [LICENSE](./LICENSE). Vendored Tin Can code
196
+ retains source attribution and its [MIT notice](./TINCAN_LICENSE).
197
+
198
+ [RELEASING.md](./RELEASING.md) covers versioning, package inspection, publication,
199
+ and release notes. Changes to the shared address format require an explicit
200
+ contract update; Muster never independently fixes the frozen naming behavior.
package/TINCAN_LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mike Williams
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,51 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ /** Claude's CLI cannot report all effective enterprise policy before starting.
7
+ * Refuse detected managed sources rather than pretending inline settings win. */
8
+ export async function assertClaudePolicy(env) {
9
+ if (env.CLAUDE_CONFIG_DIR)
10
+ throw new Error("Custom CLAUDE_CONFIG_DIR is unsupported: Tin Can discovers ~/.claude/sessions");
11
+ const home = env.HOME ?? homedir();
12
+ const system = process.platform === "darwin"
13
+ ? "/Library/Application Support/ClaudeCode"
14
+ : "/etc/claude-code";
15
+ const files = [
16
+ join(system, "managed-settings.json"),
17
+ join(system, "managed-mcp.json"),
18
+ join(home, ".claude", "remote-settings.json"),
19
+ ];
20
+ try {
21
+ for (const file of await readdir(join(system, "managed-settings.d")))
22
+ if (file.endsWith(".json"))
23
+ files.push(join(system, "managed-settings.d", file));
24
+ }
25
+ catch (e) {
26
+ if (e.code !== "ENOENT")
27
+ throw e;
28
+ }
29
+ for (const file of files) {
30
+ try {
31
+ const settings = JSON.parse(await readFile(file, "utf8"));
32
+ if (settings && Object.keys(settings).length)
33
+ throw new Error(`Managed Claude policy detected at ${file}; effective sandbox policy cannot be verified by this v1 launcher`);
34
+ }
35
+ catch (e) {
36
+ if (e.code !== "ENOENT")
37
+ throw e;
38
+ }
39
+ }
40
+ if (process.platform === "darwin") {
41
+ try {
42
+ const { stdout } = await promisify(execFile)("defaults", ["read", "com.anthropic.claudecode"], { timeout: 1000, env });
43
+ if (stdout.trim())
44
+ throw new Error("Managed Claude preferences detected; effective sandbox policy cannot be verified by this v1 launcher");
45
+ }
46
+ catch (e) {
47
+ if (e.code !== 1)
48
+ throw e;
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,40 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const exec = promisify(execFile);
4
+ export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000) {
5
+ const args = [
6
+ "--disable",
7
+ "hooks",
8
+ "--disable",
9
+ "plugins",
10
+ "--disable",
11
+ "remote_plugin",
12
+ "--disable",
13
+ "apps",
14
+ "--disable",
15
+ "skill_mcp_dependency_install",
16
+ "-c",
17
+ "notify=[]",
18
+ ];
19
+ async function list(extra) {
20
+ const { stdout } = await exec("codex", [...extra, "mcp", "list", "--json"], {
21
+ env,
22
+ cwd,
23
+ timeout: Math.max(1, Math.min(4000, deadline - Date.now())),
24
+ maxBuffer: 1024 * 1024,
25
+ });
26
+ const value = JSON.parse(stdout);
27
+ if (!Array.isArray(value))
28
+ throw new Error("Cannot verify effective Codex MCP configuration");
29
+ return value;
30
+ }
31
+ for (const server of await list(args)) {
32
+ if (typeof server.name !== "string" ||
33
+ !/^[a-zA-Z0-9_-]+$/.test(server.name))
34
+ throw new Error("Cannot safely disable an inherited MCP server with a non-simple name");
35
+ args.push("-c", `mcp_servers.${server.name}.enabled=false`);
36
+ }
37
+ if ((await list(args)).some((s) => s.enabled !== false))
38
+ throw new Error("Inherited Codex MCP servers remain enabled; refusing launch");
39
+ return args;
40
+ }
@@ -0,0 +1,116 @@
1
+ import { codexPolicyArgs } from "./codex-policy.js";
2
+ import { spawn } from "node:child_process";
3
+ /** JSONL protocol mechanics independently adapted from Tin Can src/codex/cli.ts
4
+ * at fbaaea5842fd4a5c86849d7f51c8e16b8683b058 (MIT). */
5
+ export class CodexRpc {
6
+ env;
7
+ cwd;
8
+ policy;
9
+ child;
10
+ serial = 0;
11
+ buffer = "";
12
+ initialized = false;
13
+ pending = new Map();
14
+ constructor(env, cwd = process.cwd(), policy) {
15
+ this.env = env;
16
+ this.cwd = cwd;
17
+ this.policy = policy;
18
+ }
19
+ async start(deadline) {
20
+ if (this.child)
21
+ return;
22
+ const policy = this.policy ?? (await codexPolicyArgs(this.env, this.cwd, deadline));
23
+ const child = spawn("codex", [...policy, "app-server", "--listen", "stdio://"], {
24
+ env: this.env,
25
+ cwd: this.cwd,
26
+ stdio: "pipe",
27
+ });
28
+ this.child = child;
29
+ child.stderr.resume();
30
+ const fail = (e) => {
31
+ for (const p of this.pending.values()) {
32
+ clearTimeout(p.timer);
33
+ p.reject(e);
34
+ }
35
+ this.pending.clear();
36
+ };
37
+ child.on("error", fail);
38
+ child.on("exit", () => fail(new Error("Codex app-server exited")));
39
+ child.stdin.on("error", fail);
40
+ child.stdout.on("data", (data) => {
41
+ this.buffer += data;
42
+ let i;
43
+ while ((i = this.buffer.indexOf("\n")) >= 0) {
44
+ const line = this.buffer.slice(0, i);
45
+ this.buffer = this.buffer.slice(i + 1);
46
+ let m;
47
+ try {
48
+ m = JSON.parse(line);
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ const p = this.pending.get(m.id);
54
+ if (!p)
55
+ continue;
56
+ clearTimeout(p.timer);
57
+ this.pending.delete(m.id);
58
+ if (m.error)
59
+ p.reject(new Error(m.error.message ?? "Codex RPC error"));
60
+ else if (!m.result || typeof m.result !== "object")
61
+ p.reject(new Error("Invalid Codex RPC result"));
62
+ else
63
+ p.resolve(m.result);
64
+ }
65
+ });
66
+ }
67
+ send(method, params, deadline) {
68
+ const remaining = deadline - Date.now();
69
+ if (remaining <= 0)
70
+ return Promise.reject(new Error(`Timed out calling ${method}`));
71
+ return new Promise((resolve, reject) => {
72
+ const id = ++this.serial;
73
+ const timer = setTimeout(() => {
74
+ this.pending.delete(id);
75
+ reject(new Error(`Timed out calling ${method}`));
76
+ }, Math.min(remaining, 2000));
77
+ this.pending.set(id, { resolve, reject, timer });
78
+ this.child.stdin.write(JSON.stringify({ id, method, params }) + "\n");
79
+ });
80
+ }
81
+ async call(method, params, deadline) {
82
+ await this.start(deadline);
83
+ if (!this.initialized) {
84
+ await this.send("initialize", {
85
+ clientInfo: { name: "muster", version: "0.1.0" },
86
+ capabilities: { experimentalApi: true },
87
+ }, deadline);
88
+ this.initialized = true;
89
+ }
90
+ return this.send(method, params, deadline);
91
+ }
92
+ async close() {
93
+ if (!this.child)
94
+ return;
95
+ const child = this.child;
96
+ this.child = undefined;
97
+ for (const p of this.pending.values()) {
98
+ clearTimeout(p.timer);
99
+ p.reject(new Error("Codex RPC closed"));
100
+ }
101
+ this.pending.clear();
102
+ if (child.exitCode !== null)
103
+ return;
104
+ await new Promise((resolve) => {
105
+ const timer = setTimeout(() => {
106
+ child.kill("SIGKILL");
107
+ resolve();
108
+ }, 1000);
109
+ child.once("exit", () => {
110
+ clearTimeout(timer);
111
+ resolve();
112
+ });
113
+ child.kill("SIGTERM");
114
+ });
115
+ }
116
+ }