@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 +66 -30
- package/dist/hosts/index.js +14 -2
- package/dist/identity-auth.js +28 -11
- package/dist/identity-cli.js +112 -4
- package/dist/identity-copy.js +71 -7
- package/dist/identity-login.js +314 -0
- package/dist/identity-token.js +82 -0
- package/dist/muster.js +27 -2
- package/dist/run.js +25 -17
- package/package.json +1 -1
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.
|
|
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 --
|
|
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
|
-
|
|
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
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
|
595
|
-
and a command that appeared to do it for you
|
|
596
|
-
|
|
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
|
|
637
|
-
it still works. Tokens expire and are revoked; there is
|
|
638
|
-
that without exercising the credential, and `identities`
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
directory
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
package/dist/hosts/index.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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)
|
package/dist/identity-auth.js
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
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 —
|
|
54
|
-
: "no credential for a launch —
|
|
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"
|
|
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`);
|
package/dist/identity-cli.js
CHANGED
|
@@ -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
|
|
21
|
-
// identityAuthState
|
|
22
|
-
// when the named variable is unset. So on
|
|
23
|
-
//
|
|
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
|
+
}
|
package/dist/identity-copy.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
|
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
|
|
180
|
-
// only route a Claude identity can launch under.
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
if (identityMeta.agent === "claude"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|