@brutalsystems/muster 0.7.12 → 0.7.14

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/README.md CHANGED
@@ -413,11 +413,270 @@ No user-level runtime configuration is rewritten, and workspace-trust dialogs
413
413
  are never accepted automatically.
414
414
 
415
415
  Launch intent is fsynced to `~/.muster/launches.jsonl` before a runtime starts;
416
- ready/failure outcomes follow. The log contains the **full prompt**, cwd,
417
- requester, runtime, kind and host. Registry and task outputs also live under
418
- `~/.muster`, with private file permissions. An abandoned `registry.lock` fails
419
- closed: verify no Muster operation is running before removing that directory.
420
- There is no automatic time-based lock theft.
416
+ ready/failure outcomes follow. For a local requester the log contains the
417
+ **full prompt**, cwd, requester, runtime, kind and host. A non-local requester
418
+ gets metadata-only logging instead — no prompt text and no environment values,
419
+ see [Requester profiles and enrolments](#requester-profiles-and-enrolments)
420
+ below. Registry and task outputs also live under `~/.muster`, with private
421
+ file permissions. An abandoned `registry.lock` fails closed: verify no Muster
422
+ operation is running before removing that directory. There is no automatic
423
+ time-based lock theft. `launches.jsonl` rotates at 8 MiB: the file is renamed
424
+ to `launches.jsonl.1`, replacing any previous generation, and a fresh file is
425
+ opened at mode `0600`. One prior generation is kept; this bounds the file
426
+ without adding a retention policy nobody asked for.
427
+
428
+ ### Containment levels
429
+
430
+ `--level` names a legal `--permissions` / `--sandbox` pair. It is a spelling,
431
+ not a second policy path, and naming both a level and either flag is an error.
432
+
433
+ | Level | Equivalent to |
434
+ | ------ | ------------------------------------------------------------------------------------- |
435
+ | `read` | `--permissions deny --sandbox read-only` |
436
+ | `work` | `--permissions auto --sandbox workspace-write` |
437
+ | `open` | `--permissions bypass --sandbox full-access` (still requires `allow_dangerous_flags`) |
438
+
439
+ `deny` with `workspace-write` or `full-access` has no level and stays available
440
+ through the flags.
441
+
442
+ Every launch also records how strongly its containment was imposed:
443
+
444
+ | `enforcement` | Meaning |
445
+ | ------------- | ---------------------------------------------------------------------------- |
446
+ | `kernel` | Codex or Claude Code with an OS sandbox configured and active. |
447
+ | `tool-policy` | OpenCode — rules the agent is asked to respect, with no kernel enforcement. |
448
+ | `none` | `full-access` — the sandbox is deliberately disabled. |
449
+
450
+ ### Requester profiles and enrolments
451
+
452
+ A non-local caller — one reached through a transport such as Tin Can rather
453
+ than the local CLI or a local MCP client — is identified by an `(authority,
454
+ subject)` tuple supplied by whatever already authenticated it. That tuple must
455
+ be enrolled in `config.toml` and bound to a profile, or the request is
456
+ refused. Two additions to `configSchema`, following the existing record style
457
+ of `[projects]`, `[mcp_servers]` and `[plugins]`:
458
+
459
+ ```toml
460
+ [requester_profiles.research]
461
+ allowed_roots = ["/Users/mikewilliams/Source/brutalsystems"]
462
+ level = "read"
463
+ min_enforcement = "kernel" # "tool-policy" to admit OpenCode here
464
+ env_allow = ["GH_TOKEN"]
465
+ env_defaults = { PATH = "/usr/bin:/bin", LANG = "en_US.UTF-8" }
466
+ identities = ["research"] # bounds which identities this requester may name
467
+
468
+ [[requesters]]
469
+ authority = "example.peer.v1"
470
+ subject = "<opaque authenticated key id>"
471
+ label = "m4pro"
472
+ profile = "research"
473
+ ```
474
+
475
+ `[[requesters]]` is an array of tables rather than a record keyed by
476
+ `"authority:subject"`, so no separator can be ambiguous inside a subject. Two
477
+ enrolments with the same `(authority, subject)` are a configuration error
478
+ rather than a precedence rule. `label` is optional and never read for policy.
479
+
480
+ `allowed_roots` is **required** on a profile and must be non-empty — it is the
481
+ one key with no safe default, since the global `allowed_roots` defaults to
482
+ empty and empty is read as permitting anything the account can reach. Every
483
+ other key defaults to its narrowest value: `level` defaults to `read` and
484
+ excludes `open` (no remote request may disable the sandbox), `min_enforcement`
485
+ defaults to `kernel` (the default admits Codex and Claude Code; `tool-policy`
486
+ is what admits OpenCode), and `env_allow`/`env_defaults` default to empty.
487
+ Where a key appears in both, `env_defaults` wins — a profile that pins a value
488
+ and also allows the key means the value it pinned, not the accepting shell's. A
489
+ profile written as nothing but a required `allowed_roots` is therefore the
490
+ most restrictive profile expressible.
491
+
492
+ `identities` bounds which named identities (see [Agent
493
+ identities](#agent-identities) above) a non-local requester may launch under,
494
+ and it also defaults to its narrowest value — empty:
495
+
496
+ - **Empty or omitted** refuses any identity the request names — the launch
497
+ falls back to lending the operator's own ambient account, same as before
498
+ identities existed.
499
+ - **Exactly one** makes that identity the default: a request naming no
500
+ identity gets it anyway, and a request naming it explicitly gets the same
501
+ launch.
502
+ - **Several** require the request to choose: a request naming none of them is
503
+ refused rather than guessing, and a request naming one outside the list is
504
+ refused too.
505
+
506
+ A local requester has no profile and no ceiling here either — it may name any
507
+ identity that exists, exactly as it may reach any root.
508
+
509
+ A request naming no level resolves to its profile's level — never to the
510
+ receiving machine's own `config.permissions` / `config.sandbox` defaults,
511
+ which would let an unconfigured local default hand a remote caller more than
512
+ its profile grants.
513
+
514
+ **The `env_allow` trap.** A non-local launch's environment is composed from
515
+ nothing — an allowlist, not the accepting shell's environment filtered by a
516
+ denylist. That means a remote profile must list in `env_allow` (or supply in
517
+ `env_defaults`) everything the runtime's own toolchain needs — not just what
518
+ the agent's task needs. A missing entry does not present as a policy refusal;
519
+ it presents as a **broken runtime** — a launch that starts and then fails in
520
+ ways that look like a bug in Codex, Claude Code or OpenCode rather than a
521
+ missing environment variable. This is exactly the diagnosis-confusing failure
522
+ shape the rest of this feature avoids, so test a first remote profile with a
523
+ trivial launch before relying on it.
524
+
525
+ **`env_allow` is not the whole environment.** Two configured things still
526
+ contribute variables read from the accepting process's unfiltered environment,
527
+ exactly as for a local launch. A selected MCP server contributes its own declared
528
+ `env_vars` and its `bearer_token_env_var`. And on every OpenCode launch, each
529
+ configured `[opencode.provider]` contributes its API key, because the provider
530
+ configuration handed to the runtime is built from them — so a remote OpenCode
531
+ launch reaches this machine's model providers even with `env_allow = []`. That is the same principle applied
532
+ one level up: a configured credential authenticates, one that merely happens to
533
+ be present does not. It does mean a remote request may not choose which servers
534
+ or plugins run: naming `mcp` or `plugin` is refused as not expressible
535
+ remotely, and a remote launch gets this machine's configured `default_mcp` and
536
+ `default_plugins`. To withhold a credential from a remote requester, withhold
537
+ the server — a profile cannot withhold the variable alone. Provider keys cannot
538
+ be withheld per requester at all today; a machine that must not lend its model
539
+ providers should not enrol a remote requester for OpenCode.
540
+
541
+ **What this does and does not defend against.** Muster does not authenticate
542
+ its caller. A trusted local invoker that has already authenticated a remote
543
+ peer passes along the provenance it established, and Muster applies least
544
+ privilege to that provenance. This is not a defence against a compromised
545
+ same-user process: anything able to exec Muster can already rewrite the very
546
+ `config.toml` it reads, including its own enrolment and profile. The value is
547
+ in what a legitimately-authenticated remote peer is confined to, not in
548
+ resisting an attacker who is already running as the same local user.
549
+
550
+ **Stop and output are asymmetric.** A local operator may stop or read the
551
+ output of any launch on the machine, as before. A non-local requester may
552
+ `stop` or read `output` for only the launches it asked for — an attempt to act
553
+ on another requester's launch is refused. An entry with no recorded requester
554
+ (older data, or a local launch) reads as local and is therefore protected the
555
+ same as any other local launch. `list` is not scoped this way: every caller
556
+ sees every launch on the machine, so the asymmetry covers acting on a launch,
557
+ not seeing that it exists.
558
+
559
+ ## Agent identities
560
+
561
+ An **identity** is a named, pre-authenticated template configuration for one
562
+ agent, stored under `~/.muster/identities/<name>` at mode `0700`. Launching
563
+ under one makes a launch state which account it runs as instead of silently
564
+ inheriting whatever the environment happened to imply.
565
+
566
+ ```sh
567
+ muster setup-identity --identity work --agent claude --token-env CLAUDE_WORK_TOKEN
568
+ muster setup-identity --identity ci-bot --agent codex
569
+ muster setup-identity --identity research --agent opencode
570
+ ```
571
+
572
+ `--token-env` names the environment variable holding a Claude Code token (from
573
+ `claude setup-token`) — never the token itself. It applies to Claude **only**,
574
+ and Claude **requires** it: Claude's keychain credential is keyed to the
575
+ template directory's own path, and a launch runs from a per-launch copy whose
576
+ path hashes to a different keychain service name, so a token is the only thing
577
+ that can authenticate a Claude identity launch. `muster run --identity` refuses
578
+ a Claude identity without one rather than spawning an agent that cannot log in.
579
+ Codex and OpenCode authenticate from an auth file inside the template, so
580
+ `--token-env` is refused for them — it could only ever add a way for the launch
581
+ to fail.
582
+
583
+ `setup-identity` creates the template (or reuses an existing one for the same
584
+ agent — it refuses to repurpose one that belongs to a different agent),
585
+ prints the **one-time login** for that agent to run yourself, and reports
586
+ whether a credential is already configured:
587
+
588
+ ```sh
589
+ CLAUDE_CONFIG_DIR=~/.muster/identities/work claude setup-token # then export it
590
+ CODEX_HOME=~/.muster/identities/ci-bot codex login
591
+ OPENCODE_CONFIG_DIR=~/.muster/identities/research opencode auth login
592
+ ```
593
+
594
+ Muster never performs that login itself — each is an interactive OAuth flow,
595
+ and a command that appeared to do it for you would be hiding a credential
596
+ prompt. Run the printed command, complete the flow, then check the result:
597
+
598
+ ```sh
599
+ muster identities
600
+ ```
601
+
602
+ Launch under a named identity with `--identity`:
603
+
604
+ ```sh
605
+ muster run claude --identity work --cwd ~/projects/thing --prompt 'Ship it'
606
+ ```
607
+
608
+ At launch, Muster copies a declared subset of that identity's files into a
609
+ fresh per-launch directory under `~/.muster/identities-live/<launchId>/` and
610
+ points the runtime's own configuration-location variable
611
+ (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `OPENCODE_CONFIG_DIR` +
612
+ `XDG_DATA_HOME`) at that copy — never at the template itself, so one launch
613
+ can never mutate another's credential.
614
+
615
+ The copy lives only as long as the launch. `muster stop` removes it directly,
616
+ and otherwise it is removed by the **first `muster list` after the entry
617
+ reaches a terminal status** (`exited`, `failed`, `stopped`, `unknown`). There
618
+ is no prune operation — an earlier draft of this documentation said the copy
619
+ was retained until the entry was pruned, and that was never what was built.
620
+ This matters for two reasons: an agent writes its own session transcript into
621
+ its configuration directory, so that transcript goes with the copy moments
622
+ after the launch ends (capture it while the launch is still running if you
623
+ need it), and resume/fork (#32) cannot assume a copy is still there.
624
+
625
+ Launching without `--identity` sets none of this: behaviour is exactly as
626
+ before, using the ambient environment.
627
+
628
+ Session discovery follows the same relocated path, so a Claude identity's
629
+ sessions are found even though `CLAUDE_CONFIG_DIR` no longer points at
630
+ `~/.claude` — this was previously refused outright as an unsupported
631
+ configuration.
632
+
633
+ Five things worth stating plainly, because each is a sharp edge:
634
+
635
+ 1. **`muster identities` reports *configured*, never *valid*.** A keychain
636
+ item, an auth file or a set variable proves a credential exists — not that
637
+ it still works. Tokens expire and are revoked; there is no way to check
638
+ that without exercising the credential, and `identities` does not do that.
639
+ 2. **The identity store holds credentials at rest** for Codex and OpenCode,
640
+ because their own logins write auth files directly into it. Directories are
641
+ `0700`, and deleting an identity deletes a credential — there is no
642
+ separate secret to revoke elsewhere first. Claude Code is the exception:
643
+ its credential lives in the macOS keychain (scoped to the identity's
644
+ directory path), or is supplied by `--token-env`, never as a file in the
645
+ identity directory.
646
+ 3. **Use `claude setup-token` with `--token-env`, not `ANTHROPIC_API_KEY`.**
647
+ The OAuth token from `setup-token` bills the account's Claude subscription.
648
+ An `ANTHROPIC_API_KEY` silently moves the same account onto pay-per-token
649
+ API billing instead — same agent, very different bill, no error either way.
650
+ 4. **A Claude identity's token is briefly written to disk during a launch.**
651
+ The tmux host serialises the whole launch specification — environment
652
+ included — to a temporary `launch.json` at mode `0600` and removes it once
653
+ the launch is recorded. That is the same treatment every other launch
654
+ variable gets, not special handling for this one, but it does mean a
655
+ long-lived subscription token passes through disk; a launch whose host
656
+ temp directory leaks (a crash before cleanup, a misconfigured `TMPDIR`)
657
+ leaves it there.
658
+ 5. **A requester profile with no `identities` lends the operator's own
659
+ account** to that requester, because the launch falls back to the ambient
660
+ environment exactly as it did before identities existed. That is preserved
661
+ for compatibility, not a recommendation — an operator should set
662
+ `identities` on any requester profile whose access they actually care
663
+ about bounding.
664
+
665
+ A Claude launch under an identity also marks the launch's working directory
666
+ trusted *inside that launch's own copy* of the configuration, so a brand-new
667
+ directory starts as a registered session without anyone accepting a trust
668
+ dialog. This is distinct from the ordinary Claude prerequisite described
669
+ above: it only ever writes to the per-launch copy, never to the operator's own
670
+ `~/.claude.json`, and only applies to launches under an identity. A launch
671
+ without `--identity` still requires the operator to have trusted the directory
672
+ themselves beforehand.
673
+
674
+ Automated tests use fake runtimes, so they prove the plumbing — the copy, the
675
+ variables, the trust flag, the lifecycle — but not that a real agent starts
676
+ authenticated from a copy. See
677
+ [docs/identity-verification.md](./docs/identity-verification.md) for the
678
+ manual checklist that covers that, and for which agents have actually been
679
+ verified.
421
680
 
422
681
  ## Optional tools for launched agents
423
682
 
@@ -6,8 +6,6 @@ import { promisify } from "node:util";
6
6
  /** Claude's CLI cannot report all effective enterprise policy before starting.
7
7
  * Refuse detected managed sources rather than pretending inline settings win. */
8
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
9
  const home = env.HOME ?? homedir();
12
10
  const system = process.platform === "darwin"
13
11
  ? "/Library/Application Support/ClaudeCode"
package/dist/config.js CHANGED
@@ -6,13 +6,60 @@ import { z } from "zod";
6
6
  import { mcpName, mcpServerSchema } from "./mcp.js";
7
7
  import { pluginName, pluginSchema } from "./plugins.js";
8
8
  import { openCodeSectionSchema } from "./providers.js";
9
+ import { identityNameSchema } from "./identity-store.js";
10
+ import { authoritySchema, subjectSchema } from "./requester.js";
11
+ import { RequesterConfigError } from "./requester-policy.js";
12
+ /**
13
+ * What a remote requester is permitted here. Every default is the narrowest
14
+ * value, so the shortest profile is the most restrictive one.
15
+ *
16
+ * `allowed_roots` is the one key with no safe default: the global
17
+ * `allowed_roots` defaults to empty, which `assertAllowedWorkspace` reads as
18
+ * permitting anything the account can reach, so inheriting it would hand an
19
+ * unconfigured profile the whole filesystem.
20
+ *
21
+ * `level` excludes `open` deliberately — no remote request may disable the
22
+ * sandbox, and a config naming it should fail loudly rather than be checked for
23
+ * at launch.
24
+ *
25
+ * `min_enforcement` is the one key whose default excludes a runtime rather than
26
+ * narrowing a permission: `kernel` admits Codex and Claude Code, and
27
+ * `tool-policy` is what admits OpenCode.
28
+ */
29
+ export const requesterProfileSchema = z
30
+ .object({
31
+ allowed_roots: z.array(z.string().min(1)).min(1),
32
+ level: z.enum(["read", "work"]).default("read"),
33
+ min_enforcement: z.enum(["kernel", "tool-policy"]).default("kernel"),
34
+ env_allow: z.array(z.string().min(1)).default([]),
35
+ /**
36
+ * Baseline overrides, e.g. a PATH that does not depend on the accepting
37
+ * shell. It wins over the source environment for every key it sets,
38
+ * `env_allow` entries included: a profile that pins a value and also allows
39
+ * the key meant the value it pinned.
40
+ */
41
+ env_defaults: z.record(z.string().min(1), z.string()).default({}),
42
+ /** Which identities this requester may launch under. Empty refuses any. */
43
+ identities: z.array(identityNameSchema).default([]),
44
+ })
45
+ .strict();
46
+ /** Binding an authenticated `(authority, subject)` to a profile. The grant. */
47
+ export const requesterEnrolmentSchema = z
48
+ .object({
49
+ authority: authoritySchema,
50
+ subject: subjectSchema,
51
+ label: z.string().min(1).max(200).optional(),
52
+ profile: z.string().min(1),
53
+ })
54
+ .strict();
9
55
  export const configSchema = z
10
56
  .object({
11
57
  host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
12
58
  launch_timeout_sec: z.number().finite().positive().max(600).default(30),
13
59
  max_concurrent: z.number().int().positive().max(1000).default(4),
14
60
  allowed_roots: z.array(z.string().min(1)).default([]),
15
- projects: z.record(z.string().regex(/^[a-zA-Z0-9_-]+$/), z.string().min(1))
61
+ projects: z
62
+ .record(z.string().regex(/^[a-zA-Z0-9_-]+$/), z.string().min(1))
16
63
  .default({}),
17
64
  permissions: z.enum(["auto", "deny", "bypass"]).default("deny"),
18
65
  sandbox: z
@@ -29,6 +76,10 @@ export const configSchema = z
29
76
  plugins: z.record(pluginName, pluginSchema).default({}),
30
77
  opencode: openCodeSectionSchema,
31
78
  allow_dangerous_flags: z.boolean().default(false),
79
+ requester_profiles: z
80
+ .record(z.string().regex(/^[a-zA-Z0-9_-]+$/), requesterProfileSchema)
81
+ .default({}),
82
+ requesters: z.array(requesterEnrolmentSchema).default([]),
32
83
  })
33
84
  .strict();
34
85
  export const musterHome = () => join(homedir(), ".muster");
@@ -42,6 +93,18 @@ export async function loadConfig(home = musterHome()) {
42
93
  throw e;
43
94
  }
44
95
  const result = configSchema.parse(value);
96
+ const seen = new Set();
97
+ for (const e of result.requesters) {
98
+ // The spec lists both of these under RequesterConfigError, and the error
99
+ // model exists so a transport maps to stable codes without matching on
100
+ // message text. A plain Error is indistinguishable from a parse failure.
101
+ if (!result.requester_profiles[e.profile])
102
+ throw new RequesterConfigError(`Requester ${e.authority}/${e.subject} names unknown profile ${e.profile}. Configured: ${Object.keys(result.requester_profiles).join(", ") || "none"}`);
103
+ const key = [e.authority, e.subject].join("\0");
104
+ if (seen.has(key))
105
+ throw new RequesterConfigError(`Duplicate requester enrolment for ${e.authority}/${e.subject}`);
106
+ seen.add(key);
107
+ }
45
108
  if ((["danger-full-access", "full-access"].includes(result.sandbox) ||
46
109
  result.permissions === "bypass") &&
47
110
  !result.allow_dangerous_flags)
@@ -0,0 +1,5 @@
1
+ export function enforcementGrade(runtime, resolved) {
2
+ if (resolved.sandbox === "full-access")
3
+ return "none";
4
+ return runtime === "opencode" ? "tool-policy" : "kernel";
5
+ }
package/dist/guard.js CHANGED
@@ -4,6 +4,7 @@ import { realpath } from "node:fs/promises";
4
4
  import { isAbsolute, relative, resolve } from "node:path";
5
5
  import { mcpName, claudeMcpConfig } from "./mcp.js";
6
6
  import { pluginName } from "./plugins.js";
7
+ import { RequesterConfigError } from "./requester-policy.js";
7
8
  export const runSchema = z
8
9
  .object({
9
10
  runtime: z.enum(["codex", "claude", "opencode"]),
@@ -28,11 +29,48 @@ export const runSchema = z
28
29
  .string()
29
30
  .regex(/^[a-zA-Z0-9_-]+$/)
30
31
  .optional(),
32
+ /** Name of a stored identity to launch under. See src/identity-store.ts. */
33
+ identity: z
34
+ .string()
35
+ .regex(/^[a-zA-Z0-9_-]+$/)
36
+ .optional(),
31
37
  requestKey: z.string().min(1).max(200).optional(),
38
+ level: z.enum(["read", "work", "open"]).optional(),
32
39
  args: z.array(z.string()).default([]),
33
40
  })
34
41
  .strict();
42
+ /**
43
+ * A level is a spelling of a legal `permissions` + `sandbox` pair, not a second
44
+ * policy path — `test/level.test.ts` asserts the equivalence for all three.
45
+ * The two legal combinations no level names, `deny` + `workspace-write` and
46
+ * `deny` + `full-access`, stay reachable through the flags.
47
+ */
48
+ export const LEVELS = {
49
+ read: { permissions: "deny", sandbox: "read-only" },
50
+ work: { permissions: "auto", sandbox: "workspace-write" },
51
+ open: { permissions: "bypass", sandbox: "full-access" },
52
+ };
53
+ /**
54
+ * Checked against raw input before parsing, like `project` and `cwd`: once
55
+ * defaults have been applied a caller's value is indistinguishable from one
56
+ * Muster chose.
57
+ */
58
+ export function assertLevelExclusive(input) {
59
+ if (typeof input !== "object" || input === null)
60
+ return;
61
+ const o = input;
62
+ if (o.level !== undefined &&
63
+ (o.permissions !== undefined || o.sandbox !== undefined))
64
+ throw new Error("use level or permissions/sandbox, not both");
65
+ }
35
66
  export function resolvePermissions(req, config) {
67
+ if (req.level) {
68
+ const pair = { ...LEVELS[req.level] };
69
+ if ((pair.permissions === "bypass" || pair.sandbox === "full-access") &&
70
+ !config.allow_dangerous_flags)
71
+ throw new Error("bypass or full-access requires allow_dangerous_flags=true in config.toml");
72
+ return pair;
73
+ }
36
74
  const permissions = req.permissions ?? config.permissions;
37
75
  const configuredSandbox = req.sandbox ?? config.sandbox;
38
76
  const sandbox = configuredSandbox === "danger-full-access"
@@ -108,6 +146,12 @@ export function paramsFingerprint(req, permissions, mcp, plugins) {
108
146
  [...mcp].sort(),
109
147
  [...plugins].sort(),
110
148
  req.model ?? null,
149
+ // Hashed because it selects which account's credentials the agent
150
+ // holds. Without it, one request key reused across two identities
151
+ // dedupes: the caller asks for account-b and is handed the launch
152
+ // already running under account-a, reported as success. A reused key
153
+ // with different parameters must be refused, not answered.
154
+ req.identity ?? null,
111
155
  req.args,
112
156
  ]))
113
157
  .digest("hex");
@@ -209,3 +253,62 @@ export function launchEnv(env) {
209
253
  const blocked = /^(CLAUDECODE|CLAUDE_CODE_|CODEX_THREAD_ID$|TMUX$|TMUX_PANE$|MCP_)/;
210
254
  return Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && !blocked.test(k)));
211
255
  }
256
+ /**
257
+ * What a composed environment must contain to launch at all. `LANG` is here
258
+ * deliberately: an empty or absent `LANG` has broken every tmux launch on the
259
+ * remote hosts before, and a composed environment is exactly where it goes
260
+ * missing.
261
+ */
262
+ export const BASELINE_ENV = [
263
+ "PATH",
264
+ "HOME",
265
+ "USER",
266
+ "SHELL",
267
+ "TMPDIR",
268
+ "TERM",
269
+ "LANG",
270
+ ];
271
+ /**
272
+ * The base environment handed to the launched agent — distinct from the
273
+ * environment Muster's own subprocesses run in, which stays denylist-filtered
274
+ * from the parent because it is Muster's own machinery on its own machine and
275
+ * never reaches an agent.
276
+ *
277
+ * Local requesters keep the denylist: the user's own machine, the user's own
278
+ * credentials, long-standing documented behaviour. Remote requesters get an
279
+ * allowlist composed from nothing, because the alternative hands an agent
280
+ * whatever happened to be in the environment of the process that accepted the
281
+ * request — a set nobody chose.
282
+ */
283
+ export function agentBaseEnv(requester, profile, sourceEnv) {
284
+ if (requester.kind === "local")
285
+ return launchEnv(sourceEnv);
286
+ if (!profile)
287
+ throw new RequesterConfigError("a remote requester reached environment composition without a profile");
288
+ const env = {};
289
+ // Allow-listed variables come from the accepting environment.
290
+ for (const key of profile.env_allow) {
291
+ const value = sourceEnv[key];
292
+ if (typeof value === "string")
293
+ env[key] = value;
294
+ }
295
+ // The baseline a launch cannot run without, from the profile where it sets one.
296
+ for (const key of BASELINE_ENV) {
297
+ const value = profile.env_defaults[key] ?? sourceEnv[key];
298
+ if (typeof value === "string" && value.length)
299
+ env[key] = value;
300
+ }
301
+ // `env_defaults` wins over everything above, and is delivered even for a key
302
+ // that is neither baseline nor allow-listed. A profile that sets a value meant
303
+ // the value it chose; dropping it would leave the profile reading as though it
304
+ // supplies something it never delivers, and pinning `PATH` while handing over
305
+ // the accepting shell's raw one is precisely the mismatch this feature exists
306
+ // to remove.
307
+ for (const [key, value] of Object.entries(profile.env_defaults))
308
+ if (typeof value === "string" && value.length)
309
+ env[key] = value;
310
+ for (const required of ["PATH", "LANG"])
311
+ if (!env[required])
312
+ throw new RequesterConfigError(`composed environment for this requester has no ${required}; set it under the profile's [env_defaults] table`);
313
+ return env;
314
+ }
@@ -5,8 +5,21 @@ import { readFile, access } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { descendants } from "./processes.js";
8
+ /**
9
+ * Where Claude Code records its sessions. `CLAUDE_CONFIG_DIR` relocates the whole
10
+ * configuration directory, sessions included, so discovery must follow it —
11
+ * otherwise a launch whose configuration was relocated records its session
12
+ * somewhere Muster never looks, and resolution fails at the deadline rather than
13
+ * reporting anything useful.
14
+ */
15
+ export function claudeSessionsDir(env) {
16
+ const configured = env.CLAUDE_CONFIG_DIR;
17
+ if (typeof configured === "string" && configured.length)
18
+ return join(configured, "sessions");
19
+ return join(env.HOME ?? homedir(), ".claude", "sessions");
20
+ }
8
21
  export async function resolveClaude(root, env, deadline) {
9
- const dir = join(env.HOME ?? homedir(), ".claude", "sessions");
22
+ const dir = claudeSessionsDir(env);
10
23
  const matches = [];
11
24
  for (const pid of await descendants(root, deadline)) {
12
25
  if (Date.now() >= deadline)
@@ -0,0 +1,66 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { identityPath } from "./identity-store.js";
7
+ const run = promisify(execFile);
8
+ /**
9
+ * Claude Code stores its credential in the macOS keychain under a service name
10
+ * derived from the configuration directory's PATH, which is why a copied
11
+ * directory cannot authenticate: it hashes to a name that does not exist.
12
+ */
13
+ export function claudeKeychainService(configDir) {
14
+ return ("Claude Code-credentials-" +
15
+ createHash("sha256").update(configDir).digest("hex").slice(0, 8));
16
+ }
17
+ async function keychainHas(service) {
18
+ try {
19
+ await run("security", ["find-generic-password", "-s", service]);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ async function readsAsJson(file) {
27
+ try {
28
+ JSON.parse(await readFile(file, "utf8"));
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ export async function identityAuthState(home, name, meta, env, probe = keychainHas) {
36
+ const dir = identityPath(home, name);
37
+ const no = (detail) => ({ state: "not-configured", detail });
38
+ const yes = (detail) => ({ state: "configured", detail });
39
+ if (meta.agent === "claude") {
40
+ if (meta.token_env)
41
+ return env[meta.token_env]
42
+ ? yes(`token variable ${meta.token_env} is set`)
43
+ : no(`token variable ${meta.token_env} is not set`);
44
+ // A keychain item for the TEMPLATE's path proves the template is usable
45
+ // where it sits, and nothing more. A launch never points Claude at the
46
+ // template: it copies it, and the copy's path hashes to a DIFFERENT service
47
+ // name with no fallback (see claudeKeychainService above). So the keychain
48
+ // route cannot produce a launch, and reporting "configured" for it would
49
+ // promise one that dies at its deadline. `token_env` is the only route a
50
+ // Claude identity can launch under, so its absence is not-configured
51
+ // regardless of what the keychain holds.
52
+ return no((await probe(claudeKeychainService(dir)))
53
+ ? "a keychain item exists for this identity's template path, but a launch runs from a copy whose path hashes to a different service name — mint a token with `claude setup-token` and name the variable with --token-env"
54
+ : "no credential for a launch — mint a token with `claude setup-token` and name the variable with --token-env");
55
+ }
56
+ // Codex keeps a flat JSON auth file. OpenCode splits config from data: its
57
+ // auth file lives under a data root the binary itself always suffixes with
58
+ // "opencode" (join(XDG_DATA_HOME, "opencode", "auth.json") — confirmed
59
+ // against the installed 1.18.32 binary and the real ~/.local/share/opencode
60
+ // layout; see identity-copy.ts and task-4-report.md). The identity template
61
+ // mirrors that shape under data/opencode/auth.json.
62
+ const file = meta.agent === "opencode" ? join("data", "opencode", "auth.json") : "auth.json";
63
+ return (await readsAsJson(join(dir, file)))
64
+ ? yes(`${file} is present`)
65
+ : no(`${file} is missing or unreadable — log in for this identity`);
66
+ }