@brutalsystems/muster 0.7.11 → 0.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +280 -9
- package/dist/claude-policy.js +0 -2
- package/dist/config.js +64 -1
- package/dist/enforcement.js +5 -0
- package/dist/guard.js +103 -0
- package/dist/identity/claude.js +14 -1
- package/dist/identity/processes.js +60 -12
- package/dist/identity-auth.js +66 -0
- package/dist/identity-cli.js +88 -0
- package/dist/identity-copy.js +124 -0
- package/dist/identity-store.js +101 -0
- package/dist/log.js +82 -4
- package/dist/mcp-tools.js +134 -0
- package/dist/muster.js +72 -96
- package/dist/registry.js +21 -4
- package/dist/requester-policy.js +106 -0
- package/dist/requester.js +59 -0
- package/dist/run.js +306 -54
- package/package.json +1 -1
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* on every transaction, so a process that died while Muster was not running
|
|
17
17
|
* is reported as dead rather than trusted from what was recorded.
|
|
18
18
|
*
|
|
19
|
-
* Membership is
|
|
20
|
-
* a reparented child
|
|
19
|
+
* Membership is the union of the parent chain and the process group the root
|
|
20
|
+
* leads, because a reparented child leaves the chain but keeps its group.
|
|
21
21
|
*/
|
|
22
22
|
import { execFile } from "node:child_process";
|
|
23
23
|
import { promisify } from "node:util";
|
|
@@ -41,8 +41,41 @@ export async function processRef(pid) {
|
|
|
41
41
|
export async function isSame(ref) {
|
|
42
42
|
return (await processRef(ref.pid))?.start === ref.start;
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The process group a pid leads, or undefined if it leads none.
|
|
46
|
+
*
|
|
47
|
+
* Only a leader's group is safe to treat as membership: the group then exists
|
|
48
|
+
* because this process was started, so everything in it descends from that
|
|
49
|
+
* start. A process that merely belongs to someone else's group shares it with
|
|
50
|
+
* whatever else is there — a shell, a tmux pane, the terminal — and sweeping
|
|
51
|
+
* that would reach far outside the session.
|
|
52
|
+
*
|
|
53
|
+
* Both terminal hosts start their root in a new session, so in practice the
|
|
54
|
+
* launched root does lead its own group.
|
|
55
|
+
*/
|
|
56
|
+
export async function ownedGroup(pid) {
|
|
57
|
+
try {
|
|
58
|
+
const pgid = Number((await command("ps", ["-p", String(pid), "-o", "pgid="])).trim());
|
|
59
|
+
return pgid === pid ? pgid : undefined;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Membership by two signals, because neither is sufficient alone.
|
|
67
|
+
*
|
|
68
|
+
* The parent chain is exact while it holds, and it stops holding the moment an
|
|
69
|
+
* intermediate process exits: the children reparent to launchd and are no
|
|
70
|
+
* longer reachable from the root, though they are still running the session's
|
|
71
|
+
* work. The process group survives that — reparenting changes no pid's group —
|
|
72
|
+
* but is only meaningful for a group the root leads, and is escapable by a
|
|
73
|
+
* process that calls setsid() for itself.
|
|
74
|
+
*
|
|
75
|
+
* So: union them. What the chain loses to reparenting, the group usually keeps.
|
|
76
|
+
*/
|
|
77
|
+
export async function descendants(root, deadline = Date.now() + 1000, group) {
|
|
78
|
+
const rows = (await command("ps", ["-axo", "pid=,ppid=,pgid="], Math.max(1, Math.min(1000, deadline - Date.now()))))
|
|
46
79
|
.split("\n")
|
|
47
80
|
.map((l) => l.trim().split(/\s+/).map(Number));
|
|
48
81
|
const found = new Set([root]);
|
|
@@ -54,10 +87,26 @@ export async function descendants(root, deadline = Date.now() + 1000) {
|
|
|
54
87
|
changed = true;
|
|
55
88
|
}
|
|
56
89
|
}
|
|
90
|
+
if (group)
|
|
91
|
+
for (const [pid, , pgid] of rows)
|
|
92
|
+
if (pid && pid > 1 && pgid === group)
|
|
93
|
+
found.add(pid);
|
|
57
94
|
return [...found];
|
|
58
95
|
}
|
|
59
|
-
|
|
60
|
-
|
|
96
|
+
/** Whether anything still runs in a group we started. */
|
|
97
|
+
export async function groupAlive(group, deadline = Date.now() + 1000) {
|
|
98
|
+
try {
|
|
99
|
+
const rows = (await command("ps", ["-axo", "pid=,pgid="], Math.max(1, Math.min(1000, deadline - Date.now()))))
|
|
100
|
+
.split("\n")
|
|
101
|
+
.map((l) => l.trim().split(/\s+/).map(Number));
|
|
102
|
+
return rows.some(([pid, pgid]) => pid && pid > 1 && pgid === group);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function liveTree(root, group) {
|
|
109
|
+
return (await Promise.all((await descendants(root, Date.now() + 1000, group)).map(processRef))).filter((r) => !!r);
|
|
61
110
|
}
|
|
62
111
|
function signal(ref, name) {
|
|
63
112
|
try {
|
|
@@ -74,11 +123,10 @@ function signal(ref, name) {
|
|
|
74
123
|
* every signal, because a pid that exits between discovery and the signal may
|
|
75
124
|
* already belong to something else.
|
|
76
125
|
*
|
|
77
|
-
* Reparented descendants are
|
|
78
|
-
*
|
|
79
|
-
* That needs a membership signal the chain does not provide (see issue #24).
|
|
126
|
+
* Reparented descendants are reachable only through the process group, so pass
|
|
127
|
+
* one when the caller has recorded it; the parent chain alone cannot see them.
|
|
80
128
|
*/
|
|
81
|
-
export async function stopTree(ref) {
|
|
129
|
+
export async function stopTree(ref, group) {
|
|
82
130
|
if (!(await isSame(ref)))
|
|
83
131
|
return;
|
|
84
132
|
const known = new Map();
|
|
@@ -94,12 +142,12 @@ export async function stopTree(ref) {
|
|
|
94
142
|
return added;
|
|
95
143
|
};
|
|
96
144
|
const deepestFirst = () => [...known.values()].reverse();
|
|
97
|
-
remember(await liveTree(ref.pid));
|
|
145
|
+
remember(await liveTree(ref.pid, group));
|
|
98
146
|
for (const r of deepestFirst())
|
|
99
147
|
if (await isSame(r))
|
|
100
148
|
signal(r, "SIGTERM");
|
|
101
149
|
await delay(150);
|
|
102
|
-
const late = (await isSame(ref)) ?
|
|
150
|
+
const late = remember((await isSame(ref)) || group ? await liveTree(ref.pid, group) : []);
|
|
103
151
|
if (late.length) {
|
|
104
152
|
for (const r of late.reverse())
|
|
105
153
|
if (await isSame(r))
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { identityPath } from "./identity-store.js";
|
|
7
|
+
const run = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* Claude Code stores its credential in the macOS keychain under a service name
|
|
10
|
+
* derived from the configuration directory's PATH, which is why a copied
|
|
11
|
+
* directory cannot authenticate: it hashes to a name that does not exist.
|
|
12
|
+
*/
|
|
13
|
+
export function claudeKeychainService(configDir) {
|
|
14
|
+
return ("Claude Code-credentials-" +
|
|
15
|
+
createHash("sha256").update(configDir).digest("hex").slice(0, 8));
|
|
16
|
+
}
|
|
17
|
+
async function keychainHas(service) {
|
|
18
|
+
try {
|
|
19
|
+
await run("security", ["find-generic-password", "-s", service]);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function readsAsJson(file) {
|
|
27
|
+
try {
|
|
28
|
+
JSON.parse(await readFile(file, "utf8"));
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function identityAuthState(home, name, meta, env, probe = keychainHas) {
|
|
36
|
+
const dir = identityPath(home, name);
|
|
37
|
+
const no = (detail) => ({ state: "not-configured", detail });
|
|
38
|
+
const yes = (detail) => ({ state: "configured", detail });
|
|
39
|
+
if (meta.agent === "claude") {
|
|
40
|
+
if (meta.token_env)
|
|
41
|
+
return env[meta.token_env]
|
|
42
|
+
? yes(`token variable ${meta.token_env} is set`)
|
|
43
|
+
: no(`token variable ${meta.token_env} is not set`);
|
|
44
|
+
// A keychain item for the TEMPLATE's path proves the template is usable
|
|
45
|
+
// where it sits, and nothing more. A launch never points Claude at the
|
|
46
|
+
// template: it copies it, and the copy's path hashes to a DIFFERENT service
|
|
47
|
+
// name with no fallback (see claudeKeychainService above). So the keychain
|
|
48
|
+
// route cannot produce a launch, and reporting "configured" for it would
|
|
49
|
+
// promise one that dies at its deadline. `token_env` is the only route a
|
|
50
|
+
// Claude identity can launch under, so its absence is not-configured
|
|
51
|
+
// regardless of what the keychain holds.
|
|
52
|
+
return no((await probe(claudeKeychainService(dir)))
|
|
53
|
+
? "a keychain item exists for this identity's template path, but a launch runs from a copy whose path hashes to a different service name — mint a token with `claude setup-token` and name the variable with --token-env"
|
|
54
|
+
: "no credential for a launch — mint a token with `claude setup-token` and name the variable with --token-env");
|
|
55
|
+
}
|
|
56
|
+
// Codex keeps a flat JSON auth file. OpenCode splits config from data: its
|
|
57
|
+
// auth file lives under a data root the binary itself always suffixes with
|
|
58
|
+
// "opencode" (join(XDG_DATA_HOME, "opencode", "auth.json") — confirmed
|
|
59
|
+
// against the installed 1.18.32 binary and the real ~/.local/share/opencode
|
|
60
|
+
// layout; see identity-copy.ts and task-4-report.md). The identity template
|
|
61
|
+
// mirrors that shape under data/opencode/auth.json.
|
|
62
|
+
const file = meta.agent === "opencode" ? join("data", "opencode", "auth.json") : "auth.json";
|
|
63
|
+
return (await readsAsJson(join(dir, file)))
|
|
64
|
+
? yes(`${file} is present`)
|
|
65
|
+
: no(`${file} is missing or unreadable — log in for this identity`);
|
|
66
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { identityAuthState } from "./identity-auth.js";
|
|
2
|
+
import { IdentityError, identityDirExists, identityMetaSchema, identityNameSchema, identityPath, listIdentities, readIdentity, writeIdentity, } from "./identity-store.js";
|
|
3
|
+
/**
|
|
4
|
+
* The one-time login for each agent. Muster never performs it: each is an
|
|
5
|
+
* interactive OAuth flow, and a command that appeared to do it for you would be
|
|
6
|
+
* hiding a credential prompt.
|
|
7
|
+
*/
|
|
8
|
+
function loginCommand(agent, dir) {
|
|
9
|
+
if (agent === "claude")
|
|
10
|
+
// setup-token first, and not as an alternative: a launch runs from a COPY of
|
|
11
|
+
// this directory, and the keychain credential an interactive /login writes is
|
|
12
|
+
// keyed to THIS path, so it cannot be reached from a copy. Only a token can.
|
|
13
|
+
return `CLAUDE_CONFIG_DIR=${dir} claude setup-token # then export the token and pass --token-env`;
|
|
14
|
+
if (agent === "codex")
|
|
15
|
+
return `CODEX_HOME=${dir} codex login`;
|
|
16
|
+
return `OPENCODE_CONFIG_DIR=${dir} opencode auth login`;
|
|
17
|
+
}
|
|
18
|
+
export async function setupIdentity(opts) {
|
|
19
|
+
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.
|
|
24
|
+
if (opts.tokenEnv && opts.agent !== "claude")
|
|
25
|
+
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
|
+
// Distinguish "no such identity" from "an identity that is there but unusable".
|
|
27
|
+
// Overwriting the second would silently repurpose a directory that still holds
|
|
28
|
+
// another agent's credential.
|
|
29
|
+
const present = await identityDirExists(opts.home, name);
|
|
30
|
+
let created = true;
|
|
31
|
+
let existing;
|
|
32
|
+
if (present) {
|
|
33
|
+
created = false;
|
|
34
|
+
existing = await readIdentity(opts.home, name); // throws if unusable
|
|
35
|
+
if (existing.agent !== opts.agent)
|
|
36
|
+
throw new IdentityError(name, `already exists for agent ${existing.agent}; refusing to repurpose it for ${opts.agent}`);
|
|
37
|
+
}
|
|
38
|
+
// An operator who passes --token-env means it to take effect, even on an
|
|
39
|
+
// identity that already has one recorded — that's the whole point of this
|
|
40
|
+
// command's remedy path (see issue #59). Omitting the flag on a re-run must
|
|
41
|
+
// leave whatever is recorded alone, which is why this only fires when a
|
|
42
|
+
// value was actually supplied and it differs from what's already there.
|
|
43
|
+
const tokenEnvChanged = opts.tokenEnv !== undefined && opts.tokenEnv !== existing?.token_env;
|
|
44
|
+
const meta = identityMetaSchema.parse({
|
|
45
|
+
agent: opts.agent,
|
|
46
|
+
// Preserve the original creation timestamp for an existing identity;
|
|
47
|
+
// only a brand-new directory gets a fresh one.
|
|
48
|
+
created: existing?.created ?? new Date().toISOString(),
|
|
49
|
+
...(opts.tokenEnv !== undefined
|
|
50
|
+
? { token_env: opts.tokenEnv }
|
|
51
|
+
: existing?.token_env
|
|
52
|
+
? { token_env: existing.token_env }
|
|
53
|
+
: {}),
|
|
54
|
+
});
|
|
55
|
+
if (created || tokenEnvChanged)
|
|
56
|
+
await writeIdentity(opts.home, name, meta);
|
|
57
|
+
const current = await readIdentity(opts.home, name);
|
|
58
|
+
const dir = identityPath(opts.home, name);
|
|
59
|
+
return {
|
|
60
|
+
created,
|
|
61
|
+
path: dir,
|
|
62
|
+
login: loginCommand(current.agent, dir),
|
|
63
|
+
auth: await identityAuthState(opts.home, name, current, opts.env ?? process.env),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export async function describeIdentities(home, env,
|
|
67
|
+
/** Profile grants, so stale ones surface without a launch having to fail. */
|
|
68
|
+
grants = {}) {
|
|
69
|
+
const found = await listIdentities(home);
|
|
70
|
+
const identities = [];
|
|
71
|
+
for (const { name, meta } of found)
|
|
72
|
+
identities.push({
|
|
73
|
+
name,
|
|
74
|
+
agent: meta.agent,
|
|
75
|
+
auth: await identityAuthState(home, name, meta, env),
|
|
76
|
+
});
|
|
77
|
+
// A profile granting an identity that does not exist is a misconfiguration.
|
|
78
|
+
// It is deliberately NOT checked in loadConfig: Muster reads its config for
|
|
79
|
+
// every command, so failing there would stop `list` and `stop` working and
|
|
80
|
+
// turn a stale grant into a total outage.
|
|
81
|
+
const names = new Set(found.map((i) => i.name));
|
|
82
|
+
const staleGrants = [];
|
|
83
|
+
for (const [profile, granted] of Object.entries(grants))
|
|
84
|
+
for (const identity of granted)
|
|
85
|
+
if (!names.has(identity))
|
|
86
|
+
staleGrants.push({ profile, identity });
|
|
87
|
+
return { identities, staleGrants };
|
|
88
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { identityPath } from "./identity-store.js";
|
|
4
|
+
/**
|
|
5
|
+
* What a copy must contain: enough to start authenticated, onboarded, and
|
|
6
|
+
* trusting the launch directory. Deliberately not the template's cache, plugins
|
|
7
|
+
* or history — copying only this is what keeps retained copies proportional to
|
|
8
|
+
* the transcripts written into them rather than to the template's size.
|
|
9
|
+
*
|
|
10
|
+
* OpenCode entries are settled against the installed 1.18.32 binary, not
|
|
11
|
+
* guessed (see task-4-report.md for the full evidence trail):
|
|
12
|
+
* - CONFIG resolves as `OPENCODE_CONFIG_DIR ?? XDG_CONFIG_HOME/opencode`,
|
|
13
|
+
* used as-is with no suffix appended. The real file the binary reads on
|
|
14
|
+
* this machine is `opencode.jsonc` (confirmed at ~/.config/opencode —
|
|
15
|
+
* `config.json` and `opencode.json` are also checked, in that order, but
|
|
16
|
+
* neither exists here; `opencode.jsonc` is the one actually in use).
|
|
17
|
+
* - DATA always resolves as `XDG_DATA_HOME/opencode` — the "opencode"
|
|
18
|
+
* suffix is hardcoded in the binary and cannot be overridden
|
|
19
|
+
* independently (there is no OPENCODE_DATA_DIR). Its auth file is
|
|
20
|
+
* `<data>/auth.json`, confirmed against the string join(e.Path.data,
|
|
21
|
+
* "auth.json") in the binary and against the real file at
|
|
22
|
+
* ~/.local/share/opencode/auth.json on this machine.
|
|
23
|
+
* So the copy reproduces that shape under a `data/` subdirectory, and
|
|
24
|
+
* `identityEnv` points XDG_DATA_HOME at it (see below). This was NOT
|
|
25
|
+
* verified end to end against an authenticated OpenCode identity — no one
|
|
26
|
+
* has run the one-time login for one on this machine, so there is no
|
|
27
|
+
* template to copy from and launch. The config/data path resolution above is
|
|
28
|
+
* verified from the binary's own logic and the real on-disk layout; whether a
|
|
29
|
+
* copy built this way actually leaves OpenCode believing itself logged in on
|
|
30
|
+
* first launch is unverified.
|
|
31
|
+
*/
|
|
32
|
+
export const IDENTITY_FILES = {
|
|
33
|
+
claude: [".claude.json", "settings.json"],
|
|
34
|
+
codex: ["auth.json", "config.toml"],
|
|
35
|
+
opencode: ["opencode.jsonc", join("data", "opencode", "auth.json")],
|
|
36
|
+
};
|
|
37
|
+
export const identityLiveDir = (home, launchId) => join(home, "identities-live", launchId);
|
|
38
|
+
export async function copyIdentity(home, name, meta, launchId) {
|
|
39
|
+
const from = identityPath(home, name);
|
|
40
|
+
const to = identityLiveDir(home, launchId);
|
|
41
|
+
await mkdir(to, { recursive: true, mode: 0o700 });
|
|
42
|
+
for (const file of IDENTITY_FILES[meta.agent]) {
|
|
43
|
+
const dest = join(to, file);
|
|
44
|
+
// A declared file may nest inside a subdirectory (opencode's data/opencode/
|
|
45
|
+
// auth.json) — the destination directory needs to exist before copyFile can
|
|
46
|
+
// write into it. Not swallowed: if the parent cannot be created, that must
|
|
47
|
+
// surface rather than leave the file silently absent from the copy.
|
|
48
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
49
|
+
try {
|
|
50
|
+
await copyFile(join(from, file), dest);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
// A template legitimately lacks a file until the agent writes one — a new
|
|
54
|
+
// identity has no settings.json yet. Anything else is a real failure, and
|
|
55
|
+
// hiding it would launch an agent that cannot authenticate with no signal.
|
|
56
|
+
if (e.code !== "ENOENT")
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return to;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The variables the AGENT receives. Muster's own subprocesses never get these:
|
|
64
|
+
* a version probe pointed at a per-launch copy answers the wrong question.
|
|
65
|
+
*/
|
|
66
|
+
export function identityEnv(meta, copyPath, sourceEnv) {
|
|
67
|
+
if (meta.agent === "codex")
|
|
68
|
+
return { CODEX_HOME: copyPath };
|
|
69
|
+
if (meta.agent === "opencode")
|
|
70
|
+
// OPENCODE_CONFIG_DIR replaces the config root outright, so it is set to
|
|
71
|
+
// copyPath directly. XDG_DATA_HOME is different: the binary hardcodes an
|
|
72
|
+
// "opencode" suffix onto whatever XDG_DATA_HOME is, so XDG_DATA_HOME must be
|
|
73
|
+
// set one level ABOVE where the files actually land (copyPath/data, not
|
|
74
|
+
// copyPath/data/opencode) for that suffix to land back on the copy.
|
|
75
|
+
return {
|
|
76
|
+
OPENCODE_CONFIG_DIR: copyPath,
|
|
77
|
+
XDG_DATA_HOME: join(copyPath, "data"),
|
|
78
|
+
};
|
|
79
|
+
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
|
+
}
|
|
86
|
+
return env;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Set Claude Code's workspace-trust flag for one directory, inside a copy Muster
|
|
90
|
+
* just created. The live `~/.claude.json` is never touched — that file is written
|
|
91
|
+
* continuously by every running session, and Muster cannot coordinate with it.
|
|
92
|
+
*/
|
|
93
|
+
export async function setWorkspaceTrust(copyPath, cwd) {
|
|
94
|
+
const file = join(copyPath, ".claude.json");
|
|
95
|
+
let data = {};
|
|
96
|
+
let raw;
|
|
97
|
+
try {
|
|
98
|
+
raw = await readFile(file, "utf8");
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
// The same rule identity-store.ts and copyIdentity settled on: a template
|
|
102
|
+
// that has never been logged into legitimately has no .claude.json, and a
|
|
103
|
+
// file containing only `projects` is valid. Anything else — EACCES, EISDIR —
|
|
104
|
+
// is a real failure, and swallowing it here discarded every onboarding flag
|
|
105
|
+
// the template's own login produced and wrote a file that had lost them.
|
|
106
|
+
if (e.code !== "ENOENT")
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
if (raw !== undefined)
|
|
110
|
+
try {
|
|
111
|
+
data = JSON.parse(raw);
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
// A file that EXISTS and does not parse is not "no file yet". Overwriting
|
|
115
|
+
// it would silently drop the copied template's onboarding state and leave
|
|
116
|
+
// an agent that re-onboards, or does not start at all, with no signal.
|
|
117
|
+
throw new Error(`${file} exists but is not valid JSON: ${String(e)}`);
|
|
118
|
+
}
|
|
119
|
+
const projects = (data.projects ?? {});
|
|
120
|
+
const existing = (projects[cwd] ?? {});
|
|
121
|
+
projects[cwd] = { ...existing, hasTrustDialogAccepted: true };
|
|
122
|
+
data.projects = projects;
|
|
123
|
+
await writeFile(file, JSON.stringify(data), { mode: 0o600 });
|
|
124
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
/**
|
|
5
|
+
* A named, pre-authenticated template configuration for one agent.
|
|
6
|
+
*
|
|
7
|
+
* The directory is 0700 because a Codex or OpenCode template contains that
|
|
8
|
+
* account's own auth file — written there by that agent's login, not by Muster,
|
|
9
|
+
* but on disk under `~/.muster` all the same. Deleting an identity deletes a
|
|
10
|
+
* credential.
|
|
11
|
+
*/
|
|
12
|
+
export const identityNameSchema = z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(64)
|
|
16
|
+
.regex(/^[a-zA-Z0-9_-]+$/, "must be letters, digits, dash or underscore");
|
|
17
|
+
export const identityMetaSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
agent: z.enum(["codex", "claude", "opencode"]),
|
|
20
|
+
created: z.string().min(1),
|
|
21
|
+
/** Name of the variable holding this identity's token. Never the token. */
|
|
22
|
+
token_env: z.string().min(1).optional(),
|
|
23
|
+
})
|
|
24
|
+
.strict();
|
|
25
|
+
/** A local problem with an identity: unknown, malformed, or unusable. */
|
|
26
|
+
export class IdentityError extends Error {
|
|
27
|
+
identity;
|
|
28
|
+
constructor(identity, message) {
|
|
29
|
+
super(`identity ${identity}: ${message}`);
|
|
30
|
+
this.identity = identity;
|
|
31
|
+
this.name = "IdentityError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export const identitiesDir = (home) => join(home, "identities");
|
|
35
|
+
export const identityPath = (home, name) => {
|
|
36
|
+
// Wrapped so every identity problem is an IdentityError, as the error model
|
|
37
|
+
// promises — a raw schema error here would escape that contract and reach a
|
|
38
|
+
// caller as an unrecognisable shape.
|
|
39
|
+
const parsed = identityNameSchema.safeParse(name);
|
|
40
|
+
if (!parsed.success)
|
|
41
|
+
throw new IdentityError(name, "name must be letters, digits, dash or underscore");
|
|
42
|
+
return join(identitiesDir(home), parsed.data);
|
|
43
|
+
};
|
|
44
|
+
/** Whether the directory exists, regardless of whether its metadata is usable. */
|
|
45
|
+
export async function identityDirExists(home, name) {
|
|
46
|
+
const dir = identityPath(home, name); // throws IdentityError for a bad name
|
|
47
|
+
try {
|
|
48
|
+
return (await stat(dir)).isDirectory();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function writeIdentity(home, name, meta) {
|
|
55
|
+
const dir = identityPath(home, name);
|
|
56
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
57
|
+
await writeFile(join(dir, "identity.json"), JSON.stringify(identityMetaSchema.parse(meta), null, 2) + "\n", { mode: 0o600 });
|
|
58
|
+
}
|
|
59
|
+
export async function readIdentity(home, name) {
|
|
60
|
+
const file = join(identityPath(home, name), "identity.json");
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = await readFile(file, "utf8");
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
const code = e.code;
|
|
67
|
+
if (code === "ENOENT")
|
|
68
|
+
throw new IdentityError(name, "not found — create it with setup-identity");
|
|
69
|
+
throw new IdentityError(name, `metadata could not be read (${code ?? String(e)})`);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return identityMetaSchema.parse(JSON.parse(raw));
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
throw new IdentityError(name, `metadata is unusable (${String(e)})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function listIdentities(home) {
|
|
79
|
+
let names;
|
|
80
|
+
try {
|
|
81
|
+
names = (await readdir(identitiesDir(home), { withFileTypes: true }))
|
|
82
|
+
.filter((e) => e.isDirectory())
|
|
83
|
+
.map((e) => e.name)
|
|
84
|
+
.sort();
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
if (e.code === "ENOENT")
|
|
88
|
+
return [];
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const name of names)
|
|
93
|
+
try {
|
|
94
|
+
out.push({ name, meta: await readIdentity(home, name) });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// A directory without usable metadata is not an identity. `identities`
|
|
98
|
+
// reports store contents, and a half-made directory is not one.
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
package/dist/log.js
CHANGED
|
@@ -1,15 +1,93 @@
|
|
|
1
|
-
import { mkdir, open } from "node:fs/promises";
|
|
1
|
+
import { mkdir, open, rename, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Fields a metadata-only record must not carry: the instruction itself, any
|
|
5
|
+
* captured screen or output, and anything environment-derived. Enough remains
|
|
6
|
+
* to diagnose a failed launch without holding what was asked.
|
|
7
|
+
*
|
|
8
|
+
* `args` is deliberately NOT dropped: `runtimeArgs` admits only `--model`,
|
|
9
|
+
* `-m`, `--effort`, `--agent` and `--variant`, so argv cannot carry instruction
|
|
10
|
+
* text or a credential, and keeping it is worth more for diagnosis than the
|
|
11
|
+
* nothing it exposes.
|
|
12
|
+
*/
|
|
13
|
+
export const METADATA_DROP = ["prompt", "diagnostic", "output", "env"];
|
|
14
|
+
/**
|
|
15
|
+
* A nested launch record — a ready session's `peer`, a started task's `run` —
|
|
16
|
+
* is REBUILT from this allowlist in metadata mode rather than filtered by a
|
|
17
|
+
* drop list.
|
|
18
|
+
*
|
|
19
|
+
* `peer.name` and `peer.canonical_id` are slugs of the runtime's own session
|
|
20
|
+
* title, which every runtime derives from the prompt, so `{"name":
|
|
21
|
+
* "secret-instruction"}` reached a log the README calls promptless. A drop
|
|
22
|
+
* list would have fixed those two and let the next field someone adds to a
|
|
23
|
+
* peer through; an allowlist admits only what is named here, which is the set
|
|
24
|
+
* the spec enumerates for a metadata record.
|
|
25
|
+
*/
|
|
26
|
+
export const METADATA_RECORD_KEEP = [
|
|
27
|
+
"kind",
|
|
28
|
+
"id",
|
|
29
|
+
"runtime",
|
|
30
|
+
"state",
|
|
31
|
+
"cwd",
|
|
32
|
+
"host",
|
|
33
|
+
"pid",
|
|
34
|
+
"permissions",
|
|
35
|
+
"sandbox",
|
|
36
|
+
"enforcement",
|
|
37
|
+
"mcp",
|
|
38
|
+
"mcp_warnings",
|
|
39
|
+
"plugins",
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* `requester` is the one nested object passed through whole: the spec keeps the
|
|
43
|
+
* tuple and its label, and a label is supplied by the receiver's own config,
|
|
44
|
+
* never derived from the request.
|
|
45
|
+
*/
|
|
46
|
+
const PASS_THROUGH = ["requester"];
|
|
47
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
48
|
+
/**
|
|
49
|
+
* One generation, 8 MiB. The file had no bound at all before this.
|
|
50
|
+
*
|
|
51
|
+
* Rotation is not locked. Two processes crossing the ceiling together cannot
|
|
52
|
+
* lose a line — each write opens the file fresh, and a rename only clobbers
|
|
53
|
+
* the destination — but three or more crossing at once can, because a second
|
|
54
|
+
* clobber may take out an intermediate file that has already been closed.
|
|
55
|
+
* Accepted: the registry's lock exists for launch reservations, and coupling
|
|
56
|
+
* a best-effort history to it would cost more than the history is worth. If
|
|
57
|
+
* that trade ever stops holding, this is the place it changes.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
3
60
|
export class LaunchLog {
|
|
4
61
|
home;
|
|
5
62
|
constructor(home) {
|
|
6
63
|
this.home = home;
|
|
7
64
|
}
|
|
8
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The caller chooses the mode; this method only obeys it. The policy that a
|
|
67
|
+
* non-local requester always gets `metadata`, and that a profile may not
|
|
68
|
+
* relax it, lives with the requester policy resolution, not here.
|
|
69
|
+
*/
|
|
70
|
+
async write(data, mode = "full") {
|
|
9
71
|
await mkdir(this.home, { recursive: true, mode: 0o700 });
|
|
10
|
-
const
|
|
72
|
+
const path = join(this.home, "launches.jsonl");
|
|
73
|
+
const size = await stat(path)
|
|
74
|
+
.then((s) => s.size)
|
|
75
|
+
.catch(() => 0);
|
|
76
|
+
if (size >= MAX_BYTES)
|
|
77
|
+
await rename(path, path + ".1").catch(() => undefined);
|
|
78
|
+
const body = mode === "full"
|
|
79
|
+
? data
|
|
80
|
+
: Object.fromEntries(Object.entries(data)
|
|
81
|
+
.filter(([k]) => !METADATA_DROP.includes(k))
|
|
82
|
+
.map(([k, v]) => isRecord(v) && !PASS_THROUGH.includes(k)
|
|
83
|
+
? [
|
|
84
|
+
k,
|
|
85
|
+
Object.fromEntries(Object.entries(v).filter(([field]) => METADATA_RECORD_KEEP.includes(field))),
|
|
86
|
+
]
|
|
87
|
+
: [k, v]));
|
|
88
|
+
const file = await open(path, "a", 0o600);
|
|
11
89
|
try {
|
|
12
|
-
await file.writeFile(JSON.stringify({ at: new Date().toISOString(), ...
|
|
90
|
+
await file.writeFile(JSON.stringify({ at: new Date().toISOString(), ...body }) + "\n");
|
|
13
91
|
await file.sync();
|
|
14
92
|
}
|
|
15
93
|
finally {
|