@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Tin Can — Address Format
2
2
 
3
- > **Normative for `@brutalsystems/tincan` 1.6.0.**
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:
@@ -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 **Tin Can 0.6.4**. The suite spawns an installed binary as an MCP
1028
- subprocess, compares durable IDs, and verifies delivery to fake runtimes through
1029
- both terminal hosts. A missing binary or missing durable field fails the test.
1030
- For reproducible verification, install that exact release outside Muster:
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
- npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.6.4
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 the published 0.6.4 release installed in an isolated
1039
- temporary directory. No Tin Can source edits are made.
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 { isAbsolute, relative, resolve } from "node:path";
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
- export function launchArgs(req, config, mcp = [], bridgePath) {
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: { denyWrite: readonly ? ["/"] : [] },
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
- argv = launchArgs(effective, this.config, prepared.servers, bridgePath);
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
  "name": "@brutalsystems/muster",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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.6.0",
3
+ "tincan_version": "1.9.2",
4
4
  "spec": "CANONICAL_ID.md",
5
5
  "slugify": [
6
6
  {