@brutalsystems/muster 0.7.14 → 0.7.16

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
@@ -157,7 +157,10 @@ Auto-selection tries tmux, then pty. Every peer includes `host`, `capabilities`
157
157
  and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
158
158
 
159
159
  - **tmux:** each agent has its own session on the dedicated `muster` tmux
160
- server. Opening or exiting one agent does not switch another agent's viewer.
160
+ server. `MUSTER_TMUX_SERVER` names a different one, for a caller that must not
161
+ share the machine's Muster server — Muster's own test suite sets it, so a gate
162
+ run cannot leave sessions beside yours. Opening or exiting one agent does not
163
+ switch another agent's viewer.
161
164
  Watchable and attachable; survives the CLI or MCP server exiting.
162
165
  Use the returned attach hint to reconnect to that agent.
163
166
  - **pty:** not watchable or attachable. The CLI prints the peer record and stays
@@ -564,36 +567,62 @@ under one makes a launch state which account it runs as instead of silently
564
567
  inheriting whatever the environment happened to imply.
565
568
 
566
569
  ```sh
567
- muster setup-identity --identity work --agent claude --token-env CLAUDE_WORK_TOKEN
570
+ muster setup-identity --identity work --agent claude --interactive
568
571
  muster setup-identity --identity ci-bot --agent codex
569
572
  muster setup-identity --identity research --agent opencode
570
573
  ```
571
574
 
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
+ Claude needs a credential of its own: its keychain credential is keyed to the
575
576
  template directory's own path, and a launch runs from a per-launch copy whose
576
577
  path hashes to a different keychain service name, so a token is the only thing
577
578
  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.
579
+ a Claude identity with neither route configured rather than spawning an agent
580
+ that cannot log in. There are two ways to give it one:
581
+
582
+ - **`--interactive` (recommended).** Runs `claude setup-token` itself inside a
583
+ pty with your terminal attached both ways — you see the flow and approve the
584
+ browser step, and anything it asks you (the `Paste code here if prompted:`
585
+ fallback, say, which is what an ssh session gets) reaches it from your
586
+ keyboard. It captures the token from the output and stores it at
587
+ `~/.muster/identities/<name>/token`, mode `0600` — the same directory
588
+ Codex and OpenCode identities already hold their own `auth.json` in. The
589
+ command prints a redacted confirmation of what it stored, never the token
590
+ (`muster identities` later reports only that a stored token is present). If
591
+ the login's output format ever changes so the token isn't recognised, this
592
+ fails open: nothing is redacted or captured, the token appears exactly as it
593
+ does today, and the command says it could not store one and points you at
594
+ `--token-env` to record the one now on your screen. It needs a terminal, and
595
+ refuses when stdin is not one.
596
+ - **`--token-env NAME`**, for a machine that should keep no credential at
597
+ rest — CI, say. It names the environment variable holding a Claude Code
598
+ token (from a `claude setup-token` run yourself) — never the token itself.
599
+ It applies to Claude **only**; Codex and OpenCode authenticate from an auth
600
+ file inside the template, so `--token-env` is refused for them — it could
601
+ only ever add a way for the launch to fail.
602
+
603
+ An identity holds exactly one of these routes at a time: storing a token
604
+ clears a declared `--token-env`, and passing `--token-env` deletes a stored
605
+ token. `setup-identity` says which route it removed, if either. Passing
606
+ `--interactive` and `--token-env` in the same command is refused rather than
607
+ resolved by precedence — they ask for opposite things, and choosing one for
608
+ you would be choosing where the credential lives.
582
609
 
583
610
  `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:
611
+ agent — it refuses to repurpose one that belongs to a different agent) and
612
+ reports whether a credential is already configured. Without `--interactive`
613
+ it prints the **one-time login** for that agent to run yourself instead of
614
+ running it for you:
587
615
 
588
616
  ```sh
589
- CLAUDE_CONFIG_DIR=~/.muster/identities/work claude setup-token # then export it
617
+ CLAUDE_CONFIG_DIR=~/.muster/identities/work claude setup-token # then export it, or use --interactive
590
618
  CODEX_HOME=~/.muster/identities/ci-bot codex login
591
619
  OPENCODE_CONFIG_DIR=~/.muster/identities/research opencode auth login
592
620
  ```
593
621
 
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:
622
+ Muster never performs a login without `--interactive` — each is an
623
+ interactive OAuth flow, and a command that appeared to do it for you without
624
+ showing you the flow would be hiding a credential prompt. Run the printed
625
+ command, complete the flow, then check the result:
597
626
 
598
627
  ```sh
599
628
  muster identities
@@ -633,20 +662,27 @@ configuration.
633
662
  Five things worth stating plainly, because each is a sharp edge:
634
663
 
635
664
  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.
665
+ item, an auth file, a stored token or a set variable proves a credential
666
+ exists — not that it still works. Tokens expire and are revoked; there is
667
+ no way to check that without exercising the credential, and `identities`
668
+ does not do that.
669
+ 2. **The identity store holds credentials at rest**, for every agent that
670
+ keeps one there. Codex and OpenCode always do, because their own logins
671
+ write auth files directly into the template. A Claude identity created
672
+ with `--interactive` does too, at `~/.muster/identities/<name>/token` —
673
+ the same directory, the same `0700`/`0600` protection, nothing more:
674
+ storing it does not make it any safer at rest than Codex's or OpenCode's
675
+ `auth.json` sitting right beside it, it just makes Claude consistent with
676
+ them instead of relying on the operator's shell rc file. Directories are
677
+ `0700`, and deleting an identity deletes whatever credential it holds —
678
+ there is no separate secret to revoke elsewhere first. The one route with
679
+ nothing at rest is `--token-env`: it names a variable instead, so the
680
+ credential lives only in whichever environment sets that variable.
681
+ 3. **Use `--interactive`, or `claude setup-token` with `--token-env` — never
682
+ `ANTHROPIC_API_KEY`.** The OAuth token from `setup-token` bills the
683
+ account's Claude subscription. An `ANTHROPIC_API_KEY` silently moves the
684
+ same account onto pay-per-token API billing instead — same agent, very
685
+ different bill, no error either way.
650
686
  4. **A Claude identity's token is briefly written to disk during a launch.**
651
687
  The tmux host serialises the whole launch specification — environment
652
688
  included — to a temporary `launch.json` at mode `0600` and removes it once
