@brutalsystems/muster 0.9.1 → 0.10.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/CANONICAL_ID.md +13 -1
- package/CONTRACT_PROVENANCE.md +111 -0
- package/README.md +37 -7
- package/dist/guard.js +57 -3
- package/dist/run.js +5 -2
- package/package.json +1 -1
- package/test/fixtures/canonical-id.json +1 -1
package/CANONICAL_ID.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Tin Can — Address Format
|
|
2
2
|
|
|
3
|
-
> **Normative for `@brutalsystems/tincan` 1.
|
|
3
|
+
> **Normative for `@brutalsystems/tincan` 1.9.2.**
|
|
4
4
|
>
|
|
5
5
|
> This describes what Tin Can does today, precisely enough for another tool to
|
|
6
6
|
> produce addresses Tin Can will resolve. It is a write-down of shipped
|
|
@@ -140,6 +140,18 @@ The separator is `.` so that an address needs no shell quoting.
|
|
|
140
140
|
`canonical_id` is always fully qualified, whether or not there is a collision.
|
|
141
141
|
It is the form to record in logs and to pass between tools.
|
|
142
142
|
|
|
143
|
+
**Do not recover the suffix from a canonical id by string surgery.** Taking the
|
|
144
|
+
last dot-separated segment gave the three-character suffix before 1.0.0 and
|
|
145
|
+
gives the whole durable id now — `01a0b9b4-a33e-7ab1-80a0-bb715504a0fb`, not
|
|
146
|
+
`0fb`. A reimplementation that does this builds qualified addresses that look
|
|
147
|
+
right and match nothing, and the failure surfaces as `unknown`, which names a
|
|
148
|
+
missing peer rather than a malformed address. Reported by muster, where it made
|
|
149
|
+
every `slug.suffix` address fail silently until a test was written for it.
|
|
150
|
+
|
|
151
|
+
Derive the suffix from the **uuid**, with `suffixOf` — the last three hex
|
|
152
|
+
characters of the id with non-hex removed. The canonical form is an address to
|
|
153
|
+
pass along whole, not a record to parse fields out of.
|
|
154
|
+
|
|
143
155
|
## Collision
|
|
144
156
|
|
|
145
157
|
A peer is suffixed in its **display** form when either:
|
package/CONTRACT_PROVENANCE.md
CHANGED
|
@@ -370,3 +370,114 @@ rule it documents — refuse a prefix that ends at a segment boundary — fails
|
|
|
370
370
|
new case *and* `resolve: unambiguous prefix resolves` together, in this copy.
|
|
371
371
|
That pair failing together is the argument for Tin Can's decision to document
|
|
372
372
|
the defect rather than change resolution, and it reproduces here.
|
|
373
|
+
|
|
374
|
+
## Re-sync to Tin Can 1.7.0 — 2026-09-24
|
|
375
|
+
|
|
376
|
+
The copies are now from tag `v1.7.0`, commit
|
|
377
|
+
`f2aaeb20ea278c229afe955db59f69301fc6626f`.
|
|
378
|
+
|
|
379
|
+
| File | SHA-256 |
|
|
380
|
+
| --- | --- |
|
|
381
|
+
| `CANONICAL_ID.md` | `8d5e93894e5c43cc854330f7b64ebbdcc450649cfa910c8b4015edf2559125e7` |
|
|
382
|
+
| `test/fixtures/canonical-id.json` | `1db118b22484271932e7cde33c1c80b469563f18f2961532d6910d79ce1e4c3b` |
|
|
383
|
+
|
|
384
|
+
`tincan_version: 1.7.0`, still 40 cases: 8 slugify, 7 suffix, 12 assignment, 13
|
|
385
|
+
resolution. **No expected value moved and no case was added.** Diffed
|
|
386
|
+
`v1.6.0..v1.7.0` upstream before copying: `src/naming.ts` does not appear in the
|
|
387
|
+
diff at all, the fixture's only changed line is its `tincan_version`, and
|
|
388
|
+
`CANONICAL_ID.md` gains fourteen lines of warning with no normative change. So
|
|
389
|
+
`naming.ts` is untouched here and the contract suite is expected to stay 4/4
|
|
390
|
+
without it — this is a re-label plus a doc, not an address-format change.
|
|
391
|
+
|
|
392
|
+
What the doc gains is the hazard this repo hit and reported: recovering the
|
|
393
|
+
three-hex suffix by taking a canonical id's last dot-separated segment returned
|
|
394
|
+
the suffix before 1.0.0 and returns the whole uuid now, so every qualified
|
|
395
|
+
`slug.suffix` address resolves as `unknown` — a missing peer, not a malformed
|
|
396
|
+
address, which is why it stayed quiet here until a test was written for it.
|
|
397
|
+
Tin Can names Muster as where it was found. The rule is to derive the suffix
|
|
398
|
+
from the uuid via `suffixOf` and otherwise pass a canonical id along whole
|
|
399
|
+
rather than parse fields out of it.
|
|
400
|
+
|
|
401
|
+
**A re-sync touches three sites, not two.** Both Tin Can pins —
|
|
402
|
+
`RELEASING.md` and `.github/workflows/publish.yml` — plus the SHA-256 drift
|
|
403
|
+
hashes in `test/naming.test.ts` ("contract copies remain frozen"), which must
|
|
404
|
+
move whenever a copied file changes. The two pins are the ones handed down in
|
|
405
|
+
conversation; the hashes were found by grepping for the old version and the old
|
|
406
|
+
digests rather than by working from that list, so grep the repo instead of
|
|
407
|
+
trusting an enumeration. That advice earned itself twice over: this very
|
|
408
|
+
enumeration missed `README.md`, which carried a literal baseline of its own —
|
|
409
|
+
recorded below.
|
|
410
|
+
|
|
411
|
+
Moving only the first pin is what made v0.9.0 an inert tag that published
|
|
412
|
+
nothing. `test/workflow-npm-pin.test.ts` now fails when the two pins disagree,
|
|
413
|
+
or when the `publish.yml` pin and the fixture's `tincan_version` disagree, and
|
|
414
|
+
that guard was re-confirmed by mutation here rather than assumed: reverting
|
|
415
|
+
`publish.yml` alone to 1.6.0 fails both assertions, and restoring it passes
|
|
416
|
+
them. A stale drift hash fails `npm run check` on its own.
|
|
417
|
+
|
|
418
|
+
### The pin sites became one derived value
|
|
419
|
+
|
|
420
|
+
Recording the fix rather than only the incident. The two Tin Can pins no longer
|
|
421
|
+
restate a version; both compute it from this fixture's `tincan_version`. That
|
|
422
|
+
field is the single place saying which Tin Can the vendored copies describe, so
|
|
423
|
+
a pin can no longer lag it — the v0.9.0 failure class is closed structurally
|
|
424
|
+
instead of guarded after the fact.
|
|
425
|
+
|
|
426
|
+
The guard that replaced the old agreement tests asserts the derivation
|
|
427
|
+
expression itself, not the word `tincan_version`. The first version of it
|
|
428
|
+
checked for the word and **passed against a `publish.yml` whose command had
|
|
429
|
+
been mutated to `ver="1.6.0"`**, because the prose explaining the derivation
|
|
430
|
+
contains that word too. It was caught by running the mutation rather than by
|
|
431
|
+
reading the test. A guard that cannot fail is the thing this document exists to
|
|
432
|
+
prevent, so the failure is written down next to the fix.
|
|
433
|
+
|
|
434
|
+
### README.md was a fourth site, and had already gone stale
|
|
435
|
+
|
|
436
|
+
The enumeration above missed it. `README.md`'s Verification section named its own
|
|
437
|
+
baseline — **Tin Can 0.6.4** in prose, and `@brutalsystems/tincan@0.6.4` in a
|
|
438
|
+
copy-paste command — and had drifted unnoticed while the vendored copies moved to
|
|
439
|
+
1.6.0 and then 1.9.2. Nothing failed, because it gates nothing: it is
|
|
440
|
+
instructions for a human, so the cost was silence rather than an inert tag.
|
|
441
|
+
|
|
442
|
+
That makes it worse than a failing pin, not better. Anyone following the README
|
|
443
|
+
installed a Tin Can four minors behind the one the fixture describes and watched
|
|
444
|
+
the contract suite pass 4/4 — which reads as confirmation that the contract holds
|
|
445
|
+
at a version nobody vendored.
|
|
446
|
+
|
|
447
|
+
Fixed the same way as the other two sites: the command derives `ver` from the
|
|
448
|
+
fixture, and the prose points at `tincan_version` rather than naming a release.
|
|
449
|
+
`test/workflow-npm-pin.test.ts` now includes `README.md` in the derivation loop,
|
|
450
|
+
and adds one assertion that its Verification section names no Tin Can version in
|
|
451
|
+
prose at all, because prose restates a pin as easily as a command does. Both were
|
|
452
|
+
watched failing against the stale README before anything was changed — the
|
|
453
|
+
literal-pin assertion on the command, and `expected 'Tin Can 0.6.4' to be
|
|
454
|
+
undefined` on the prose — and the derived command was then extracted from the
|
|
455
|
+
README and run end to end, installing 1.9.2 and passing the contract suite 4/4.
|
|
456
|
+
|
|
457
|
+
The prose assertion is scoped to `## Verification` on purpose. Elsewhere the
|
|
458
|
+
README records what was observed at a particular Tin Can — "Tin Can 0.6.0
|
|
459
|
+
delivers over v1, so nothing here depends on the v2 path" — which is a historical
|
|
460
|
+
claim and stays pinned to its version deliberately. A whole-file regex would fail
|
|
461
|
+
on those and teach the next person to delete the guard.
|
|
462
|
+
|
|
463
|
+
## Re-sync to Tin Can 1.9.2 — 2026-09-25
|
|
464
|
+
|
|
465
|
+
Copies are from tag `v1.9.2`.
|
|
466
|
+
|
|
467
|
+
| File | SHA-256 |
|
|
468
|
+
| --- | --- |
|
|
469
|
+
| `CANONICAL_ID.md` | `6e0dde03154956a480ae59f4d31b727ecc5a9e735c185fed6e4445f2574263a9` |
|
|
470
|
+
| `test/fixtures/canonical-id.json` | `b91406e068f6bec18629a4d2b9e91bc4d2e5a0487e4b6b4534fd5ba37c7f617a` |
|
|
471
|
+
|
|
472
|
+
`tincan_version: 1.9.2`, still 40 cases. Diffing `v1.7.0..v1.9.2` upstream, the
|
|
473
|
+
ONLY change across all three contract files is the version string in two lines —
|
|
474
|
+
`naming.ts` does not appear, and no fixture case or expected value moved. 1.8.0
|
|
475
|
+
(log chaining, arrival record), 1.9.0 (version reporting in `peers`), 1.9.1 and
|
|
476
|
+
1.9.2 (registration drift) do not touch the address format. Verified here rather
|
|
477
|
+
than taken from upstream's word: they explicitly said not to treat their summary
|
|
478
|
+
as a re-copy signal.
|
|
479
|
+
|
|
480
|
+
**This re-sync needed no pin edit.** The pins derive from `tincan_version`, so
|
|
481
|
+
updating the fixture moved CI on its own — the first cut where the failure class
|
|
482
|
+
that produced v0.9.0 had nothing to act on. The drift hashes still moved, which
|
|
483
|
+
is the third site behaving exactly as documented above.
|
package/README.md
CHANGED
|
@@ -492,6 +492,30 @@ Every launch also records how strongly its containment was imposed:
|
|
|
492
492
|
| `tool-policy` | OpenCode — rules the agent is asked to respect, with no kernel enforcement. |
|
|
493
493
|
| `none` | `full-access` — the sandbox is deliberately disabled. |
|
|
494
494
|
|
|
495
|
+
#### What a sandboxed launch may write
|
|
496
|
+
|
|
497
|
+
Muster names the write roots rather than leaving them to default. Under
|
|
498
|
+
`workspace-write` a launched Claude session may write its cwd, `$TMPDIR`, and
|
|
499
|
+
**its own configuration directory** — the `CLAUDE_CONFIG_DIR` the child will
|
|
500
|
+
actually use, which an identity launch or a requester profile's `env_defaults`
|
|
501
|
+
may relocate. That last root is granted because an agent's memory lives there:
|
|
502
|
+
without it every Bash write to memory failed with `EPERM` while the in-process
|
|
503
|
+
file tools succeeded, which reads from inside the session as a path bug rather
|
|
504
|
+
than a boundary.
|
|
505
|
+
|
|
506
|
+
Under `read` nothing is writable, including the config dir. Under `full-access`
|
|
507
|
+
there is no sandbox to grant into.
|
|
508
|
+
|
|
509
|
+
One path stays denied that muster cannot open: **`$CLAUDE_CONFIG_DIR/projects/`**
|
|
510
|
+
(and `shell-snapshots/`). Claude Code refuses writes there in a sandboxed Bash
|
|
511
|
+
regardless of what muster grants — naming those directories explicitly in
|
|
512
|
+
`allowWrite` changes nothing, which is presumably deliberate, since a session's
|
|
513
|
+
own transcripts live there. Project-scoped memory under
|
|
514
|
+
`projects/<cwd-slug>/memory` is therefore reachable only through the file tools,
|
|
515
|
+
which are in-process and not kernel-sandboxed. Note that `work` implies auto
|
|
516
|
+
mode, which asks the model to prefer shell commands for file edits, so a session
|
|
517
|
+
saving project-scoped memory has to be told to use `Write`/`Edit` instead.
|
|
518
|
+
|
|
495
519
|
### Requester profiles and enrolments
|
|
496
520
|
|
|
497
521
|
A non-local caller — one reached through a transport such as Tin Can rather
|
|
@@ -1024,19 +1048,25 @@ The ordinary suite requires tmux, node-pty, POSIX `ps`/`lsof`, and Python 3 for
|
|
|
1024
1048
|
real file locks in the fake Codex executable. It uses isolated runtime homes and
|
|
1025
1049
|
no model APIs. It skips the four explicitly invoked compatibility cases.
|
|
1026
1050
|
|
|
1027
|
-
The verified contract baseline is
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1051
|
+
The verified contract baseline is the Tin Can named by `tincan_version` in the
|
|
1052
|
+
frozen fixture, `test/fixtures/canonical-id.json`. That field is the single place
|
|
1053
|
+
recording which release the vendored copies describe, and both `RELEASING.md` and
|
|
1054
|
+
`publish.yml` derive their pin from it. The suite spawns an installed binary as
|
|
1055
|
+
an MCP subprocess, compares durable IDs, and verifies delivery to fake runtimes
|
|
1056
|
+
through both terminal hosts. A missing binary or missing durable field fails the
|
|
1057
|
+
test. For reproducible verification, install that exact release outside Muster —
|
|
1058
|
+
derived here too, rather than typed, so this command cannot name a release the
|
|
1059
|
+
fixture has moved past:
|
|
1031
1060
|
|
|
1032
1061
|
```sh
|
|
1033
1062
|
contract_dir=$(mktemp -d)
|
|
1034
|
-
|
|
1063
|
+
ver=$(node -p "require('./test/fixtures/canonical-id.json').tincan_version")
|
|
1064
|
+
npm install --prefix "$contract_dir" --no-save "@brutalsystems/tincan@$ver"
|
|
1035
1065
|
MUSTER_TINCAN_BIN="$contract_dir/node_modules/.bin/tincan" npm run test:contract
|
|
1036
1066
|
```
|
|
1037
1067
|
|
|
1038
|
-
Verification uses
|
|
1039
|
-
|
|
1068
|
+
Verification uses that published release installed in an isolated temporary
|
|
1069
|
+
directory. No Tin Can source edits are made.
|
|
1040
1070
|
Fixture version metadata alone does not indicate address-format drift: compare
|
|
1041
1071
|
the case arrays. Those arrays cover naming only, not peer-list membership or
|
|
1042
1072
|
state semantics. Upgrades require separate compatibility verification; passing
|
package/dist/guard.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { realpath } from "node:fs/promises";
|
|
4
|
-
import {
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
5
7
|
import { mcpName, claudeMcpConfig } from "./mcp.js";
|
|
6
8
|
import { pluginName } from "./plugins.js";
|
|
7
9
|
import { RequesterConfigError } from "./requester-policy.js";
|
|
@@ -316,7 +318,47 @@ export function runtimeArgs(req) {
|
|
|
316
318
|
}
|
|
317
319
|
return result;
|
|
318
320
|
}
|
|
319
|
-
|
|
321
|
+
/**
|
|
322
|
+
* The config dir the launched agent will actually use. `launchEnv`'s blocklist
|
|
323
|
+
* is /^(CLAUDECODE|CLAUDE_CODE_|...)/, which does not match CLAUDE_CONFIG_DIR,
|
|
324
|
+
* so the child inherits whatever the caller set and this resolves to the same
|
|
325
|
+
* dir the child will open.
|
|
326
|
+
*/
|
|
327
|
+
export function claudeConfigDir(env) {
|
|
328
|
+
const configured = env.CLAUDE_CONFIG_DIR;
|
|
329
|
+
if (configured && configured.trim().length > 0)
|
|
330
|
+
return resolve(configured);
|
|
331
|
+
return join(env.HOME ?? homedir(), ".claude");
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* The keys of a profile's own settings.json that name its skill set rather than
|
|
335
|
+
* its policy. `--setting-sources ""` is what keeps `permissions`, `sandbox` and
|
|
336
|
+
* `hooks` muster's alone — forwarding those would let a settings file re-widen
|
|
337
|
+
* what `assertClaudePolicy` refuses to take on trust — so only these two are
|
|
338
|
+
* carried across, and muster's keys are spread after them and win regardless.
|
|
339
|
+
*/
|
|
340
|
+
const forwardedSettings = ["enabledPlugins", "extraKnownMarketplaces"];
|
|
341
|
+
function profilePluginSettings(configDir) {
|
|
342
|
+
let parsed;
|
|
343
|
+
try {
|
|
344
|
+
parsed = JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8"));
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// No settings file, unreadable, or not JSON: a launch with built-in skills
|
|
348
|
+
// only is degraded but correct, and failing here would take the sandbox fix
|
|
349
|
+
// down with it.
|
|
350
|
+
return {};
|
|
351
|
+
}
|
|
352
|
+
if (parsed === null || typeof parsed !== "object")
|
|
353
|
+
return {};
|
|
354
|
+
const source = parsed;
|
|
355
|
+
const out = {};
|
|
356
|
+
for (const key of forwardedSettings)
|
|
357
|
+
if (source[key] !== undefined)
|
|
358
|
+
out[key] = source[key];
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
export function launchArgs(req, config, mcp = [], bridgePath, env = process.env) {
|
|
320
362
|
// Before `runtimeArgs`, so this function owns every model error on its own
|
|
321
363
|
// rather than by its caller's discipline: `Muster.launch` happens to resolve
|
|
322
364
|
// first, but `launchArgs` is exported and an input carrying both a model
|
|
@@ -357,7 +399,9 @@ export function launchArgs(req, config, mcp = [], bridgePath) {
|
|
|
357
399
|
: policy.permissions === "bypass"
|
|
358
400
|
? "bypassPermissions"
|
|
359
401
|
: "dontAsk";
|
|
402
|
+
const configDir = claudeConfigDir(env);
|
|
360
403
|
const settings = {
|
|
404
|
+
...profilePluginSettings(configDir),
|
|
361
405
|
disableAllHooks: true,
|
|
362
406
|
permissions: {
|
|
363
407
|
defaultMode: mode,
|
|
@@ -374,7 +418,17 @@ export function launchArgs(req, config, mcp = [], bridgePath) {
|
|
|
374
418
|
autoAllowBashIfSandboxed: true,
|
|
375
419
|
allowUnsandboxedCommands: false,
|
|
376
420
|
excludedCommands: [],
|
|
377
|
-
filesystem: {
|
|
421
|
+
filesystem: {
|
|
422
|
+
denyWrite: readonly ? ["/"] : [],
|
|
423
|
+
// The agent's own memory lives under the config dir, outside the cwd
|
|
424
|
+
// that Claude Code defaults the write root to, so Bash writes to it
|
|
425
|
+
// failed with EPERM while the in-process file tools succeeded (#80).
|
|
426
|
+
// Granted only under workspace-write: read-only must stay read-only,
|
|
427
|
+
// and full-access has no sandbox to grant into.
|
|
428
|
+
...(policy.sandbox === "workspace-write"
|
|
429
|
+
? { allowWrite: [configDir] }
|
|
430
|
+
: {}),
|
|
431
|
+
},
|
|
378
432
|
},
|
|
379
433
|
};
|
|
380
434
|
return [
|
package/dist/run.js
CHANGED
|
@@ -240,7 +240,7 @@ export class Muster {
|
|
|
240
240
|
if (req.runtime === "opencode")
|
|
241
241
|
runtimeArgs(effective);
|
|
242
242
|
else
|
|
243
|
-
argv = launchArgs(effective, this.config);
|
|
243
|
+
argv = launchArgs(effective, this.config, [], undefined, this.sourceEnv);
|
|
244
244
|
const driver = req.kind === "session"
|
|
245
245
|
? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
|
|
246
246
|
: undefined;
|
|
@@ -389,7 +389,10 @@ export class Muster {
|
|
|
389
389
|
await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
|
|
390
390
|
}
|
|
391
391
|
if (req.runtime !== "opencode")
|
|
392
|
-
|
|
392
|
+
// `runtimeEnv`, not the host's: an identity launch relocates
|
|
393
|
+
// CLAUDE_CONFIG_DIR to the per-launch copy, and the sandbox must grant
|
|
394
|
+
// the dir the child will actually open, not the one Muster runs under.
|
|
395
|
+
argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv);
|
|
393
396
|
if (req.runtime === "codex") {
|
|
394
397
|
codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
|
|
395
398
|
argv.splice(1, 0, ...codexPolicy);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "FROZEN. Describes @brutalsystems/tincan address behaviour as shipped. Adding cases is fine; changing an expected value means the address format changed - see CANONICAL_ID.md, 'Changing this format'.",
|
|
3
|
-
"tincan_version": "1.
|
|
3
|
+
"tincan_version": "1.9.2",
|
|
4
4
|
"spec": "CANONICAL_ID.md",
|
|
5
5
|
"slugify": [
|
|
6
6
|
{
|