@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.
@@ -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 derived from the parent chain and inherits its one real limit:
20
- * a reparented child is unreachable from the root. See issue #24.
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
- export async function descendants(root, deadline = Date.now() + 1000) {
45
- const rows = (await command("ps", ["-axo", "pid=,ppid="], Math.max(1, Math.min(1000, deadline - Date.now()))))
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
- async function liveTree(root) {
60
- return (await Promise.all((await descendants(root)).map(processRef))).filter((r) => !!r);
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 a separate gap: once an intermediate process dies,
78
- * its children leave the parent chain entirely and no re-read will find them.
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)) ? remember(await liveTree(ref.pid)) : [];
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
- async write(data) {
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 file = await open(join(this.home, "launches.jsonl"), "a", 0o600);
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(), ...data }) + "\n");
90
+ await file.writeFile(JSON.stringify({ at: new Date().toISOString(), ...body }) + "\n");
13
91
  await file.sync();
14
92
  }
15
93
  finally {