@@ -1,8 +1,20 @@
1
1
  import { TmuxHost } from "./tmux.js";
2
2
  import { PtyHost } from "./pty.js";
3
3
  import { MacosTerminalHost } from "./macos-terminal.js";
4
- export function hosts() {
5
- return [new TmuxHost(), new PtyHost(), new MacosTerminalHost()];
4
+ /**
5
+ * There is one Muster tmux server per machine, which is why the name is a
6
+ * default rather than a parameter everywhere. `MUSTER_TMUX_SERVER` overrides it
7
+ * for callers that must not share that server — the suite above all, whose
8
+ * launches would otherwise land beside a developer's real sessions on `-L
9
+ * muster` and stay there, since a tmux launch is meant to survive its parent
10
+ * exiting (#57).
11
+ */
12
+ export function hosts(env = process.env) {
13
+ return [
14
+ new TmuxHost(env.MUSTER_TMUX_SERVER || undefined),
15
+ new PtyHost(),
16
+ new MacosTerminalHost(),
17
+ ];
6
18
  }
7
19
  export async function selectHost(id, drivers) {
8
20
  for (const driver of drivers)
@@ -3,7 +3,8 @@ import { execFile } from "node:child_process";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
- import { identityPath } from "./identity-store.js";
6
+ import { identityPath, IdentityError, } from "./identity-store.js";
7
+ import { resolveCredential } from "./identity-token.js";
7
8
  const run = promisify(execFile);
8
9
  /**
9
10
  * Claude Code stores its credential in the macOS keychain under a service name
@@ -37,21 +38,35 @@ export async function identityAuthState(home, name, meta, env, probe = keychainH
37
38
  const no = (detail) => ({ state: "not-configured", detail });
38
39
  const yes = (detail) => ({ state: "configured", detail });
39
40
  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`);
41
+ // A broken credential must not take the whole listing down with it.
42
+ // `describeIdentities` calls this once per identity with no error handling
43
+ // of its own, so an IdentityError thrown here — an empty token file, an
44
+ // unreadable one — would make `muster identities` fail outright and report
45
+ // nothing about the identities that are fine. That is the same hazard the
46
+ // stale-grant comment in identity-cli.ts already names: a local problem
47
+ // turning into a total outage. Reported as a state, not raised.
48
+ let credential;
49
+ try {
50
+ credential = await resolveCredential(home, name, meta, env);
51
+ }
52
+ catch (e) {
53
+ return no(e instanceof IdentityError ? e.message : String(e));
54
+ }
55
+ if (credential.route === "env")
56
+ return credential.token
57
+ ? yes(`token variable ${credential.variable} is set`)
58
+ : no(`token variable ${credential.variable} is not set`);
59
+ if (credential.token)
60
+ return yes("a stored token is present");
44
61
  // A keychain item for the TEMPLATE's path proves the template is usable
45
62
  // where it sits, and nothing more. A launch never points Claude at the
46
63
  // template: it copies it, and the copy's path hashes to a DIFFERENT service
47
64
  // name with no fallback (see claudeKeychainService above). So the keychain
48
65
  // 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.
66
+ // promise one that dies at its deadline.
52
67
  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");
68
+ ? "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 — run `setup-identity --interactive` to store a token instead"
69
+ : "no credential for a launch — run `setup-identity --interactive` to log in and store a token");
55
70
  }
56
71
  // Codex keeps a flat JSON auth file. OpenCode splits config from data: its
57
72
  // auth file lives under a data root the binary itself always suffixes with
@@ -59,7 +74,9 @@ export async function identityAuthState(home, name, meta, env, probe = keychainH
59
74
  // against the installed 1.18.32 binary and the real ~/.local/share/opencode
60
75
  // layout; see identity-copy.ts and task-4-report.md). The identity template
61
76
  // mirrors that shape under data/opencode/auth.json.
62
- const file = meta.agent === "opencode" ? join("data", "opencode", "auth.json") : "auth.json";
77
+ const file = meta.agent === "opencode"
78
+ ? join("data", "opencode", "auth.json")
79
+ : "auth.json";
63
80
  return (await readsAsJson(join(dir, file)))
64
81
  ? yes(`${file} is present`)
65
82
  : no(`${file} is missing or unreadable — log in for this identity`);
@@ -1,5 +1,7 @@
1
1
  import { identityAuthState } from "./identity-auth.js";
2
2
  import { IdentityError, identityDirExists, identityMetaSchema, identityNameSchema, identityPath, listIdentities, readIdentity, writeIdentity, } from "./identity-store.js";
3
+ import { removeStoredToken, writeStoredToken, } from "./identity-token.js";
4
+ import { captureClaudeToken, redactToken, } from "./identity-login.js";
3
5
  /**
4
6
  * The one-time login for each agent. Muster never performs it: each is an
5
7
  * interactive OAuth flow, and a command that appeared to do it for you would be
@@ -17,10 +19,12 @@ function loginCommand(agent, dir) {
17
19
  }
18
20
  export async function setupIdentity(opts) {
19
21
  const name = identityNameSchema.parse(opts.name);
20
- // token_env is consumed ONLY by identityEnv's Claude branch, and
21
- // identityAuthState ignores it for the others — while the launch path refuses
22
- // when the named variable is unset. So on a codex or opencode identity it has
23
- // exactly one effect: a new way to fail. Refused where the operator types it.
22
+ // token_env is consumed ONLY by `resolveCredential`, which both
23
+ // `identityAuthState` and the launch path reach exclusively from their Claude
24
+ // branch — and the launch path refuses when the named variable is unset. So on
25
+ // a codex or opencode identity, where the credential is an auth file the
26
+ // agent's own login writes into the template, it has exactly one effect: a new
27
+ // way to fail. Refused where the operator types it.
24
28
  if (opts.tokenEnv && opts.agent !== "claude")
25
29
  throw new IdentityError(name, `--token-env applies only to a claude identity; ${opts.agent} authenticates from an auth file in the template, and a token variable here would only add a way for the launch to fail`);
26
30
  // Distinguish "no such identity" from "an identity that is there but unusable".
@@ -52,6 +56,15 @@ export async function setupIdentity(opts) {
52
56
  ? { token_env: existing.token_env }
53
57
  : {}),
54
58
  });
59
+ // One route per identity. An operator naming a variable has chosen the env
60
+ // route, so a stored token is now dead weight. Order matters: remove the
61
+ // stale token BEFORE recording the env route. If removal fails, the identity
62
+ // holds neither route and reports not-configured — the operator re-runs and
63
+ // succeeds. Writing first fails worse: both routes coexist on the failure
64
+ // path, with a secret at rest that nothing reads.
65
+ let clearedRoute;
66
+ if (opts.tokenEnv !== undefined && (await removeStoredToken(opts.home, name)))
67
+ clearedRoute = `removed this identity's stored token; ${opts.tokenEnv} is now its credential`;
55
68
  if (created || tokenEnvChanged)
56
69
  await writeIdentity(opts.home, name, meta);
57
70
  const current = await readIdentity(opts.home, name);
@@ -61,8 +74,37 @@ export async function setupIdentity(opts) {
61
74
  path: dir,
62
75
  login: loginCommand(current.agent, dir),
63
76
  auth: await identityAuthState(opts.home, name, current, opts.env ?? process.env),
77
+ ...(clearedRoute ? { clearedRoute } : {}),
64
78
  };
65
79
  }
80
+ /**
81
+ * Store a token as this identity's credential, and clear the other route.
82
+ *
83
+ * Clearing is not tidiness. An identity left with both a stored token and a
84
+ * declared `token_env` has an account that depends on whether a variable
85
+ * happens to be exported — exactly the defect storing the token exists to
86
+ * remove.
87
+ */
88
+ export async function storeIdentityToken(opts) {
89
+ const name = identityNameSchema.parse(opts.name);
90
+ const meta = await readIdentity(opts.home, name); // throws if unusable
91
+ if (meta.agent !== "claude")
92
+ throw new IdentityError(name, `a token applies only to a claude identity; ${meta.agent} authenticates from an auth file its own login writes into the template`);
93
+ // Order matters, and not for tidiness. If the token were written first and
94
+ // clearing token_env then failed, the identity would hold BOTH routes — and
95
+ // `resolveCredential` prefers the env route, so the token just stored would be
96
+ // silently ignored while the command reported success. Clearing first fails
97
+ // the other way: an identity with neither route, which reports
98
+ // `not-configured` and tells the operator exactly what to do.
99
+ let clearedRoute;
100
+ if (meta.token_env) {
101
+ const { token_env: cleared, ...rest } = meta;
102
+ await writeIdentity(opts.home, name, identityMetaSchema.parse(rest));
103
+ clearedRoute = `cleared token_env ${cleared}; this identity now uses its stored token`;
104
+ }
105
+ await writeStoredToken(opts.home, name, opts.token);
106
+ return clearedRoute ? { clearedRoute } : {};
107
+ }
66
108
  export async function describeIdentities(home, env,
67
109
  /** Profile grants, so stale ones surface without a launch having to fail. */
68
110
  grants = {}) {
@@ -86,3 +128,69 @@ grants = {}) {
86
128
  staleGrants.push({ profile, identity });
87
129
  return { identities, staleGrants };
88
130
  }
131
+ /**
132
+ * The guided path: create the identity, run its login, store what comes back.
133
+ *
134
+ * `isTTY`, `write`, `env` and `stdin` are parameters rather than direct reads of
135
+ * `process` so the whole shipped path — refusal, capture, storage and note — is
136
+ * testable without a terminal, with a fake `claude` on `env.PATH`.
137
+ */
138
+ export async function interactiveSetup(opts) {
139
+ const write = opts.write ?? ((t) => process.stdout.write(t));
140
+ if (!opts.isTTY)
141
+ throw new IdentityError(opts.name, "interactive setup needs a terminal; run it yourself, or use --token-env for an unattended machine");
142
+ const created = await setupIdentity({
143
+ home: opts.home,
144
+ name: opts.name,
145
+ agent: opts.agent,
146
+ });
147
+ if (opts.agent !== "claude")
148
+ return {
149
+ path: created.path,
150
+ stored: false,
151
+ note: `Run this once, then check with \`muster identities\`:\n\n ${created.login}\n`,
152
+ };
153
+ write(`Running: claude setup-token\n${"─".repeat(50)}\n`);
154
+ const result = await captureClaudeToken({
155
+ dir: created.path,
156
+ env: opts.env ?? process.env,
157
+ onData: write,
158
+ ...(opts.stdin ? { stdin: opts.stdin } : {}),
159
+ });
160
+ write(`${"─".repeat(50)}\n`);
161
+ // Checked BEFORE the token branch, deliberately: a login that failed writes
162
+ // no credential even if something token-shaped went past on the way down.
163
+ //
164
+ // This is also the case where the child never started at all. node-pty does
165
+ // not throw for a missing executable — it exits 1 having emitted zero bytes —
166
+ // so an operator with no `claude` on PATH used to be told Muster could not
167
+ // parse output that never existed, with no identity name and no hint that
168
+ // nothing had run. The child's own output is the error here, whatever it was.
169
+ if (result.exitCode !== 0)
170
+ throw new IdentityError(opts.name, `\`claude setup-token\` failed (exit ${result.exitCode}) and no credential was stored; its own output above is the error. If nothing was printed above it could not be started at all — check that \`claude\` is on PATH.`);
171
+ if (result.token) {
172
+ const { clearedRoute } = await storeIdentityToken({
173
+ home: opts.home,
174
+ name: opts.name,
175
+ token: result.token,
176
+ });
177
+ return {
178
+ path: created.path,
179
+ stored: true,
180
+ note: `Token captured and stored (${redactToken(result.token)}). It was not printed here.` +
181
+ (clearedRoute ? `\n${clearedRoute}` : ""),
182
+ };
183
+ }
184
+ // Exit 0 and no match: the login worked and its output format changed. Fails
185
+ // open, so the token is above in clear and the operator has it.
186
+ //
187
+ // The one thing this must NOT say is "run --interactive again": a second run
188
+ // performs a second `claude setup-token` and mints a DIFFERENT token, so it
189
+ // can never store the one already in the scrollback. `--token-env` is the
190
+ // only route that can record it.
191
+ return {
192
+ path: created.path,
193
+ stored: false,
194
+ note: `Muster could not recognise a token in that output, so nothing was stored — but the login itself succeeded, and the token is above in clear. Record it by naming the variable that holds it:\n\n export CLAUDE_TOKEN=<the token above>\n muster setup-identity --identity ${opts.name} --agent claude --token-env CLAUDE_TOKEN\n`,
195
+ };
196
+ }
@@ -1,5 +1,6 @@
1
1
  import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
+ import TOML from "@iarna/toml";
3
4
  import { identityPath } from "./identity-store.js";
4
5
  /**
5
6
  * What a copy must contain: enough to start authenticated, onboarded, and
@@ -63,7 +64,14 @@ export async function copyIdentity(home, name, meta, launchId) {
63
64
  * The variables the AGENT receives. Muster's own subprocesses never get these:
64
65
  * a version probe pointed at a per-launch copy answers the wrong question.
65
66
  */
66
- export function identityEnv(meta, copyPath, sourceEnv) {
67
+ export function identityEnv(meta, copyPath, sourceEnv,
68
+ /**
69
+ * The already-resolved token. Resolved by the caller rather than here, because
70
+ * the file route needs async IO and because resolving it against the TEMPLATE
71
+ * keeps the number of on-disk copies of the secret at one — the per-launch
72
+ * copy never carries it.
73
+ */
74
+ credential) {
67
75
  if (meta.agent === "codex")
68
76
  return { CODEX_HOME: copyPath };
69
77
  if (meta.agent === "opencode")
@@ -77,12 +85,9 @@ export function identityEnv(meta, copyPath, sourceEnv) {
77
85
  XDG_DATA_HOME: join(copyPath, "data"),
78
86
  };
79
87
  const env = { CLAUDE_CONFIG_DIR: copyPath };
80
- if (meta.token_env) {
81
- const token = sourceEnv[meta.token_env];
82
- // Absence is the caller's problem to report before spawning; see run.ts.
83
- if (typeof token === "string" && token.length)
84
- env.CLAUDE_CODE_OAUTH_TOKEN = token;
85
- }
88
+ // Absence is the caller's problem to report before spawning; see run.ts.
89
+ if (credential)
90
+ env.CLAUDE_CODE_OAUTH_TOKEN = credential;
86
91
  return env;
87
92
  }
88
93
  /**
@@ -122,3 +127,62 @@ export async function setWorkspaceTrust(copyPath, cwd) {
122
127
  data.projects = projects;
123
128
  await writeFile(file, JSON.stringify(data), { mode: 0o600 });
124
129
  }
130
+ /**
131
+ * Set Codex's directory-trust flag for one directory, inside a copy Muster just
132
+ * created. Codex has its own gate, unrelated to Claude's: without a
133
+ * `[projects."<dir>"] trust_level = "trusted"` entry in the config.toml it
134
+ * reads, the TUI stops on "Do you trust the contents of this directory?" before
135
+ * it ever opens a thread. No thread is opened, so no thread-writer lock is
136
+ * written, so `resolveCodex` finds nothing and the launch burns its whole
137
+ * deadline reporting `no descendant runtime identity found`.
138
+ *
139
+ * A fresh identity copy has no `projects` table at all, which made EVERY Codex
140
+ * identity launch fail exactly that way — measured, not inferred: the pane was
141
+ * captured mid-launch sitting on the prompt while the copy filled with Codex's
142
+ * own sqlite state and never grew a `thread-writer-locks/` directory.
143
+ *
144
+ * `-c projects."<dir>".trust_level="trusted"` does NOT satisfy the gate. It
145
+ * parses and is accepted, and the prompt still appears: the check reads
146
+ * persisted config, so the entry has to be in the file.
147
+ *
148
+ * The user's real `~/.codex/config.toml` is never touched, for the same reason
149
+ * `setWorkspaceTrust` leaves the live `~/.claude.json` alone.
150
+ */
151
+ export async function setCodexWorkspaceTrust(copyPath, cwd) {
152
+ const file = join(copyPath, "config.toml");
153
+ let raw;
154
+ try {
155
+ raw = await readFile(file, "utf8");
156
+ }
157
+ catch (e) {
158
+ // A template that has never been logged into has no config.toml; Codex
159
+ // runs on defaults. Anything else — EACCES, EISDIR — is a real failure,
160
+ // and swallowing it would hand the agent a config missing every setting
161
+ // the template's own login wrote.
162
+ if (e.code !== "ENOENT")
163
+ throw e;
164
+ }
165
+ let data = {};
166
+ if (raw !== undefined)
167
+ try {
168
+ data = TOML.parse(raw);
169
+ }
170
+ catch (e) {
171
+ // A file that EXISTS and does not parse is not "no file yet". Codex would
172
+ // refuse it too, so writing over it trades one clear failure for a silent
173
+ // loss of the template's settings.
174
+ throw new Error(`${file} exists but is not valid TOML: ${String(e)}`);
175
+ }
176
+ const projects = (data.projects ?? {});
177
+ const existing = (projects[cwd] ?? {});
178
+ if (existing.trust_level === "trusted")
179
+ return;
180
+ projects[cwd] = { ...existing, trust_level: "trusted" };
181
+ data.projects = projects;
182
+ // Re-serialised rather than appended, so an entry the template already
183
+ // carries with a different trust_level is corrected instead of duplicated —
184
+ // a duplicate table is a TOML error and Codex would refuse the whole file.
185
+ // Comments and key order in the copy are lost; the copy is per-launch and
186
+ // machine-read, and the template it came from is untouched.
187
+ await writeFile(file, TOML.stringify(data), { mode: 0o600 });
188
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * A Claude long-lived OAuth token, as `claude setup-token` prints it. The length
3
+ * floor is what separates a real token from the literal word in help text.
4
+ */
5
+ export const CLAUDE_TOKEN_PATTERN = /sk-ant-oat01-[A-Za-z0-9_-]{40,}/;
6
+ /** The token's fixed literal prefix, used to detect an in-progress match. */
7
+ const TOKEN_PREFIX = "sk-ant-oat01-";
8
+ /** The character class the token body is drawn from, after the prefix. */
9
+ const TOKEN_BODY_CHAR = /[A-Za-z0-9_-]/;
10
+ /** Enough to recognise which token this is; never enough to use it. */
11
+ export function redactToken(token) {
12
+ return `${token.slice(0, 12)}…${token.slice(-5)}`;
13
+ }
14
+ /**
15
+ * How many leading characters of `text` are safe to release right now — the
16
+ * rest, a trailing suffix, could still turn into (part of) a token given more
17
+ * input and must be held back.
18
+ *
19
+ * Two shapes count as "could still turn into a token":
20
+ * - a trailing partial prefix of the literal `sk-ant-oat01-` (e.g. text
21
+ * ending in `sk-ant-o` might still complete into the literal on the next
22
+ * push), and
23
+ * - an already-open run: the literal prefix followed by an unbroken run of
24
+ * token-body characters that reaches all the way to the end of `text` —
25
+ * the body has no upper bound, so more valid characters could still
26
+ * arrive and extend it.
27
+ *
28
+ * Everything else — including a run of token-body characters that is *not*
29
+ * preceded by the literal prefix, and ordinary prose — is not a live prefix
30
+ * of the pattern and is safe to release immediately. That is what keeps the
31
+ * held-back tail bounded for realistic non-token output (prompts with no
32
+ * trailing newline, a `\r`-redrawn spinner) instead of waiting on a newline
33
+ * that may never come.
34
+ */
35
+ function livePrefixStart(text) {
36
+ // An open run: the FIRST literal occurrence with nothing but body characters
37
+ // between it and the end of `text`. First, not rightmost, and that direction
38
+ // is load-bearing. The literal's own `-` is a body character, so a token can
39
+ // contain a second occurrence of the literal inside its body. Cutting at the
40
+ // rightmost one releases everything before it — the token's own head — in
41
+ // clear, and then captures only the truncated tail as the token, storing an
42
+ // unusable credential while reporting success. Holding from the first open
43
+ // occurrence is strictly more conservative and still exactly minimal: every
44
+ // later occurrence lies inside the run being held.
45
+ for (let i = 0; i + TOKEN_PREFIX.length <= text.length; i++) {
46
+ if (!text.startsWith(TOKEN_PREFIX, i))
47
+ continue;
48
+ let open = true;
49
+ for (let j = i + TOKEN_PREFIX.length; j < text.length; j++) {
50
+ if (!TOKEN_BODY_CHAR.test(text.charAt(j))) {
51
+ open = false;
52
+ break;
53
+ }
54
+ }
55
+ if (open)
56
+ return i;
57
+ }
58
+ // A partial prefix of the literal itself, still trailing at the very end.
59
+ for (let n = Math.min(TOKEN_PREFIX.length - 1, text.length); n >= 1; n--) {
60
+ if (text.endsWith(TOKEN_PREFIX.slice(0, n)))
61
+ return text.length - n;
62
+ }
63
+ return text.length;
64
+ }
65
+ /**
66
+ * Relays a stream while holding back any token it contains.
67
+ *
68
+ * Buffers on the boundary of what could still become a token match — never a
69
+ * fixed byte count, and never "wait for the next newline". A byte-count tail
70
+ * can split a token across two relayed pieces, neither of which matches the
71
+ * pattern, and the operator's scrollback then holds both halves in clear:
72
+ * that is the exact leak this object exists to prevent. Waiting for a
73
+ * newline is wrong in the other direction: it holds back ordinary, non-token
74
+ * output — a prompt printed with no trailing newline, a `\r`-redrawn spinner
75
+ * — until the process exits, and grows the held-back tail without bound for
76
+ * as long as no newline arrives. `livePrefixStart` computes the exact
77
+ * minimal suffix that could still extend into a match, so anything else
78
+ * streams through immediately, and a real, complete token is still always
79
+ * relayed whole or not at all.
80
+ *
81
+ * A run of token-body characters that never terminates — the child never
82
+ * stops emitting, or line-wraps the token before the pattern's length floor
83
+ * is reached — stays held until `flush()`. That is the one place an
84
+ * unbounded hold is both correct and expected: the stream has ended, so
85
+ * whatever's left is exactly as complete as it will ever be.
86
+ *
87
+ * Matches are found with a fresh, local `/g` regex built from
88
+ * `CLAUDE_TOKEN_PATTERN.source` on every call, never by setting `g` on
89
+ * `CLAUDE_TOKEN_PATTERN` itself — a shared exported regex with `g` set
90
+ * carries `lastIndex` across unrelated calls and would silently skip matches
91
+ * for every other caller of the pattern.
92
+ */
93
+ export function createTokenFilter(onData) {
94
+ let carry = "";
95
+ let token;
96
+ let redacted = false;
97
+ const relay = (text) => {
98
+ if (!text)
99
+ return;
100
+ const matches = text.match(new RegExp(CLAUDE_TOKEN_PATTERN.source, "g"));
101
+ if (!matches)
102
+ return onData(text);
103
+ token ??= matches[0];
104
+ redacted = true;
105
+ let out = text;
106
+ for (const m of matches)
107
+ out = out.split(m).join(redactToken(m));
108
+ onData(out);
109
+ };
110
+ return {
111
+ push(chunk) {
112
+ const text = carry + chunk;
113
+ const cut = livePrefixStart(text);
114
+ carry = text.slice(cut);
115
+ if (cut > 0)
116
+ relay(text.slice(0, cut));
117
+ },
118
+ flush() {
119
+ const rest = carry;
120
+ carry = "";
121
+ relay(rest);
122
+ },
123
+ get token() {
124
+ return token;
125
+ },
126
+ get redacted() {
127
+ return redacted;
128
+ },
129
+ };
130
+ }
131
+ /**
132
+ * Attach the operator's keyboard to the child for the life of the capture, and
133
+ * return the function that puts the terminal back.
134
+ *
135
+ * Without this the login is half-attached: the operator sees `Paste code here
136
+ * if prompted:` — the documented fallback whenever the browser callback cannot
137
+ * reach the local listener, which is every ssh session — and nothing they type
138
+ * reaches the child. There is deliberately no timeout, and Ctrl-C goes to
139
+ * Muster rather than the pty child (node-pty puts it in its own session), so
140
+ * the flow hangs until the operator kills Muster.
141
+ *
142
+ * Raw mode is what makes that keyboard useful (no local echo of a pasted code,
143
+ * single keypresses delivered immediately) and it is also the one thing in this
144
+ * whole change that can damage the operator's shell: a leaked raw mode leaves
145
+ * their terminal with no echo and no Ctrl-C long after Muster has exited. So
146
+ * the returned restore is the requirement, the caller runs it in a `finally`,
147
+ * it is idempotent, and it puts raw mode back FIRST — before anything else that
148
+ * could throw and skip it.
149
+ *
150
+ * Two things guard the window the caller's `finally` cannot reach — the one
151
+ * between entering raw mode and the restore being in the caller's hands:
152
+ *
153
+ * - **Raw mode is entered LAST**, after the listener is attached and the
154
+ * stream resumed. That is the whole of the fix, and it is why the attach
155
+ * order below must not be "tidied". Guarding the call site instead cannot
156
+ * work: a throw partway through this function never returns a restore for
157
+ * any `finally` to run, so the ordering has to make the window not exist.
158
+ * The `catch` is only for the stragglers — a throw before raw mode still
159
+ * has a resumed stream and a live listener to undo.
160
+ * - **A signal net.** A `kill -TERM` from another window, or an uncaught
161
+ * exception raised outside the capture's promise chain, ends the process
162
+ * without unwinding any `finally`. So SIGINT/SIGTERM/`exit` handlers run
163
+ * the same restore. They are removed by that restore, so repeated captures
164
+ * cannot accumulate handlers and a completed one leaves none behind — and
165
+ * because the handler is gone by the time it re-raises, the signal keeps
166
+ * its normal effect (node's default disposition: terminate, 128 + signum).
167
+ *
168
+ * Only a TTY is touched. A piped stdin has no raw mode to set, and nothing is
169
+ * forwarded from it: `--interactive` already refuses a non-terminal stdin.
170
+ */
171
+ function attachInput(pty, input, signals) {
172
+ if (!input.isTTY)
173
+ return () => { };
174
+ const wasRaw = input.isRaw === true;
175
+ const wasPaused = input.isPaused();
176
+ const forward = (chunk) => {
177
+ try {
178
+ pty.write(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
179
+ }
180
+ catch {
181
+ // The child is gone and the exit path is already settling; a keystroke
182
+ // that lands in that window is not worth failing the login over.
183
+ }
184
+ };
185
+ const hooked = [];
186
+ let restored = false;
187
+ const restore = () => {
188
+ if (restored)
189
+ return;
190
+ restored = true;
191
+ try {
192
+ input.setRawMode(wasRaw);
193
+ }
194
+ catch {
195
+ // Nothing better to do: the terminal is whatever it is.
196
+ }
197
+ try {
198
+ input.off("data", forward);
199
+ }
200
+ catch {
201
+ // Ditto.
202
+ }
203
+ if (wasPaused)
204
+ try {
205
+ input.pause();
206
+ }
207
+ catch {
208
+ // Ditto.
209
+ }
210
+ for (const [event, handler] of hooked.splice(0))
211
+ try {
212
+ signals.off(event, handler);
213
+ }
214
+ catch {
215
+ // Ditto.
216
+ }
217
+ };
218
+ try {
219
+ for (const signal of ["SIGINT", "SIGTERM"]) {
220
+ const handler = () => {
221
+ restore();
222
+ // The restore above removed this handler, so this re-raise reaches
223
+ // node's default disposition rather than looping back in here.
224
+ signals.kill(signals.pid, signal);
225
+ };
226
+ hooked.push([signal, handler]);
227
+ signals.once(signal, handler);
228
+ }
229
+ // Covers what no signal does: an uncaught exception, or a `process.exit`
230
+ // from somewhere else entirely.
231
+ const onExit = () => restore();
232
+ hooked.push(["exit", onExit]);
233
+ signals.once("exit", onExit);
234
+ input.on("data", forward);
235
+ input.resume();
236
+ // LAST. See the ordering note above; do not move this up.
237
+ input.setRawMode(true);
238
+ }
239
+ catch (err) {
240
+ restore();
241
+ throw err;
242
+ }
243
+ return restore;
244
+ }
245
+ /**
246
+ * Run `claude setup-token` against an identity's template and capture the token
247
+ * it prints, relaying everything else so the operator still sees and approves
248
+ * the browser step.
249
+ *
250
+ * Fails OPEN. If the output format changes and the pattern stops matching,
251
+ * nothing is redacted and nothing is captured: the operator sees exactly what
252
+ * they see today, and — the login itself having succeeded — the caller reports
253
+ * that it could not recognise a token and points at `--token-env` to record the
254
+ * one now in their scrollback. (Re-running `--interactive` cannot: it mints a
255
+ * different token.) The alternatives — silently leaking because redaction
256
+ * broke, or silently refusing because capture broke — are both worse than the
257
+ * status quo.
258
+ */
259
+ export async function captureClaudeToken(opts) {
260
+ const module = await import("node-pty");
261
+ const pty = module.spawn("claude", ["setup-token"], {
262
+ cwd: opts.dir,
263
+ env: { ...opts.env, CLAUDE_CONFIG_DIR: opts.dir },
264
+ name: "xterm-256color",
265
+ // Load-bearing, not cosmetic: `claude` renders the token inside a
266
+ // bordered box sized to the terminal width, and a narrower column count
267
+ // wraps the token itself across a hard line break. A token split by a
268
+ // real newline matches nothing on either side of the break, which
269
+ // defeats this filter entirely. Do not "tidy" this back toward a more
270
+ // realistic terminal width.
271
+ cols: 1000,
272
+ rows: 30,
273
+ });
274
+ // Inside the `try`, so the `finally` below is in charge of the restore from
275
+ // the moment there is one — on every exit path: normal exit, an error from the
276
+ // pty, a rejected await. A throw from inside `attachInput` itself never gets
277
+ // here at all, which is why that function restores whatever it managed to set
278
+ // up before rethrowing, and enters raw mode last of all.
279
+ let detachInput = () => { };
280
+ try {
281
+ detachInput = attachInput(pty, opts.stdin ?? process.stdin, opts.signals ?? process);
282
+ const filter = createTokenFilter(opts.onData);
283
+ const dataSub = pty.onData((chunk) => filter.push(chunk));
284
+ const exitCode = await new Promise((resolve) => {
285
+ const exitSub = pty.onExit(({ exitCode }) => {
286
+ exitSub.dispose();
287
+ // node-pty can still have an onData chunk queued when onExit fires;
288
+ // resolving synchronously here risks flushing before it arrives and
289
+ // silently dropping a token that lands right at process exit.
290
+ // Deferring to a macrotask lets any already-queued onData run first.
291
+ setImmediate(() => resolve(exitCode));
292
+ });
293
+ });
294
+ dataSub.dispose();
295
+ filter.flush();
296
+ try {
297
+ // Defensive: the process has already exited by the time we get here, so
298
+ // this is normally a no-op. It costs nothing to make sure nothing is
299
+ // left running once this promise settles.
300
+ pty.kill();
301
+ }
302
+ catch {
303
+ // Already gone.
304
+ }
305
+ return {
306
+ ...(filter.token ? { token: filter.token } : {}),
307
+ exitCode,
308
+ redacted: filter.redacted,
309
+ };
310
+ }
311
+ finally {
312
+ detachInput();
313
+ }
314
+ }
@@ -0,0 +1,82 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { IdentityError, identityPath } from "./identity-store.js";
5
+ /**
6
+ * Where an identity's own credential lives, for the agents whose login does not
7
+ * write one into the template itself. Deliberately the same directory Codex and
8
+ * OpenCode identities already hold `auth.json` in, so one statement is true of
9
+ * every agent: deleting an identity's directory revokes its credential.
10
+ */
11
+ export const tokenPath = (home, name) => join(identityPath(home, name), "token");
12
+ /**
13
+ * The stored token, or undefined when there is none.
14
+ *
15
+ * Absent and empty are different answers. A missing file is an identity that
16
+ * has not been logged in yet; a file holding nothing is a broken write, and
17
+ * reporting it as "not configured" would send the operator to create a
18
+ * credential they already tried to create.
19
+ */
20
+ export async function readStoredToken(home, name) {
21
+ const file = tokenPath(home, name);
22
+ let raw;
23
+ try {
24
+ raw = await readFile(file, "utf8");
25
+ }
26
+ catch (e) {
27
+ const code = e.code;
28
+ if (code === "ENOENT")
29
+ return undefined;
30
+ // EACCES, EISDIR and friends are real failures. Swallowing them would
31
+ // report "no credential" for an identity whose credential is right there.
32
+ throw new IdentityError(name, `token could not be read (${code ?? String(e)})`);
33
+ }
34
+ const token = raw.trim();
35
+ if (!token)
36
+ throw new IdentityError(name, "token file is empty — store it again with `setup-identity --interactive`");
37
+ return token;
38
+ }
39
+ export async function writeStoredToken(home, name, token) {
40
+ const value = token.trim();
41
+ if (!value)
42
+ throw new IdentityError(name, "refusing to store an empty token");
43
+ const dir = identityPath(home, name);
44
+ await mkdir(dir, { recursive: true, mode: 0o700 });
45
+ // Written whole or not at all. A truncated credential reads as present and
46
+ // fails at the agent, which is the worst of both outcomes.
47
+ const temp = join(dir, `.token.${randomUUID()}`);
48
+ try {
49
+ await writeFile(temp, value + "\n", { mode: 0o600 });
50
+ await rename(temp, tokenPath(home, name));
51
+ }
52
+ catch (e) {
53
+ await rm(temp, { force: true });
54
+ const code = e.code;
55
+ throw new IdentityError(name, `token could not be written (${code ?? String(e)})`);
56
+ }
57
+ }
58
+ /** True when a token file was there to remove. */
59
+ export async function removeStoredToken(home, name) {
60
+ try {
61
+ await rm(tokenPath(home, name));
62
+ return true;
63
+ }
64
+ catch (e) {
65
+ const code = e.code;
66
+ if (code === "ENOENT")
67
+ return false;
68
+ throw new IdentityError(name, `token could not be removed (${code ?? String(e)})`);
69
+ }
70
+ }
71
+ export async function resolveCredential(home, name, meta, env) {
72
+ if (meta.token_env)
73
+ return {
74
+ route: "env",
75
+ variable: meta.token_env,
76
+ token: env[meta.token_env] || undefined,
77
+ };
78
+ // No environment is consulted here, and that is the point. A file-route
79
+ // identity answers identically in every shell, which is what makes
80
+ // `muster identities` mean the same thing wherever it is run.
81
+ return { route: "file", token: await readStoredToken(home, name) };
82
+ }
package/dist/muster.js CHANGED
@@ -10,7 +10,8 @@ import { delay } from "./identity/processes.js";
10
10
  import { formatHuman, humanColorEnabled } from "./format.js";
11
11
  import { checkPlugins, npmRegistry } from "./doctor.js";
12
12
  import { loadConfig, musterHome } from "./config.js";
13
- import { describeIdentities, setupIdentity } from "./identity-cli.js";
13
+ import { describeIdentities, interactiveSetup, setupIdentity, } from "./identity-cli.js";
14
+ import { IdentityError } from "./identity-store.js";
14
15
  import { MCP_TOOLS } from "./mcp-tools.js";
15
16
  const listSchema = z
16
17
  .object({ kind: z.enum(["session", "task"]).optional() })
@@ -23,7 +24,7 @@ const help = `Usage:
23
24
  muster stop <id> [--format json|human]
24
25
  muster output <id>
25
26
  muster doctor [--format json|human]
26
- muster setup-identity --identity NAME --agent <codex|claude|opencode> [--token-env VAR]
27
+ muster setup-identity --identity NAME --agent <codex|claude|opencode> [--token-env VAR] [--interactive]
27
28
  muster identities [--format json|human]
28
29
  muster mcp
29
30
  muster --version
@@ -50,6 +51,11 @@ configured, a launch outside all of them is refused.
50
51
  --request-key KEY names the launch request itself. Sending the same key again
51
52
  returns the launch it already produced rather than starting a second one, and
52
53
  sending it with different parameters is refused rather than answered.
54
+ setup-identity takes either --interactive (Muster runs the Claude login and
55
+ stores the token in the identity) or --token-env VAR (the identity reads its
56
+ token from that variable instead, keeping nothing at rest). They are the two
57
+ mutually exclusive credential routes; passing both is an error rather than a
58
+ precedence rule.
53
59
  run, list and stop default to JSON. output prints raw task text.
54
60
 
55
61
  doctor checks path-configured plugins against the packages they came from: a
@@ -182,6 +188,7 @@ async function main() {
182
188
  identity: { type: "string" },
183
189
  agent: { type: "string" },
184
190
  "token-env": { type: "string" },
191
+ interactive: { type: "boolean" },
185
192
  },
186
193
  });
187
194
  const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, ...values } = parsed;
@@ -235,6 +242,24 @@ async function main() {
235
242
  const { identity, agent, "token-env": tokenEnv, } = values;
236
243
  if (!identity || !agent)
237
244
  throw new Error("setup-identity requires --identity and --agent");
245
+ if (values.interactive) {
246
+ // Refused, not ranked. These are the two mutually exclusive credential
247
+ // routes, and the interactive branch is taken before tokenEnv is read:
248
+ // silently preferring it would run a browser login and write a
249
+ // year-long token to disk on the machine the operator explicitly asked
250
+ // to keep no credential at rest, while never recording the variable
251
+ // they named.
252
+ if (tokenEnv)
253
+ throw new IdentityError(identity, "--interactive and --token-env are the two mutually exclusive credential routes: --interactive runs the login and stores a token in the identity, --token-env records the name of a variable to read one from instead. Pass one, not both.");
254
+ const result = await interactiveSetup({
255
+ home: musterHome(),
256
+ name: identity,
257
+ agent: agent,
258
+ isTTY: Boolean(process.stdin.isTTY),
259
+ });
260
+ process.stdout.write(`\n${result.note}\n`);
261
+ return;
262
+ }
238
263
  // Not routed through print(): its RecordView/DoctorView shapes are for
239
264
  // run/list/stop/doctor, and an identity record shares no field with
240
265
  // either, so this always prints JSON.
package/dist/run.js CHANGED
@@ -9,7 +9,8 @@ import { enforcementGrade } from "./enforcement.js";
9
9
  import { LOCAL } from "./requester.js";
10
10
  import { assertEnforcementMeetsMinimum, assertLevelWithinCeiling, assertMayAct, resolveIdentityForRequest, resolveRequesterPolicy, RequesterPolicyRefusal, } from "./requester-policy.js";
11
11
  import { IdentityError, readIdentity, } from "./identity-store.js";
12
- import { copyIdentity, identityEnv, setWorkspaceTrust, } from "./identity-copy.js";
12
+ import { resolveCredential } from "./identity-token.js";
13
+ import { copyIdentity, identityEnv, setCodexWorkspaceTrust, setWorkspaceTrust, } from "./identity-copy.js";
13
14
  import { Registry } from "./registry.js";
14
15
  import { LaunchLog } from "./log.js";
15
16
  import { hosts, selectHost } from "./hosts/index.js";
@@ -90,7 +91,7 @@ export class Muster {
90
91
  }
91
92
  static async create(opts = {}) {
92
93
  const env = launchEnv(opts.env ?? process.env), home = opts.home ?? join(env.HOME ?? homedir(), ".muster");
93
- return new Muster(await loadConfig(home), home, env, opts.drivers ?? hosts(), { ...(opts.env ?? process.env) });
94
+ return new Muster(await loadConfig(home), home, env, opts.drivers ?? hosts(opts.env ?? process.env), { ...(opts.env ?? process.env) });
94
95
  }
95
96
  run(input, requester = LOCAL) {
96
97
  if (this.closing)
@@ -169,6 +170,7 @@ export class Muster {
169
170
  // read-only half, and the copy happens below once that id exists.
170
171
  const identityName = resolveIdentityForRequest(req.identity, policy);
171
172
  let identityMeta;
173
+ let credential;
172
174
  if (identityName) {
173
175
  identityMeta = await readIdentity(this.home, identityName);
174
176
  if (identityMeta.agent !== req.runtime)
@@ -176,20 +178,20 @@ export class Muster {
176
178
  // Claude is the one runtime whose credential cannot survive the copy. It
177
179
  // is keyed by the configuration directory's PATH, and a launch points
178
180
  // Claude at the per-launch copy, which hashes to a service name that does
179
- // not exist — the unsuffixed item is not a fallback. So `token_env` is the
180
- // only route a Claude identity can launch under. Without this, such an
181
- // identity would pass validation, copy, spawn a real agent and die at the
182
- // deadline with "no Claude session registry found".
183
- if (identityMeta.agent === "claude" && !identityMeta.token_env)
184
- throw new IdentityError(identityName, "a Claude identity needs a token: run `claude setup-token`, export the " +
185
- "token, and name the variable with --token-env. The keychain credential " +
186
- "is keyed to the template's own path and cannot be used from the " +
187
- "per-launch copy a launch actually runs from");
188
- // Reported now rather than by an agent that starts unauthenticated: the
189
- // variable is read from the unfiltered source environment because that is
190
- // where the operator set it, and `hostEnv` filters CLAUDE_CODE_* away.
191
- if (identityMeta.token_env && !this.sourceEnv[identityMeta.token_env])
192
- throw new IdentityError(identityName, `needs ${identityMeta.token_env} set in the environment`);
181
+ // not exist — the unsuffixed item is not a fallback. So a token is the
182
+ // only route a Claude identity can launch under.
183
+ //
184
+ // Resolved from the TEMPLATE, which exists now; the copy does not yet.
185
+ if (identityMeta.agent === "claude") {
186
+ credential = await resolveCredential(this.home, identityName, identityMeta,
187
+ // The unfiltered source environment, because that is where the
188
+ // operator set the variable and `hostEnv` filters CLAUDE_CODE_* away.
189
+ this.sourceEnv);
190
+ if (!credential.token)
191
+ throw new IdentityError(identityName, credential.route === "env"
192
+ ? `needs ${credential.variable} set in the environment`
193
+ : "has no credential — run `setup-identity --interactive` to log in and store a token");
194
+ }
193
195
  }
194
196
  /**
195
197
  * The one request every permission- and argv-deriving helper is given.
@@ -314,9 +316,15 @@ export class Muster {
314
316
  // session never registers, and the launch burns its whole deadline.
315
317
  // `assertAllowedWorkspace` already realpaths for containment.
316
318
  await setWorkspaceTrust(copy, await realpath(req.cwd));
319
+ // Codex gates the same way behind a different file and a different key,
320
+ // and a fresh copy trusts nothing — so every Codex identity launch sat
321
+ // on "Do you trust the contents of this directory?" until the deadline.
322
+ // Resolved for the same reason: Codex keys `projects` by its own cwd.
323
+ else if (identityMeta.agent === "codex")
324
+ await setCodexWorkspaceTrust(copy, await realpath(req.cwd));
317
325
  runtimeEnv = {
318
326
  ...runtimeEnv,
319
- ...identityEnv(identityMeta, copy, this.sourceEnv),
327
+ ...identityEnv(identityMeta, copy, this.sourceEnv, credential?.token),
320
328
  };
321
329
  entry = await this.registry.update(entry.launchId, {
322
330
  identityPath: copy,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {