@brutalsystems/muster 0.7.12 → 0.7.15
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 +264 -5
- 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-auth.js +66 -0
- package/dist/identity-cli.js +88 -0
- package/dist/identity-copy.js +184 -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 +15 -2
- package/dist/requester-policy.js +106 -0
- package/dist/requester.js +59 -0
- package/dist/run.js +296 -47
- package/package.json +1 -1
|
@@ -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,184 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import TOML from "@iarna/toml";
|
|
4
|
+
import { identityPath } from "./identity-store.js";
|
|
5
|
+
/**
|
|
6
|
+
* What a copy must contain: enough to start authenticated, onboarded, and
|
|
7
|
+
* trusting the launch directory. Deliberately not the template's cache, plugins
|
|
8
|
+
* or history — copying only this is what keeps retained copies proportional to
|
|
9
|
+
* the transcripts written into them rather than to the template's size.
|
|
10
|
+
*
|
|
11
|
+
* OpenCode entries are settled against the installed 1.18.32 binary, not
|
|
12
|
+
* guessed (see task-4-report.md for the full evidence trail):
|
|
13
|
+
* - CONFIG resolves as `OPENCODE_CONFIG_DIR ?? XDG_CONFIG_HOME/opencode`,
|
|
14
|
+
* used as-is with no suffix appended. The real file the binary reads on
|
|
15
|
+
* this machine is `opencode.jsonc` (confirmed at ~/.config/opencode —
|
|
16
|
+
* `config.json` and `opencode.json` are also checked, in that order, but
|
|
17
|
+
* neither exists here; `opencode.jsonc` is the one actually in use).
|
|
18
|
+
* - DATA always resolves as `XDG_DATA_HOME/opencode` — the "opencode"
|
|
19
|
+
* suffix is hardcoded in the binary and cannot be overridden
|
|
20
|
+
* independently (there is no OPENCODE_DATA_DIR). Its auth file is
|
|
21
|
+
* `<data>/auth.json`, confirmed against the string join(e.Path.data,
|
|
22
|
+
* "auth.json") in the binary and against the real file at
|
|
23
|
+
* ~/.local/share/opencode/auth.json on this machine.
|
|
24
|
+
* So the copy reproduces that shape under a `data/` subdirectory, and
|
|
25
|
+
* `identityEnv` points XDG_DATA_HOME at it (see below). This was NOT
|
|
26
|
+
* verified end to end against an authenticated OpenCode identity — no one
|
|
27
|
+
* has run the one-time login for one on this machine, so there is no
|
|
28
|
+
* template to copy from and launch. The config/data path resolution above is
|
|
29
|
+
* verified from the binary's own logic and the real on-disk layout; whether a
|
|
30
|
+
* copy built this way actually leaves OpenCode believing itself logged in on
|
|
31
|
+
* first launch is unverified.
|
|
32
|
+
*/
|
|
33
|
+
export const IDENTITY_FILES = {
|
|
34
|
+
claude: [".claude.json", "settings.json"],
|
|
35
|
+
codex: ["auth.json", "config.toml"],
|
|
36
|
+
opencode: ["opencode.jsonc", join("data", "opencode", "auth.json")],
|
|
37
|
+
};
|
|
38
|
+
export const identityLiveDir = (home, launchId) => join(home, "identities-live", launchId);
|
|
39
|
+
export async function copyIdentity(home, name, meta, launchId) {
|
|
40
|
+
const from = identityPath(home, name);
|
|
41
|
+
const to = identityLiveDir(home, launchId);
|
|
42
|
+
await mkdir(to, { recursive: true, mode: 0o700 });
|
|
43
|
+
for (const file of IDENTITY_FILES[meta.agent]) {
|
|
44
|
+
const dest = join(to, file);
|
|
45
|
+
// A declared file may nest inside a subdirectory (opencode's data/opencode/
|
|
46
|
+
// auth.json) — the destination directory needs to exist before copyFile can
|
|
47
|
+
// write into it. Not swallowed: if the parent cannot be created, that must
|
|
48
|
+
// surface rather than leave the file silently absent from the copy.
|
|
49
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
50
|
+
try {
|
|
51
|
+
await copyFile(join(from, file), dest);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
// A template legitimately lacks a file until the agent writes one — a new
|
|
55
|
+
// identity has no settings.json yet. Anything else is a real failure, and
|
|
56
|
+
// hiding it would launch an agent that cannot authenticate with no signal.
|
|
57
|
+
if (e.code !== "ENOENT")
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return to;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The variables the AGENT receives. Muster's own subprocesses never get these:
|
|
65
|
+
* a version probe pointed at a per-launch copy answers the wrong question.
|
|
66
|
+
*/
|
|
67
|
+
export function identityEnv(meta, copyPath, sourceEnv) {
|
|
68
|
+
if (meta.agent === "codex")
|
|
69
|
+
return { CODEX_HOME: copyPath };
|
|
70
|
+
if (meta.agent === "opencode")
|
|
71
|
+
// OPENCODE_CONFIG_DIR replaces the config root outright, so it is set to
|
|
72
|
+
// copyPath directly. XDG_DATA_HOME is different: the binary hardcodes an
|
|
73
|
+
// "opencode" suffix onto whatever XDG_DATA_HOME is, so XDG_DATA_HOME must be
|
|
74
|
+
// set one level ABOVE where the files actually land (copyPath/data, not
|
|
75
|
+
// copyPath/data/opencode) for that suffix to land back on the copy.
|
|
76
|
+
return {
|
|
77
|
+
OPENCODE_CONFIG_DIR: copyPath,
|
|
78
|
+
XDG_DATA_HOME: join(copyPath, "data"),
|
|
79
|
+
};
|
|
80
|
+
const env = { CLAUDE_CONFIG_DIR: copyPath };
|
|
81
|
+
if (meta.token_env) {
|
|
82
|
+
const token = sourceEnv[meta.token_env];
|
|
83
|
+
// Absence is the caller's problem to report before spawning; see run.ts.
|
|
84
|
+
if (typeof token === "string" && token.length)
|
|
85
|
+
env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
86
|
+
}
|
|
87
|
+
return env;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Set Claude Code's workspace-trust flag for one directory, inside a copy Muster
|
|
91
|
+
* just created. The live `~/.claude.json` is never touched — that file is written
|
|
92
|
+
* continuously by every running session, and Muster cannot coordinate with it.
|
|
93
|
+
*/
|
|
94
|
+
export async function setWorkspaceTrust(copyPath, cwd) {
|
|
95
|
+
const file = join(copyPath, ".claude.json");
|
|
96
|
+
let data = {};
|
|
97
|
+
let raw;
|
|
98
|
+
try {
|
|
99
|
+
raw = await readFile(file, "utf8");
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
// The same rule identity-store.ts and copyIdentity settled on: a template
|
|
103
|
+
// that has never been logged into legitimately has no .claude.json, and a
|
|
104
|
+
// file containing only `projects` is valid. Anything else — EACCES, EISDIR —
|
|
105
|
+
// is a real failure, and swallowing it here discarded every onboarding flag
|
|
106
|
+
// the template's own login produced and wrote a file that had lost them.
|
|
107
|
+
if (e.code !== "ENOENT")
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
if (raw !== undefined)
|
|
111
|
+
try {
|
|
112
|
+
data = JSON.parse(raw);
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
// A file that EXISTS and does not parse is not "no file yet". Overwriting
|
|
116
|
+
// it would silently drop the copied template's onboarding state and leave
|
|
117
|
+
// an agent that re-onboards, or does not start at all, with no signal.
|
|
118
|
+
throw new Error(`${file} exists but is not valid JSON: ${String(e)}`);
|
|
119
|
+
}
|
|
120
|
+
const projects = (data.projects ?? {});
|
|
121
|
+
const existing = (projects[cwd] ?? {});
|
|
122
|
+
projects[cwd] = { ...existing, hasTrustDialogAccepted: true };
|
|
123
|
+
data.projects = projects;
|
|
124
|
+
await writeFile(file, JSON.stringify(data), { mode: 0o600 });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Set Codex's directory-trust flag for one directory, inside a copy Muster just
|
|
128
|
+
* created. Codex has its own gate, unrelated to Claude's: without a
|
|
129
|
+
* `[projects."<dir>"] trust_level = "trusted"` entry in the config.toml it
|
|
130
|
+
* reads, the TUI stops on "Do you trust the contents of this directory?" before
|
|
131
|
+
* it ever opens a thread. No thread is opened, so no thread-writer lock is
|
|
132
|
+
* written, so `resolveCodex` finds nothing and the launch burns its whole
|
|
133
|
+
* deadline reporting `no descendant runtime identity found`.
|
|
134
|
+
*
|
|
135
|
+
* A fresh identity copy has no `projects` table at all, which made EVERY Codex
|
|
136
|
+
* identity launch fail exactly that way — measured, not inferred: the pane was
|
|
137
|
+
* captured mid-launch sitting on the prompt while the copy filled with Codex's
|
|
138
|
+
* own sqlite state and never grew a `thread-writer-locks/` directory.
|
|
139
|
+
*
|
|
140
|
+
* `-c projects."<dir>".trust_level="trusted"` does NOT satisfy the gate. It
|
|
141
|
+
* parses and is accepted, and the prompt still appears: the check reads
|
|
142
|
+
* persisted config, so the entry has to be in the file.
|
|
143
|
+
*
|
|
144
|
+
* The user's real `~/.codex/config.toml` is never touched, for the same reason
|
|
145
|
+
* `setWorkspaceTrust` leaves the live `~/.claude.json` alone.
|
|
146
|
+
*/
|
|
147
|
+
export async function setCodexWorkspaceTrust(copyPath, cwd) {
|
|
148
|
+
const file = join(copyPath, "config.toml");
|
|
149
|
+
let raw;
|
|
150
|
+
try {
|
|
151
|
+
raw = await readFile(file, "utf8");
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
// A template that has never been logged into has no config.toml; Codex
|
|
155
|
+
// runs on defaults. Anything else — EACCES, EISDIR — is a real failure,
|
|
156
|
+
// and swallowing it would hand the agent a config missing every setting
|
|
157
|
+
// the template's own login wrote.
|
|
158
|
+
if (e.code !== "ENOENT")
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
161
|
+
let data = {};
|
|
162
|
+
if (raw !== undefined)
|
|
163
|
+
try {
|
|
164
|
+
data = TOML.parse(raw);
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
// A file that EXISTS and does not parse is not "no file yet". Codex would
|
|
168
|
+
// refuse it too, so writing over it trades one clear failure for a silent
|
|
169
|
+
// loss of the template's settings.
|
|
170
|
+
throw new Error(`${file} exists but is not valid TOML: ${String(e)}`);
|
|
171
|
+
}
|
|
172
|
+
const projects = (data.projects ?? {});
|
|
173
|
+
const existing = (projects[cwd] ?? {});
|
|
174
|
+
if (existing.trust_level === "trusted")
|
|
175
|
+
return;
|
|
176
|
+
projects[cwd] = { ...existing, trust_level: "trusted" };
|
|
177
|
+
data.projects = projects;
|
|
178
|
+
// Re-serialised rather than appended, so an entry the template already
|
|
179
|
+
// carries with a different trust_level is corrected instead of duplicated —
|
|
180
|
+
// a duplicate table is a TOML error and Codex would refuse the whole file.
|
|
181
|
+
// Comments and key order in the copy are lost; the copy is per-launch and
|
|
182
|
+
// machine-read, and the template it came from is untouched.
|
|
183
|
+
await writeFile(file, TOML.stringify(data), { mode: 0o600 });
|
|
184
|
+
}
|
|
@@ -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 {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `run`, `list`, `stop` and `output` tools exactly as the MCP server
|
|
3
|
+
* advertises them.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `src/muster.ts` so it can be ASSERTED against `runSchema`
|
|
6
|
+
* rather than kept in step by hand. One request shape had two hand-maintained
|
|
7
|
+
* descriptions and nothing checked that they agreed, and four fields drifted
|
|
8
|
+
* across two branches: `level`, then `identity`, `model` and `plugin`. Because
|
|
9
|
+
* the schema is `additionalProperties: false`, an omitted key is not merely
|
|
10
|
+
* undocumented — a conformant client is told it is INVALID.
|
|
11
|
+
* `test/mcp-run-schema.test.ts` is what ends that class.
|
|
12
|
+
*/
|
|
13
|
+
export const MCP_TOOLS = [
|
|
14
|
+
{
|
|
15
|
+
name: "run",
|
|
16
|
+
description: "Launch an instructed agent. Sessions return only when reachable; tasks return a non-messageable run handle.",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
runtime: {
|
|
21
|
+
type: "string",
|
|
22
|
+
enum: ["codex", "claude", "opencode"],
|
|
23
|
+
},
|
|
24
|
+
prompt: { type: "string", minLength: 1 },
|
|
25
|
+
cwd: { type: "string" },
|
|
26
|
+
kind: {
|
|
27
|
+
type: "string",
|
|
28
|
+
enum: ["session", "task"],
|
|
29
|
+
default: "session",
|
|
30
|
+
},
|
|
31
|
+
host: {
|
|
32
|
+
type: "string",
|
|
33
|
+
enum: ["auto", "tmux", "pty", "macos-terminal"],
|
|
34
|
+
},
|
|
35
|
+
open: {
|
|
36
|
+
type: "boolean",
|
|
37
|
+
description: "Open the tmux session in a terminal viewer (macOS only).",
|
|
38
|
+
},
|
|
39
|
+
terminal: {
|
|
40
|
+
type: "string",
|
|
41
|
+
enum: ["auto", "terminal", "iterm2", "ghostty"],
|
|
42
|
+
description: "Viewer app; requires open. Auto uses Terminal.app.",
|
|
43
|
+
},
|
|
44
|
+
permissions: {
|
|
45
|
+
type: "string",
|
|
46
|
+
enum: ["auto", "deny", "bypass"],
|
|
47
|
+
description: "Defaults to config (deny). Auto requires workspace-write; bypass requires authorized full-access.",
|
|
48
|
+
},
|
|
49
|
+
sandbox: {
|
|
50
|
+
type: "string",
|
|
51
|
+
enum: ["read-only", "workspace-write", "full-access"],
|
|
52
|
+
description: "Defaults to config (read-only). Full access requires config authorization.",
|
|
53
|
+
},
|
|
54
|
+
level: {
|
|
55
|
+
type: "string",
|
|
56
|
+
enum: ["read", "work", "open"],
|
|
57
|
+
description: "A spelling of a legal permissions/sandbox pair: read is deny + read-only, work is auto + workspace-write, open is bypass + full-access. Naming a level and either flag is an error.",
|
|
58
|
+
},
|
|
59
|
+
mcp: {
|
|
60
|
+
type: "array",
|
|
61
|
+
items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
|
|
62
|
+
description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
|
|
63
|
+
},
|
|
64
|
+
project: {
|
|
65
|
+
type: "string",
|
|
66
|
+
pattern: "^[a-zA-Z0-9_-]+$",
|
|
67
|
+
description: "A project named in config.toml, used instead of cwd. Naming both is an error.",
|
|
68
|
+
},
|
|
69
|
+
requestKey: {
|
|
70
|
+
type: "string",
|
|
71
|
+
minLength: 1,
|
|
72
|
+
maxLength: 200,
|
|
73
|
+
description: "Caller-owned identity for this launch. Repeating it returns the launch it already produced instead of starting another; repeating it with different parameters is refused.",
|
|
74
|
+
},
|
|
75
|
+
plugin: {
|
|
76
|
+
type: "array",
|
|
77
|
+
items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
|
|
78
|
+
description: "Configured OpenCode plugin names. Omit for session defaults; [] disables all. OpenCode only.",
|
|
79
|
+
},
|
|
80
|
+
model: {
|
|
81
|
+
type: "string",
|
|
82
|
+
minLength: 1,
|
|
83
|
+
description: "PROVIDER/MODEL, overriding the configured [opencode] model. OpenCode only.",
|
|
84
|
+
},
|
|
85
|
+
identity: {
|
|
86
|
+
type: "string",
|
|
87
|
+
pattern: "^[a-zA-Z0-9_-]+$",
|
|
88
|
+
description: "A named identity created by `muster setup-identity`; the agent runs as that account instead of inheriting the ambient environment. A non-local requester is bounded by its profile's identities.",
|
|
89
|
+
},
|
|
90
|
+
args: { type: "array", items: { type: "string" } },
|
|
91
|
+
},
|
|
92
|
+
required: ["runtime", "prompt"],
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "list",
|
|
98
|
+
description: "List Muster-owned sessions and tasks, including host capabilities.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: { kind: { type: "string", enum: ["session", "task"] } },
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "stop",
|
|
107
|
+
description: "Stop a Muster-owned run by durable id or unambiguous peer name.",
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: { id: { type: "string", minLength: 1 } },
|
|
111
|
+
required: ["id"],
|
|
112
|
+
additionalProperties: false,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "output",
|
|
117
|
+
description: "Read captured output from a task run.",
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: { id: { type: "string", minLength: 1 } },
|
|
121
|
+
required: ["id"],
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
/**
|
|
127
|
+
* runSchema keys deliberately absent from the advertised `run` inputSchema.
|
|
128
|
+
*
|
|
129
|
+
* Empty today, and it stays here rather than being implicit: adding a field to
|
|
130
|
+
* runSchema without either advertising it or naming it here — with a reason —
|
|
131
|
+
* fails the drift test. That is the point. An entry is a decision that MCP
|
|
132
|
+
* callers may not set the field, not a place to park an oversight.
|
|
133
|
+
*/
|
|
134
|
+
export const RUN_MCP_OMITTED = [];
|