@agentproto/runtime 2.12.0 → 3.1.0

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.
@@ -52,6 +52,43 @@ declare function loadAllowlist(workspace: string): Promise<Set<string>>;
52
52
  * Otherwise every matching entry is constrained, so `args` must match
53
53
  * one of them as a prefix. */
54
54
  declare function isCommandAllowed(entries: readonly AllowlistEntry[], baseName: string, args: readonly string[]): boolean;
55
+ /**
56
+ * How `terminal_start` is gated for a workspace:
57
+ * - `"allowlist"` — `argv[0]` must pass the same allowlist check
58
+ * `command_execute` applies. The shipped default.
59
+ * - `"all"` — no check. The legitimate setting for a trusted local
60
+ * workspace: this task exists so `all` becomes a deliberate
61
+ * DECISION, not an accident of which tool the caller happened to
62
+ * use.
63
+ * - `"off"` — `terminal_start` is refused outright, whatever the argv.
64
+ * NOTE: `off` means the door is CLOSED, not that the gate is
65
+ * disabled — the name reads both ways and the wrong reading is a
66
+ * security hole.
67
+ */
68
+ type TerminalGateMode = "allowlist" | "all" | "off";
69
+ /** Global-default env var: the fallback when a workspace's allowlist
70
+ * file carries no `terminalGate` field. */
71
+ declare const TERMINAL_GATE_ENV = "AGENTPROTO_TERMINAL_GATE";
72
+ /** Shipped default: gate `terminal_start` through the allowlist, same as
73
+ * `command_execute`. */
74
+ declare const DEFAULT_TERMINAL_GATE: TerminalGateMode;
75
+ /**
76
+ * Resolve the terminal gate mode for `workspace`, highest priority first:
77
+ * the workspace's own `terminalGate` field in
78
+ * `<workspace>/.agentproto/allowed-commands.json`, then the global
79
+ * default from `AGENTPROTO_TERMINAL_GATE`, then
80
+ * `DEFAULT_TERMINAL_GATE`.
81
+ *
82
+ * NOTE the precedence is the OPPOSITE of `loadSandboxConfig`'s
83
+ * env-as-escape-hatch: here the env var is the GLOBAL DEFAULT, so a
84
+ * workspace's explicit setting deliberately WINS over it. Don't "fix"
85
+ * that — a workspace must be able to pin its own posture.
86
+ *
87
+ * An unrecognised value in either place resolves to
88
+ * `DEFAULT_TERMINAL_GATE` and is never treated as `all` — fail toward
89
+ * the gate, not away from it.
90
+ */
91
+ declare function loadTerminalGateMode(workspace: string): Promise<TerminalGateMode>;
55
92
  /**
56
93
  * Command basenames that execute arbitrary code and read the filesystem
57
94
  * unrestricted. Allowlisting one grants a caller full host code execution +
@@ -67,4 +104,4 @@ declare function isInterpreterBasename(name: string): boolean;
67
104
  /** Human-readable warning for allowlisting/running an interpreter. */
68
105
  declare function interpreterExecWarning(baseName: string): string;
69
106
 
70
- export { ALLOWLIST_REL, type AllowlistEntry, INTERPRETER_BASENAMES, interpreterExecWarning, isCommandAllowed, isInterpreterBasename, loadAllowlist, loadAllowlistEntries };
107
+ export { ALLOWLIST_REL, type AllowlistEntry, DEFAULT_TERMINAL_GATE, INTERPRETER_BASENAMES, TERMINAL_GATE_ENV, type TerminalGateMode, interpreterExecWarning, isCommandAllowed, isInterpreterBasename, loadAllowlist, loadAllowlistEntries, loadTerminalGateMode };
@@ -64,6 +64,30 @@ function isCommandAllowed(entries, baseName, args) {
64
64
  if (matching.some((e) => e.args === void 0)) return true;
65
65
  return matching.some((e) => argsMatchPrefix(e.args, args));
66
66
  }
67
+ var TERMINAL_GATE_ENV = "AGENTPROTO_TERMINAL_GATE";
68
+ var DEFAULT_TERMINAL_GATE = "allowlist";
69
+ function parseTerminalGateMode(raw) {
70
+ return raw === "allowlist" || raw === "all" || raw === "off" ? raw : void 0;
71
+ }
72
+ async function loadTerminalGateMode(workspace) {
73
+ const path = resolve(workspace, ALLOWLIST_REL);
74
+ let fromWorkspace;
75
+ if (existsSync(path)) {
76
+ try {
77
+ const raw = await readFile(path, "utf8");
78
+ fromWorkspace = parseTerminalGateMode(
79
+ JSON.parse(raw).terminalGate
80
+ );
81
+ } catch (err) {
82
+ console.error(
83
+ `[command-allowlist] failed to read terminalGate from ${ALLOWLIST_REL} (will use default):`,
84
+ err
85
+ );
86
+ }
87
+ }
88
+ const fromEnv = parseTerminalGateMode(process.env[TERMINAL_GATE_ENV]);
89
+ return fromWorkspace ?? fromEnv ?? DEFAULT_TERMINAL_GATE;
90
+ }
67
91
  var INTERPRETER_BASENAMES = /* @__PURE__ */ new Set([
68
92
  "bash",
69
93
  "sh",
@@ -99,6 +123,6 @@ function interpreterExecWarning(baseName) {
99
123
  return `command_execute ran the interpreter '${baseName}', which executes arbitrary code and can read files outside the workspace \u2014 the cwd anchor does not confine it. Allowlisting interpreters grants full host code execution; prefer allowlisting specific tools. An OS-level command sandbox is planned as the real confinement.`;
100
124
  }
101
125
 
102
- export { ALLOWLIST_REL, INTERPRETER_BASENAMES, interpreterExecWarning, isCommandAllowed, isInterpreterBasename, loadAllowlist, loadAllowlistEntries };
126
+ export { ALLOWLIST_REL, DEFAULT_TERMINAL_GATE, INTERPRETER_BASENAMES, TERMINAL_GATE_ENV, interpreterExecWarning, isCommandAllowed, isInterpreterBasename, loadAllowlist, loadAllowlistEntries, loadTerminalGateMode };
103
127
  //# sourceMappingURL=command-allowlist.mjs.map
104
128
  //# sourceMappingURL=command-allowlist.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/command-allowlist.ts"],"names":["command"],"mappings":"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","file":"command-allowlist.mjs","sourcesContent":["/**\n * Pure, dependency-light workspace command-allowlist logic, shared by the\n * daemon's `command_execute` MCP tool (`command-tools.ts`) and any other\n * host that needs to gate shell execution against the same\n * `<workspace>/.agentproto/allowed-commands.json` file — e.g. the\n * `mastra-agent` adapter's own `command_execute` workspace tool, which runs\n * out-of-process from the daemon and has no other way to reach this logic.\n *\n * Kept free of daemon-only concerns (session recording, PR provenance,\n * OS-level sandboxing) so it stays cheap to import from a spawned child\n * process — no `SessionsRegistry`, no `@agentproto/command-sandbox`.\n *\n * Allowlist file shape — each entry in `commands` is either a plain\n * basename string (unconstrained args) or an object constraining the argv\n * prefix:\n * {\n * \"version\": 1,\n * \"commands\": [\n * \"claude\",\n * \"node\",\n * { \"command\": \"git\", \"args\": [\"status\"] }\n * ]\n * }\n *\n * Match is by command BASENAME. A plain string entry allows that basename\n * with ANY args. An object entry with `args` only allows invocations whose\n * argv starts with exactly that token sequence (a prefix match). When a\n * basename has BOTH a plain entry and constrained entries, the plain entry\n * wins.\n */\n\nimport { existsSync } from \"node:fs\"\nimport { readFile, stat } from \"node:fs/promises\"\nimport { resolve } from \"node:path\"\n\nexport const ALLOWLIST_REL = \".agentproto/allowed-commands.json\"\n\n/** One normalized allowlist entry — a basename with an optional argv-prefix\n * constraint. `args` absent ⇒ unconstrained (any args), matching a plain\n * basename-string entry in the JSON file. */\nexport interface AllowlistEntry {\n command: string\n args?: string[]\n}\n\ninterface AllowlistFile {\n version?: number\n commands?: Array<string | { command?: unknown; args?: unknown }>\n}\n\ninterface AllowlistCacheEntry {\n mtimeMs: number\n entries: AllowlistEntry[]\n}\n\nlet allowlistCache: { path: string; entry: AllowlistCacheEntry } | null = null\n\nfunction normalizeAllowlistEntry(\n raw: string | { command?: unknown; args?: unknown },\n): AllowlistEntry | undefined {\n if (typeof raw === \"string\") {\n const command = raw.trim()\n return command.length > 0 ? { command } : undefined\n }\n if (typeof raw.command !== \"string\" || raw.command.trim().length === 0) {\n return undefined\n }\n const command = raw.command.trim()\n if (raw.args === undefined) return { command }\n if (!Array.isArray(raw.args) || !raw.args.every(a => typeof a === \"string\")) {\n // Malformed `args` (not a string array) — drop the constraint rather\n // than silently allowlisting an unintended shape; the operator gets a\n // basename-only entry, which is at least not MORE permissive than\n // the array they wrote.\n return undefined\n }\n return { command, args: [...raw.args] }\n}\n\n/** Load the workspace's allowlist as normalized entries (basename +\n * optional argv-prefix constraint). Cheap stat-cached, same as\n * `loadAllowlist`; both share one cache keyed on the file's mtime. */\nexport async function loadAllowlistEntries(\n workspace: string,\n): Promise<AllowlistEntry[]> {\n const path = resolve(workspace, ALLOWLIST_REL)\n if (!existsSync(path)) {\n allowlistCache = null\n return []\n }\n try {\n const s = await stat(path)\n if (\n allowlistCache &&\n allowlistCache.path === path &&\n allowlistCache.entry.mtimeMs === s.mtimeMs\n ) {\n return allowlistCache.entry.entries\n }\n const raw = await readFile(path, \"utf8\")\n const parsed = JSON.parse(raw) as AllowlistFile\n const list = Array.isArray(parsed.commands) ? parsed.commands : []\n const entries = list\n .map(normalizeAllowlistEntry)\n .filter((e): e is AllowlistEntry => e !== undefined)\n allowlistCache = { path, entry: { mtimeMs: s.mtimeMs, entries } }\n return entries\n } catch (err) {\n // Bad JSON / unreadable file ⇒ deny all and surface in the error\n // the next caller gets. Don't poison the cache.\n console.error(\n `[command-allowlist] failed to load ${ALLOWLIST_REL} (will deny all):`,\n err,\n )\n allowlistCache = null\n return []\n }\n}\n\n/** Basename-only view of the allowlist — every basename that has AT LEAST\n * ONE entry, constrained or not. Existing callers (cron-scheduler.ts,\n * task-ledger.ts, supervisor.ts) gate on basename alone, same as before\n * this change; only `command_execute` itself enforces argv constraints\n * (see `isCommandAllowed`). */\nexport async function loadAllowlist(workspace: string): Promise<Set<string>> {\n const entries = await loadAllowlistEntries(workspace)\n return new Set(entries.map(e => e.command))\n}\n\n/** True when `pattern` (an allowed argv prefix) matches the start of\n * `actual` token-for-token. An empty `pattern` matches anything. */\nfunction argsMatchPrefix(pattern: readonly string[], actual: readonly string[]): boolean {\n if (pattern.length > actual.length) return false\n return pattern.every((tok, i) => actual[i] === tok)\n}\n\n/** Argv-aware allowlist check used by `command_execute`. A basename with\n * no matching entry ⇒ denied. A basename with any unconstrained entry\n * (plain string, or object with no `args`) ⇒ allowed regardless of args.\n * Otherwise every matching entry is constrained, so `args` must match\n * one of them as a prefix. */\nexport function isCommandAllowed(\n entries: readonly AllowlistEntry[],\n baseName: string,\n args: readonly string[],\n): boolean {\n const matching = entries.filter(e => e.command === baseName)\n if (matching.length === 0) return false\n if (matching.some(e => e.args === undefined)) return true\n return matching.some(e => argsMatchPrefix(e.args!, args))\n}\n\n/**\n * Command basenames that execute arbitrary code and read the filesystem\n * unrestricted. Allowlisting one grants a caller full host code execution +\n * FS read — the workspace cwd-anchor bounds only the working directory, not\n * what the interpreter itself opens. Used to surface a one-time warning\n * (see `isInterpreterBasename` / `interpreterExecWarning`) until an OS-level\n * command sandbox lands.\n */\nexport const INTERPRETER_BASENAMES: ReadonlySet<string> = new Set([\n \"bash\", \"sh\", \"zsh\", \"dash\", \"ksh\", \"fish\",\n \"node\", \"deno\", \"bun\", \"tsx\", \"ts-node\",\n \"python\", \"python2\", \"python3\", \"ruby\", \"perl\", \"php\", \"rscript\", \"osascript\",\n \"env\", \"xargs\", \"make\", \"npx\", \"uv\", \"uvx\", \"pipx\",\n])\n\n/** True when `name` (a command basename) is a code interpreter. Case-insensitive\n * so `Rscript`/`RSCRIPT` match. */\nexport function isInterpreterBasename(name: string): boolean {\n return INTERPRETER_BASENAMES.has(name.toLowerCase())\n}\n\n/** Human-readable warning for allowlisting/running an interpreter. */\nexport function interpreterExecWarning(baseName: string): string {\n return (\n `command_execute ran the interpreter '${baseName}', which executes ` +\n `arbitrary code and can read files outside the workspace — the cwd anchor ` +\n `does not confine it. Allowlisting interpreters grants full host code ` +\n `execution; prefer allowlisting specific tools. An OS-level command ` +\n `sandbox is planned as the real confinement.`\n )\n}\n"]}
1
+ {"version":3,"sources":["../src/command-allowlist.ts"],"names":["command"],"mappings":"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","file":"command-allowlist.mjs","sourcesContent":["/**\n * Pure, dependency-light workspace command-allowlist logic, shared by the\n * daemon's `command_execute` MCP tool (`command-tools.ts`) and any other\n * host that needs to gate shell execution against the same\n * `<workspace>/.agentproto/allowed-commands.json` file — e.g. the\n * `mastra-agent` adapter's own `command_execute` workspace tool, which runs\n * out-of-process from the daemon and has no other way to reach this logic.\n *\n * Kept free of daemon-only concerns (session recording, PR provenance,\n * OS-level sandboxing) so it stays cheap to import from a spawned child\n * process — no `SessionsRegistry`, no `@agentproto/command-sandbox`.\n *\n * Allowlist file shape — each entry in `commands` is either a plain\n * basename string (unconstrained args) or an object constraining the argv\n * prefix:\n * {\n * \"version\": 1,\n * \"commands\": [\n * \"claude\",\n * \"node\",\n * { \"command\": \"git\", \"args\": [\"status\"] }\n * ]\n * }\n *\n * Match is by command BASENAME. A plain string entry allows that basename\n * with ANY args. An object entry with `args` only allows invocations whose\n * argv starts with exactly that token sequence (a prefix match). When a\n * basename has BOTH a plain entry and constrained entries, the plain entry\n * wins.\n */\n\nimport { existsSync } from \"node:fs\"\nimport { readFile, stat } from \"node:fs/promises\"\nimport { resolve } from \"node:path\"\n\nexport const ALLOWLIST_REL = \".agentproto/allowed-commands.json\"\n\n/** One normalized allowlist entry — a basename with an optional argv-prefix\n * constraint. `args` absent ⇒ unconstrained (any args), matching a plain\n * basename-string entry in the JSON file. */\nexport interface AllowlistEntry {\n command: string\n args?: string[]\n}\n\ninterface AllowlistFile {\n version?: number\n commands?: Array<string | { command?: unknown; args?: unknown }>\n /** Optional top-level terminal gate mode — governs `terminal_start`\n * (see `TerminalGateMode` / `loadTerminalGateMode`). Same file as the\n * `commands` allowlist on purpose: one place to reason about gating. */\n terminalGate?: unknown\n}\n\ninterface AllowlistCacheEntry {\n mtimeMs: number\n entries: AllowlistEntry[]\n}\n\nlet allowlistCache: { path: string; entry: AllowlistCacheEntry } | null = null\n\nfunction normalizeAllowlistEntry(\n raw: string | { command?: unknown; args?: unknown },\n): AllowlistEntry | undefined {\n if (typeof raw === \"string\") {\n const command = raw.trim()\n return command.length > 0 ? { command } : undefined\n }\n if (typeof raw.command !== \"string\" || raw.command.trim().length === 0) {\n return undefined\n }\n const command = raw.command.trim()\n if (raw.args === undefined) return { command }\n if (!Array.isArray(raw.args) || !raw.args.every(a => typeof a === \"string\")) {\n // Malformed `args` (not a string array) — drop the constraint rather\n // than silently allowlisting an unintended shape; the operator gets a\n // basename-only entry, which is at least not MORE permissive than\n // the array they wrote.\n return undefined\n }\n return { command, args: [...raw.args] }\n}\n\n/** Load the workspace's allowlist as normalized entries (basename +\n * optional argv-prefix constraint). Cheap stat-cached, same as\n * `loadAllowlist`; both share one cache keyed on the file's mtime. */\nexport async function loadAllowlistEntries(\n workspace: string,\n): Promise<AllowlistEntry[]> {\n const path = resolve(workspace, ALLOWLIST_REL)\n if (!existsSync(path)) {\n allowlistCache = null\n return []\n }\n try {\n const s = await stat(path)\n if (\n allowlistCache &&\n allowlistCache.path === path &&\n allowlistCache.entry.mtimeMs === s.mtimeMs\n ) {\n return allowlistCache.entry.entries\n }\n const raw = await readFile(path, \"utf8\")\n const parsed = JSON.parse(raw) as AllowlistFile\n const list = Array.isArray(parsed.commands) ? parsed.commands : []\n const entries = list\n .map(normalizeAllowlistEntry)\n .filter((e): e is AllowlistEntry => e !== undefined)\n allowlistCache = { path, entry: { mtimeMs: s.mtimeMs, entries } }\n return entries\n } catch (err) {\n // Bad JSON / unreadable file ⇒ deny all and surface in the error\n // the next caller gets. Don't poison the cache.\n console.error(\n `[command-allowlist] failed to load ${ALLOWLIST_REL} (will deny all):`,\n err,\n )\n allowlistCache = null\n return []\n }\n}\n\n/** Basename-only view of the allowlist — every basename that has AT LEAST\n * ONE entry, constrained or not. Existing callers (cron-scheduler.ts,\n * task-ledger.ts, supervisor.ts) gate on basename alone, same as before\n * this change; only `command_execute` itself enforces argv constraints\n * (see `isCommandAllowed`). */\nexport async function loadAllowlist(workspace: string): Promise<Set<string>> {\n const entries = await loadAllowlistEntries(workspace)\n return new Set(entries.map(e => e.command))\n}\n\n/** True when `pattern` (an allowed argv prefix) matches the start of\n * `actual` token-for-token. An empty `pattern` matches anything. */\nfunction argsMatchPrefix(pattern: readonly string[], actual: readonly string[]): boolean {\n if (pattern.length > actual.length) return false\n return pattern.every((tok, i) => actual[i] === tok)\n}\n\n/** Argv-aware allowlist check used by `command_execute`. A basename with\n * no matching entry ⇒ denied. A basename with any unconstrained entry\n * (plain string, or object with no `args`) ⇒ allowed regardless of args.\n * Otherwise every matching entry is constrained, so `args` must match\n * one of them as a prefix. */\nexport function isCommandAllowed(\n entries: readonly AllowlistEntry[],\n baseName: string,\n args: readonly string[],\n): boolean {\n const matching = entries.filter(e => e.command === baseName)\n if (matching.length === 0) return false\n if (matching.some(e => e.args === undefined)) return true\n return matching.some(e => argsMatchPrefix(e.args!, args))\n}\n\n// ── terminal gate mode ─────────────────────────────────────────────\n// `command_execute` refuses any command outside the workspace allowlist,\n// but `terminal_start` spawns arbitrary argv under a PTY with no check —\n// the gate is decorative while that door is open. A three-valued mode\n// closes it: `terminal_start`'s argv[0] must pass the SAME allowlist\n// check `command_execute` applies, unless the operator deliberately\n// opens it (or closes it entirely).\n\n/**\n * How `terminal_start` is gated for a workspace:\n * - `\"allowlist\"` — `argv[0]` must pass the same allowlist check\n * `command_execute` applies. The shipped default.\n * - `\"all\"` — no check. The legitimate setting for a trusted local\n * workspace: this task exists so `all` becomes a deliberate\n * DECISION, not an accident of which tool the caller happened to\n * use.\n * - `\"off\"` — `terminal_start` is refused outright, whatever the argv.\n * NOTE: `off` means the door is CLOSED, not that the gate is\n * disabled — the name reads both ways and the wrong reading is a\n * security hole.\n */\nexport type TerminalGateMode = \"allowlist\" | \"all\" | \"off\"\n\n/** Global-default env var: the fallback when a workspace's allowlist\n * file carries no `terminalGate` field. */\nexport const TERMINAL_GATE_ENV = \"AGENTPROTO_TERMINAL_GATE\"\n\n/** Shipped default: gate `terminal_start` through the allowlist, same as\n * `command_execute`. */\nexport const DEFAULT_TERMINAL_GATE: TerminalGateMode = \"allowlist\"\n\nfunction parseTerminalGateMode(raw: unknown): TerminalGateMode | undefined {\n return raw === \"allowlist\" || raw === \"all\" || raw === \"off\" ? raw : undefined\n}\n\n/**\n * Resolve the terminal gate mode for `workspace`, highest priority first:\n * the workspace's own `terminalGate` field in\n * `<workspace>/.agentproto/allowed-commands.json`, then the global\n * default from `AGENTPROTO_TERMINAL_GATE`, then\n * `DEFAULT_TERMINAL_GATE`.\n *\n * NOTE the precedence is the OPPOSITE of `loadSandboxConfig`'s\n * env-as-escape-hatch: here the env var is the GLOBAL DEFAULT, so a\n * workspace's explicit setting deliberately WINS over it. Don't \"fix\"\n * that — a workspace must be able to pin its own posture.\n *\n * An unrecognised value in either place resolves to\n * `DEFAULT_TERMINAL_GATE` and is never treated as `all` — fail toward\n * the gate, not away from it.\n */\nexport async function loadTerminalGateMode(\n workspace: string,\n): Promise<TerminalGateMode> {\n const path = resolve(workspace, ALLOWLIST_REL)\n let fromWorkspace: TerminalGateMode | undefined\n if (existsSync(path)) {\n try {\n const raw = await readFile(path, \"utf8\")\n fromWorkspace = parseTerminalGateMode(\n (JSON.parse(raw) as AllowlistFile).terminalGate,\n )\n } catch (err) {\n // Bad JSON / unreadable file — the allowlist loader denies all on\n // the same condition; fail toward the gate here too.\n console.error(\n `[command-allowlist] failed to read terminalGate from ${ALLOWLIST_REL} (will use default):`,\n err,\n )\n }\n }\n const fromEnv = parseTerminalGateMode(process.env[TERMINAL_GATE_ENV])\n return fromWorkspace ?? fromEnv ?? DEFAULT_TERMINAL_GATE\n}\n\n/**\n * Command basenames that execute arbitrary code and read the filesystem\n * unrestricted. Allowlisting one grants a caller full host code execution +\n * FS read — the workspace cwd-anchor bounds only the working directory, not\n * what the interpreter itself opens. Used to surface a one-time warning\n * (see `isInterpreterBasename` / `interpreterExecWarning`) until an OS-level\n * command sandbox lands.\n */\nexport const INTERPRETER_BASENAMES: ReadonlySet<string> = new Set([\n \"bash\", \"sh\", \"zsh\", \"dash\", \"ksh\", \"fish\",\n \"node\", \"deno\", \"bun\", \"tsx\", \"ts-node\",\n \"python\", \"python2\", \"python3\", \"ruby\", \"perl\", \"php\", \"rscript\", \"osascript\",\n \"env\", \"xargs\", \"make\", \"npx\", \"uv\", \"uvx\", \"pipx\",\n])\n\n/** True when `name` (a command basename) is a code interpreter. Case-insensitive\n * so `Rscript`/`RSCRIPT` match. */\nexport function isInterpreterBasename(name: string): boolean {\n return INTERPRETER_BASENAMES.has(name.toLowerCase())\n}\n\n/** Human-readable warning for allowlisting/running an interpreter. */\nexport function interpreterExecWarning(baseName: string): string {\n return (\n `command_execute ran the interpreter '${baseName}', which executes ` +\n `arbitrary code and can read files outside the workspace — the cwd anchor ` +\n `does not confine it. Allowlisting interpreters grants full host code ` +\n `execution; prefer allowlisting specific tools. An OS-level command ` +\n `sandbox is planned as the real confinement.`\n )\n}\n"]}
package/dist/config.d.ts CHANGED
@@ -305,6 +305,19 @@ interface SessionsConfig {
305
305
  * default `60`. Absent ⇒ 60s.
306
306
  */
307
307
  attentionDelaySec?: number;
308
+ /**
309
+ * Root directory the daemon stores per-session transcripts under — each
310
+ * session's `events.jsonl` lands at `<eventsDir>/<sessionId>/events.jsonl`
311
+ * (and a terminal session's `terminal.jsonl` alongside it). Every
312
+ * reader/writer (transcript writer, `/sessions/:id/events` HTTP routes,
313
+ * exports, tool-call/usage logs) resolves through this single knob.
314
+ * Resolution order mirrors every other knob in this file: relative paths
315
+ * resolve against the home directory. Absent ⇒ the hardcoded default
316
+ * `~/.agentproto/sessions` — zero behaviour change out of the box. A
317
+ * change requires a daemon restart to take effect (and existing sessions'
318
+ * transcripts do NOT move).
319
+ */
320
+ eventsDir?: string;
308
321
  }
309
322
  /**
310
323
  * Policy for the daemon's own AGENTS.md resolution + injection (WP-R2) —
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts"],"names":["fs"],"mappings":"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","file":"config.mjs","sourcesContent":["/**\n * `~/.agentproto/config.json` — single hand-editable JSON for the\n * agentproto control plane's defaults. Sits alongside the existing\n * surface files (workspaces.json, credentials.json, sessions.json):\n *\n * workspaces.json which directories are workspaces + which is active\n * credentials.json tunnel host bearer tokens (mode 0600)\n * sessions.json last-known snapshot of the registry (informational)\n * config.json daemon defaults: port, bind, allowed origins,\n * tunnel host, feature toggles\n *\n * Resolution order for every daemon knob is:\n * 1. CLI flag (e.g. --port)\n * 2. Env var (where one exists, e.g. AGENTPROTO_TOKEN)\n * 3. config.json\n * 4. Hardcoded default\n *\n * This means a user can call `agentproto config set daemon.port 18791`\n * once and never re-pass `--port 18791` to `serve install` etc. CLI\n * flags still win for one-off overrides.\n *\n * Schema is intentionally narrow + extensible — unknown keys are\n * preserved on save (deep-merge), so a newer CLI writing a new\n * field won't drop one an older CLI doesn't know about. No secrets\n * here; credentials stay in credentials.json (mode 0600).\n */\n\nimport { promises as fs } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { dirname, join } from \"node:path\"\nimport type { SpawnDefaultsConfig } from \"./spawn-defaults.js\"\n\nexport const CONFIG_VERSION = 1 as const\n\nexport interface DaemonConfig {\n /** Absolute path to the workspace the daemon binds to at boot. */\n workspace?: string\n /** HTTP port. Default 18790. */\n port?: number\n /** Bind addr. Default 127.0.0.1. */\n bind?: string\n /** Trusted browser origins for mutating /sessions/* routes (in\n * addition to the hardcoded localhost defaults). */\n allowedOrigins?: string[]\n /** When true, the daemon does NOT auto-trust localhost-on-any-port.\n * Only origins explicitly listed in `allowedOrigins` are allowed.\n * Pair with a curated list (e.g. `[\"http://localhost:3000\"]`) for\n * hardened setups. Default false. */\n strictOrigins?: boolean\n /** Server label sent in tunnel hello frames. */\n label?: string\n /** Bearer token gating the gateway at boot (`AuthOptions` with\n * `mode: \"bearer\"`). Unlike `remote_enable`'s ephemeral quick-tunnel\n * token, this one lives in config.json and survives daemon restarts.\n * Set via `agentproto config set daemon.authToken <token>` (e.g.\n * `$(openssl rand -hex 32)`). Unset ⇒ the gateway boots with\n * `mode: \"none\"` — fully open on loopback, same as today. */\n authToken?: string\n /** Opt-in eager resume-on-boot (session-survivability plan §5\n * \"Opt-in vs automatic\", PR-4). When true, a daemon restart doesn't just\n * leave agent-cli sessions dead-but-lazy-resumable — the boot pass eagerly\n * re-spawns the eligible ones (those that died with\n * `endedReason: \"daemon-restart\"` and still pass `canResume`) IN PLACE,\n * restoring liveness without waiting for a prompt. Lazy resume-on-prompt is\n * always on regardless of this flag (it costs nothing until someone acts);\n * this flag only controls the *automatic* boot-time pass. Default false: a\n * box-wide restart should not silently relaunch every adapter (cost, spawn\n * storm, credential re-resolution), and default-off also keeps two daemons\n * sharing the workspace buckets from racing to resume the same rows. Set via\n * `agentproto config set daemon.resumeSessionsOnBoot true`. Surfaced in\n * `daemon_health` / `GET /health`. */\n resumeSessionsOnBoot?: boolean\n /** Idle agent-session reaper (PR-6). When set to a positive number of\n * milliseconds, the daemon periodically retires agent-cli sessions that have\n * been idle (not busy, not awaiting input, `status:\"running\"`) longer than\n * this: it SIGTERMs the adapter child to free the process and flips the row\n * to `killed`/`endedReason:\"idle-reaped\"` — dead-but-lazy-resumable (a later\n * prompt revives it) and, critically, excluded from eager resume-on-boot\n * (#638 gates on `endedReason === \"daemon-restart\"`), so enabling this makes\n * eager resume safe: only genuinely-recent work survives a restart. Default\n * OFF (unset / 0 / negative ⇒ the reaper never runs). Resolution order\n * mirrors the module docblock: `AGENTPROTO_IDLE_REAP_AFTER_MS` env > this\n * field > off. Set via `agentproto config set daemon.idleReapAfterMs\n * <ms>`. Surfaced in `daemon_health` / `GET /health`. */\n idleReapAfterMs?: number\n /** Crash-detect sweep interval in ms (crash-detect PR-1). Unlike\n * `idleReapAfterMs`, this is DEFAULT ON (non-destructive observability):\n * the daemon periodically probes every live agent-cli session's OS\n * process (`process.kill(pid, 0)`) and, when it's provably gone with no\n * exit event ever emitted for it, flips the row to\n * `error`/`endedReason:\"crashed\"` with a `lastError` string — surfacing a\n * death that would otherwise sit silently as `status:\"running\"` until the\n * next prompt's RPC throws (or forever, for a parked session with no next\n * prompt). Detects only; never restarts or notifies (later PRs). Set to a\n * non-positive value to disable the sweep entirely. Resolution order\n * mirrors `idleReapAfterMs`: `AGENTPROTO_CRASH_DETECT_INTERVAL_MS` env >\n * this field > the hardcoded default (30s). Set via `agentproto config\n * set daemon.crashDetectIntervalMs <ms>`. Surfaced in `daemon_health` /\n * `GET /health`. */\n crashDetectIntervalMs?: number\n /** Restart-scheduler sweep interval in ms (restart-scheduler PR-2). OFF by\n * default (unset / 0 / negative ⇒ the sweep never runs), unlike\n * `crashDetectIntervalMs` — a positive value here only arms the periodic\n * sweep that EXECUTES an already-scheduled restart; it never opts a\n * session in by itself (that's the per-session `agent_start.restartPolicy`\n * field). Resolution order mirrors `idleReapAfterMs`:\n * `AGENTPROTO_RESTART_SWEEP_INTERVAL_MS` env > this field > off. Set via\n * `agentproto config set daemon.restartSweepIntervalMs <ms>`. Surfaced in\n * `daemon_health` / `GET /health`. */\n restartSweepIntervalMs?: number\n /** Turn-liveness watchdog threshold in ms (turn-liveness-watchdog\n * chantier). Same shape as `crashDetectIntervalMs`: DEFAULT ON\n * (non-destructive observability). The daemon periodically sweeps every\n * BUSY agent-cli session and, for one that is mid-turn, NOT legitimately\n * `blockedOn` a subagent/command, and has had no adapter activity\n * (`lastActivityAt`) for longer than this threshold, stamps\n * `stalledSinceMs` on the descriptor and emits `session:stalled` —\n * surfacing a dead adapter stream (network drop, hung child — zero\n * frames mid-turn) that would otherwise sit indistinguishable from\n * healthy long work: `status:\"running\"`, `lastError:null`, no other\n * signal. Conservative by design: a session legitimately blocked on a\n * long tool call is NEVER a candidate, no matter how long it's silent —\n * see `stall-watchdog.ts`'s docblock for why. Detects only; never kills\n * or restarts (this is a later concern, if ever). Set to a non-positive\n * value to disable the sweep entirely. Resolution order mirrors\n * `idleReapAfterMs`: `AGENTPROTO_TURN_STALL_AFTER_MS` env > this field >\n * the hardcoded default (5 min). Set via `agentproto config set\n * daemon.turnStallAfterMs <ms>`. Surfaced in `daemon_health` /\n * `GET /health`. */\n turnStallAfterMs?: number\n}\n\nexport interface TunnelConfig {\n /** Cloud WS URL. When set + autoconnect=true, `agentproto serve`\n * bootstraps with `--connect <host>`. */\n host?: string\n /** apt_ daemon token to present at the tunnel upgrade. When set,\n * `agentproto serve` uses this BEFORE falling back to\n * credentials.json — handy in profiles where the token-per-host\n * mapping in credentials.json doesn't fit (e.g. host = tunnel URL\n * but credentials were minted against the api URL). */\n token?: string\n /** Whether `agentproto daemon start` connects the tunnel by\n * default. v0 only — implementer can ignore until daemon needs it. */\n autoconnect?: boolean\n /**\n * Opt into end-to-end encryption of the outbound `serve --connect` tunnel\n * (design: tunnel-e2e/v1). When true, the daemon negotiates a\n * token-authenticated ephemeral handshake with the host and wraps the tunnel\n * frames in an AEAD box, so even the trusted host loses plaintext visibility.\n * The handshake authenticates both ends against the shared `tunnel.token`, so\n * `token` MUST also be set. Fully backward-compatible: if the host doesn't\n * advertise e2e (an older host), the daemon falls back to today's plaintext\n * tunnel. Unset/false ⇒ plaintext, byte-identical to today. */\n e2e?: boolean\n}\n\nexport interface FeaturesConfig {\n /** Hint that PTY is desired — informational; the daemon still\n * detects node-pty's presence at runtime. */\n pty?: boolean\n /** Enable the local LLM Endpoint proxy sidecar (route registration,\n * MCP tools, child-process lifecycle). Default false — the endpoint is\n * an opt-in feature; when off, the `llm-endpoint` custom route is not\n * registered and the `llm_endpoint_*` MCP tools are not exposed. */\n llmEndpoint?: boolean\n}\n\n/**\n * Policy for `agentproto worktree new` (PLAN.md §1.4 — config carries\n * policy, never state; git itself is the authority for which worktrees\n * exist). This is the fix for the sprawl the plan measured: 31 linked\n * worktrees across 6 different parent directories, because there was no\n * `worktree new` verb and therefore no convention to converge on.\n */\n/**\n * How the daemon isolates a freshly-spawned agent session into its own git\n * worktree (`agent_start.worktree`):\n * - `\"always\"` — every depth-0 spawn is provisioned into a worktree,\n * whether or not the caller asked. A cwd that is not in\n * a git repo has nothing to isolate, so it spawns plain.\n * - `\"on-request\"` — isolate ONLY when the caller passes `worktree`. This\n * is the default and the back-compatible behaviour:\n * today's callers pass nothing and spawn exactly where\n * they asked.\n * - `\"never\"` — isolation is off; an explicit `worktree` field is\n * REJECTED (loud, not silently ignored) so a caller\n * never believes it got an isolated tree it didn't.\n */\nexport type WorktreeIsolationMode = \"always\" | \"on-request\" | \"never\"\n\nexport interface WorktreesConfig {\n /**\n * Absolute path new worktrees are created under. Layout:\n * `<root>/<repoName>/<slug>`. Resolution order (mirrors every other\n * knob in this file, see the module docblock): `--root` flag >\n * `AGENTPROTO_WORKTREES_ROOT` env > this field > the hardcoded default\n * `~/.agentproto/worktrees`. The default is a real single root, not\n * \"unconfigured\" — `worktree new` converges to one place with zero\n * setup, which is the only way the sprawl actually stops (the 6 roots\n * that exist today are 6 people each inventing a default by hand).\n */\n root?: string\n /**\n * Policy for `agent_start.worktree` isolation. Resolution order mirrors\n * the module docblock (there is no CLI flag — this is a daemon-side\n * policy read at spawn, not a per-invocation flag):\n * `AGENTPROTO_WORKTREES_ISOLATION` env > this field > the hardcoded\n * default `\"on-request\"`. `\"on-request\"` is deliberately the default:\n * any other would break back-compat by isolating callers that never\n * asked (see `worktree-isolation.ts`).\n */\n isolation?: WorktreeIsolationMode\n}\n\n/**\n * Policy for `agent_start.attach` — whether a spawn AUTO-attaches to its\n * calling session as parent lineage. `\"always\"` (the default) nests a\n * spawned child under the session that spawned it whenever that identity is\n * derivable (the trusted `?callerSessionId=` on the daemon self-ref URL, or\n * an explicit `parentSessionId` hint), so a supervisor's executors stop\n * landing as depth-0 orphans. `\"on-request\"` reverts to the pre-attach\n * behaviour: auto-attribution is off, and a child nests only when the caller\n * explicitly opts in (`attach: true` / `attach: { parent }` / an explicit\n * `parentSessionId`). Either way a per-call `attach: false` forces an\n * independent root, exactly as `worktree: false` opts out of isolation. See\n * `spawn-attach.ts`.\n */\nexport type SpawnAttachMode = \"always\" | \"on-request\"\n\n/**\n * Policy for `agent_start`'s IMPLICIT dedupe — what happens when a caller\n * spawns with NO `idempotencyKey` at all (see `spawn-dedupe.ts`):\n * - `\"always\"` — the daemon DERIVES an implicit key from the spawn's\n * `label` (required — see below) plus a hash of the\n * initial `prompt`, and dedupes a same-adapter/cwd\n * repeat against it exactly as an explicit key would.\n * This is the default, mirroring `attach`'s own\n * \"opt-in-only guard is not a guard\" precedent above.\n * Deriving needs a `label` to produce anything at all\n * — an unlabelled spawn is untouched, which is what\n * keeps this safe for the fan-out pattern this repo\n * exercises (several agents into one cwd with no\n * shared label): see `spawn-dedupe.ts`'s docblock for\n * the full false-dedup analysis, and PR #803's own\n * no-opt-in label+cwd warning backstop in\n * `session-spawn.ts`, which independently landed on\n * the same label-is-the-signal boundary.\n * - `\"on-request\"` — no implicit derivation; only an explicit\n * `idempotencyKey` dedupes (today's behaviour,\n * unchanged). A per-call `dedupe: true` still opts in\n * under this policy, mirroring `attach: true`.\n * Either way a per-call `dedupe: false` disables implicit derivation for\n * that one spawn regardless of policy — the escape hatch, mirroring\n * `attach: false` / `worktree: false`. An explicit `idempotencyKey` always\n * wins over a derived one (derivation is only attempted when the caller\n * supplied none).\n */\nexport type SpawnDedupeMode = \"always\" | \"on-request\"\n\nexport interface SpawnConfig {\n /**\n * Attach policy for `agent_start`. Resolution order mirrors the module\n * docblock (no CLI flag — a daemon-side policy read at spawn):\n * `AGENTPROTO_SPAWN_ATTACH` env > this field > the hardcoded default\n * `\"always\"`. Unlike `worktrees.isolation` (whose default preserves\n * back-compat by NOT isolating), attach defaults ON: an orphaned executor\n * is a bug, not a feature, and the auto-parent is descriptor-only lineage\n * that never relaxes a privilege gate.\n */\n attach?: SpawnAttachMode\n /**\n * Implicit-dedupe policy for `agent_start`. Resolution order mirrors\n * `attach` above (no CLI flag — a daemon-side policy read at spawn):\n * `AGENTPROTO_SPAWN_DEDUPE` env > this field > the hardcoded default\n * `\"always\"`. See {@link SpawnDedupeMode} and `spawn-dedupe.ts` for the\n * full reasoning: a retry-safety guard that only works when a caller\n * remembers to ask for it (`idempotencyKey`) is not a guard — the same\n * argument `attach` already settled for parent lineage.\n */\n dedupe?: SpawnDedupeMode\n}\n\n/**\n * Provenance policy — the opt-in `gh` PATH shim (`gh-provenance-shim.ts`).\n * Off by default. When `wrapGh` is on, every agent session the daemon spawns\n * gets a shim directory prepended to its PATH so that any `gh pr create` the\n * session (or an adapter subprocess shelling out — claude-code, codex, …) runs\n * has a deterministic `@agentproto-bot` provenance footer appended to the\n * created PR's BODY, matching the footer the cloud runner stamps. The TOOL\n * stamps, never the model; commit messages are never touched (the repo's\n * hygiene-check forbids attribution there). Resolution order mirrors\n * `spawn.attach`: `AGENTPROTO_PROVENANCE_WRAP_GH` env > this field > default\n * `false`.\n */\nexport interface ProvenanceConfig {\n /** Enable the opt-in `gh` provenance PATH shim for spawned sessions. */\n wrapGh?: boolean\n}\n\n/**\n * Session-presence policy — how the dashboard triages a session into its\n * `running` / `tending` / `attention` / `quiet` presence state (see\n * `session-presence.ts`). Optional block on `AgentprotoConfig.sessions`.\n */\nexport interface SessionsConfig {\n /**\n * How long (seconds) a session stays shown as `running` after its last\n * turn ends, before it settles into `attention`/`quiet`. The grace window\n * is measured from the session's most recent `lastActivityAt` — ANY new\n * event resets it — so a session that just answered does not read as\n * \"quiet\" the instant its turn finishes, but something that went idle long\n * ago, with nothing waiting on it, settles grey. Resolution order mirrors\n * every other knob in this file (module docblock): env var\n * `AGENTPROTO_SESSIONS_ATTENTION_DELAY_SEC` > this field > the hardcoded\n * default `60`. Absent ⇒ 60s.\n */\n attentionDelaySec?: number\n}\n\n/**\n * Policy for the daemon's own AGENTS.md resolution + injection (WP-R2) —\n * how a spawned session's initial prompt carries the repo's `AGENTS.md`\n * (see `agents-md.ts`). The daemon resolves the nearest `AGENTS.md` walking\n * up from the session's `cwd`, bounded by that repo's git toplevel, and\n * injects one AGENTS.md block after the role disposition whenever the spawn\n * carries a prompt.\n */\nexport interface AgentsMdConfig {\n /**\n * Inline-vs-pointer threshold in KiB. A resolved AGENTS.md whose byte size\n * is STRICTLY LESS than `inlineMaxKb` KiB is inlined in full into the\n * spawn's initial prompt, clearly delimited; `>=` the threshold is injected\n * as a short pointer telling the agent to read the file before its first\n * tool call instead. An absent AGENTS.md (nothing found up the walk) injects\n * nothing but is still reported on the descriptor via\n * `SessionDescriptor.agentsMdMode === \"absent\"`. Resolution order mirrors\n * the module docblock (no CLI flag, and no env override — not part of the\n * approved design): this field > the hardcoded default `8` (KiB).\n */\n inlineMaxKb?: number\n}\n\nexport interface PairingConfig {\n /** Rendezvous broker WS URL (ws:// or wss://) used by `pair offer` and by\n * autoconnect on boot. When unset, `pair offer` requires an explicit\n * `--rendezvous`. Mirrors `tunnel.host`. */\n rendezvous?: string\n /** Whether the daemon opens standing rendezvous connections for every\n * persisted pairing on boot (so a paired client can reconnect anytime).\n * Mirrors `tunnel.autoconnect`. Default true when a rendezvous is set. */\n autoconnect?: boolean\n}\n\n/**\n * A user-defined generic ACP agent — the config-file half of\n * `AcpAgentSpec` (the slug is the record key in `acpAgents`, so it's\n * omitted here). Any CLI that already speaks the Agent Client Protocol\n * can be wired with zero code by declaring one of these under\n * `acpAgents.<slug>` in `~/.agentproto/config.json`; the CLI's\n * `acpHandleFromSpec` mints a runnable `AgentCliHandle` from it at\n * resolve time (see `packages/cli/src/registry/acp-generic.ts`). Kept\n * in this package (not the CLI's) so `config.ts` stays the single\n * source of truth for the config surface without a cli→runtime→cli\n * import cycle — the CLI's `AcpAgentSpec` extends this shape.\n */\nexport interface AcpAgentConfigEntry {\n /** Display name. Defaults to the slug when omitted. */\n name?: string\n /** One-line description surfaced in `agentproto acp ls`. */\n description?: string\n /** Executable to spawn, e.g. \"gemini\". */\n bin: string\n /** Extra argv appended after `bin`, e.g. [\"--experimental-acp\"]. */\n bin_args?: string[]\n /** Extra environment variables for the spawned process. */\n env?: Record<string, string>\n /** Flag the CLI uses to receive the working directory, if it needs\n * one passed explicitly (most ACP agents take cwd over the wire). */\n cwd_flag?: string\n /** When true, advertise resumable + native-resume continuation. */\n resumable?: boolean\n /** Known model ids for the agent (informational + validation hints). */\n models?: { default?: string; allowed?: string[] }\n /** Billing endpoint this CLI's own auth bills (e.g. \"mistral\",\n * \"moonshot\", \"google\") — the provider whose wallet/auth-profile the\n * agent consumes. Lets clients link the harness to that provider's\n * wallets even when no model list is declared. Unset when the CLI's\n * billing target isn't a single known endpoint. */\n provider?: string\n /** Shown when `bin` is missing from PATH (how to install the CLI). */\n install_hint?: string\n}\n\n/**\n * Per-environment connection bundle. A profile overrides specific\n * fields of the top-level `daemon` / `tunnel` / `features` blocks\n * when selected via `--profile <name>` (or the top-level\n * `activeProfile` setting). Missing fields fall through to the\n * top-level config, so a profile only needs to declare what's\n * different — typically just `tunnel.host` + `tunnel.token`.\n *\n * Example:\n * {\n * \"daemon\": { \"workspace\": \"/code\", \"port\": 18790 },\n * \"activeProfile\": \"local\",\n * \"profiles\": {\n * \"local\": { \"tunnel\": { \"host\": \"ws://localhost:3200/connect\",\n * \"token\": \"apt_local\", \"autoconnect\": true } },\n * \"prod\": { \"tunnel\": { \"host\": \"wss://tunnel.guilde.work/connect\",\n * \"token\": \"apt_prod\", \"autoconnect\": true },\n * \"daemon\": { \"port\": 18791 } }\n * }\n * }\n *\n * Sandbox daemons generate per-sandbox profile entries at provision\n * time so the daemon inside the sandbox boots with\n * `agentproto serve --profile sandbox-<id>` and no extra plumbing.\n */\nexport interface ProfileConfig {\n daemon?: DaemonConfig\n tunnel?: TunnelConfig\n features?: FeaturesConfig\n}\n\n/**\n * A user-defined named terminal/TUI preset stored in\n * `~/.agentproto/config.json` under `terminalPresets`. Presets keep\n * local launch recipes (argv, env, cwd, name/label) out of shared\n * adapter manifests — e.g. pointing a Claude Code TUI at a local\n * LLM gateway without retyping proxy env vars every spawn.\n */\nexport interface TerminalPreset {\n /** Command + args to spawn. When provided, `sessions terminal` can\n * be used without `-- <argv...>`. */\n argv?: string[]\n /** Extra environment variables layered on top of the daemon's\n * inherited process.env. Values MUST be strings. */\n env?: Record<string, string>\n /** Working directory for the PTY session. Relative paths are\n * resolved against the current working directory at CLI time. */\n cwd?: string\n /** Workspace slug used for cwd fallback when `cwd` is omitted. */\n workspace?: string\n /** Stable session name passed to the registry (`name` field). */\n name?: string\n /** Human-readable label surfaced in session listings. */\n label?: string\n}\n\nexport interface AgentprotoConfig {\n version?: number\n daemon?: DaemonConfig\n tunnel?: TunnelConfig\n features?: FeaturesConfig\n /** E2E daemon-pairing defaults (rendezvous URL + autoconnect). */\n pairing?: PairingConfig\n /** Where `agentproto worktree new` creates worktrees. See\n * {@link WorktreesConfig}. */\n worktrees?: WorktreesConfig\n /** Spawn-time policy (`agent_start`). See {@link SpawnConfig}. */\n spawn?: SpawnConfig\n /** Session-presence policy for the dashboard. See {@link SessionsConfig}. */\n sessions?: SessionsConfig\n /** Provenance policy — the opt-in `gh` PATH shim. See {@link ProvenanceConfig}. */\n provenance?: ProvenanceConfig\n /** Daemon-side AGENTS.md resolution/injection policy. See {@link AgentsMdConfig}. */\n agentsMd?: AgentsMdConfig\n /** Named connection profiles. See `ProfileConfig` for the merge\n * semantics — a profile's fields shallow-override the top-level\n * defaults for the selected run. */\n profiles?: Record<string, ProfileConfig>\n /** Profile name to use when `--profile` isn't passed. When unset,\n * the top-level `daemon` / `tunnel` blocks are used directly. */\n activeProfile?: string\n /** Default `skills` + `options` auto-applied to every `agent_start`\n * spawn — global and per-adapter. See `resolveSpawnDefaults` in\n * `spawn-defaults.ts` for the merge precedence with an explicit call.\n * Absent ⇒ current behaviour exactly (no regression). */\n defaults?: SpawnDefaultsConfig\n /** User-defined generic ACP agents, keyed by adapter slug. Each entry\n * is minted into a runnable handle by the CLI's `acpHandleFromSpec`\n * when `resolveAdapter(slug)` finds no npm adapter package. User\n * entries shadow the curated `ACP_CATALOG` on slug collision. */\n acpAgents?: Record<string, AcpAgentConfigEntry>\n /** User-defined named terminal/TUI presets. Local-only; never\n * packaged in shared adapter manifests or defaults. */\n terminalPresets?: Record<string, TerminalPreset>\n /** Unknown keys preserved across save round-trips. */\n [unknown: string]: unknown\n}\n\nexport const CONFIG_FILE_PATH = (): string =>\n join(homedir(), \".agentproto\", \"config.json\")\n\n/**\n * Drop any `acpAgents` entries that aren't a shape we can turn into a\n * handle. The one hard requirement is a non-empty string `bin` (the\n * executable to spawn); everything else is optional and defaulted\n * downstream. Invalid entries are removed rather than throwing so the\n * daemon still boots — one warning names the offending slug so the\n * user can fix their config. Returns `undefined` when nothing valid\n * remains, keeping the key absent (== \"no generic agents\").\n */\nfunction sanitizeAcpAgents(\n raw: unknown,\n target: string,\n): Record<string, AcpAgentConfigEntry> | undefined {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) {\n console.warn(\n `[runtime/config] ${target}: 'acpAgents' is not an object — ignoring`,\n )\n return undefined\n }\n const out: Record<string, AcpAgentConfigEntry> = {}\n for (const [slug, value] of Object.entries(raw as Record<string, unknown>)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n typeof (value as { bin?: unknown }).bin === \"string\" &&\n (value as { bin: string }).bin.length > 0\n ) {\n out[slug] = value as AcpAgentConfigEntry\n } else {\n console.warn(\n `[runtime/config] ${target}: acpAgents.${slug} is missing a string 'bin' — ignoring`,\n )\n }\n }\n return Object.keys(out).length > 0 ? out : undefined\n}\n\n/**\n * Load config.json. Returns an empty object (NOT null) when the file\n * is missing, malformed, or unreadable — callers can `cfg.daemon?.port`\n * safely without null-guards. Errors during a malformed-read are\n * logged once so the user notices the file is broken without the\n * daemon refusing to boot.\n */\nexport async function loadConfig(path?: string): Promise<AgentprotoConfig> {\n const target = path ?? CONFIG_FILE_PATH()\n try {\n const raw = await fs.readFile(target, \"utf8\")\n const parsed = JSON.parse(raw) as unknown\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n const cfg = parsed as AgentprotoConfig\n // Sanitize `acpAgents` in the same tolerant spirit as the rest of\n // this loader: a malformed entry is dropped (with one warning) so a\n // single bad hand-edit can't make every generic ACP agent\n // unresolvable. Full AIP-45 validation happens later, at\n // `acpHandleFromSpec` time, with precise field-level messages.\n if (cfg.acpAgents !== undefined) {\n cfg.acpAgents = sanitizeAcpAgents(cfg.acpAgents, target)\n }\n return cfg\n }\n console.warn(\n `[runtime/config] ${target}: top-level value is not an object — ignoring`,\n )\n return {}\n } catch (err) {\n // ENOENT is the common case; only warn on other shapes.\n const code = (err as NodeJS.ErrnoException).code\n if (code && code !== \"ENOENT\") {\n console.warn(\n `[runtime/config] failed to read ${target}: ${\n err instanceof Error ? err.message : String(err)\n }`,\n )\n }\n return {}\n }\n}\n\n/**\n * Write config.json atomically (tmp + rename) so a concurrent\n * `agentproto config edit` can't half-truncate the file. Writes\n * `next` AS-IS — callers are expected to pass the full desired\n * state (loaded the existing config, mutated, passed it back).\n *\n * Earlier versions deep-merged with the on-disk file, but that made\n * deletions impossible: `setConfigKey(cfg, \"x\", undefined)` would\n * remove the key from memory, then the deep-merge would silently\n * re-add it from disk. The current design trusts the caller's\n * snapshot and uses atomic rename for crash safety.\n */\nexport async function saveConfig(\n next: AgentprotoConfig,\n path?: string,\n): Promise<void> {\n const target = path ?? CONFIG_FILE_PATH()\n const payload = { ...next, version: CONFIG_VERSION }\n const dir = dirname(target)\n await fs.mkdir(dir, { recursive: true })\n const tmp = `${target}.tmp`\n await fs.writeFile(tmp, JSON.stringify(payload, null, 2) + \"\\n\", \"utf8\")\n await fs.rename(tmp, target)\n}\n\n/**\n * Read a dot-notation key (`daemon.port`) out of a config. Returns\n * `undefined` when any segment is missing.\n */\nexport function getConfigKey(\n cfg: AgentprotoConfig,\n dotted: string,\n): unknown {\n let cur: unknown = cfg\n for (const part of dotted.split(\".\")) {\n if (cur == null || typeof cur !== \"object\") return undefined\n cur = (cur as Record<string, unknown>)[part]\n }\n return cur\n}\n\n/**\n * Set a dot-notation key in a config. Returns a new object — does\n * NOT mutate. Creates intermediate objects as needed. Setting\n * `value: undefined` is treated as a delete.\n */\nexport function setConfigKey(\n cfg: AgentprotoConfig,\n dotted: string,\n value: unknown,\n): AgentprotoConfig {\n const parts = dotted.split(\".\")\n const out: AgentprotoConfig = { ...cfg }\n let cur: Record<string, unknown> = out as Record<string, unknown>\n for (let i = 0; i < parts.length - 1; i++) {\n const k = parts[i]!\n const next = cur[k]\n if (next && typeof next === \"object\" && !Array.isArray(next)) {\n cur[k] = { ...(next as Record<string, unknown>) }\n } else {\n cur[k] = {}\n }\n cur = cur[k] as Record<string, unknown>\n }\n const leaf = parts[parts.length - 1]!\n if (value === undefined) {\n delete cur[leaf]\n } else {\n cur[leaf] = value\n }\n return out\n}\n\n/**\n * Deep merge — objects are recursively combined, everything else\n * (arrays, primitives) is replaced wholesale by `b`. Mirrors what\n * `Object.assign({}, a, b)` does for shallow keys.\n */\nfunction deepMerge<A extends Record<string, unknown>, B extends Record<string, unknown>>(\n a: A,\n b: B,\n): A & B {\n const out: Record<string, unknown> = { ...a }\n for (const [k, v] of Object.entries(b)) {\n const cur = out[k]\n if (\n v &&\n typeof v === \"object\" &&\n !Array.isArray(v) &&\n cur &&\n typeof cur === \"object\" &&\n !Array.isArray(cur)\n ) {\n out[k] = deepMerge(\n cur as Record<string, unknown>,\n v as Record<string, unknown>,\n )\n } else {\n out[k] = v\n }\n }\n return out as A & B\n}\n"]}
1
+ {"version":3,"sources":["../src/config.ts"],"names":["fs"],"mappings":";;;;;;;;;AAgCO,IAAM,cAAA,GAAiB;AAydvB,IAAM,mBAAmB,MAC9B,IAAA,CAAK,OAAA,EAAQ,EAAG,eAAe,aAAa;AAW9C,SAAS,iBAAA,CACP,KACA,MAAA,EACiD;AACjD,EAAA,IAAI,CAAC,OAAO,OAAO,GAAA,KAAQ,YAAY,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA,EAAG;AACzD,IAAA,OAAA,CAAQ,IAAA;AAAA,MACN,oBAAoB,MAAM,CAAA,8CAAA;AAAA,KAC5B;AACA,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,MAAM,MAA2C,EAAC;AAClD,EAAA,KAAA,MAAW,CAAC,IAAA,EAAM,KAAK,KAAK,MAAA,CAAO,OAAA,CAAQ,GAA8B,CAAA,EAAG;AAC1E,IAAA,IACE,SACA,OAAO,KAAA,KAAU,QAAA,IACjB,CAAC,MAAM,OAAA,CAAQ,KAAK,CAAA,IACpB,OAAQ,MAA4B,GAAA,KAAQ,QAAA,IAC3C,KAAA,CAA0B,GAAA,CAAI,SAAS,CAAA,EACxC;AACA,MAAA,GAAA,CAAI,IAAI,CAAA,GAAI,KAAA;AAAA,IACd,CAAA,MAAO;AACL,MAAA,OAAA,CAAQ,IAAA;AAAA,QACN,CAAA,iBAAA,EAAoB,MAAM,CAAA,YAAA,EAAe,IAAI,CAAA,0CAAA;AAAA,OAC/C;AAAA,IACF;AAAA,EACF;AACA,EAAA,OAAO,OAAO,IAAA,CAAK,GAAG,CAAA,CAAE,MAAA,GAAS,IAAI,GAAA,GAAM,MAAA;AAC7C;AASA,eAAsB,WAAW,IAAA,EAA0C;AACzE,EAAA,MAAM,MAAA,GAAS,QAAQ,gBAAA,EAAiB;AACxC,EAAA,IAAI;AACF,IAAA,MAAM,GAAA,GAAM,MAAMA,QAAA,CAAG,QAAA,CAAS,QAAQ,MAAM,CAAA;AAC5C,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA;AAC7B,IAAA,IAAI,MAAA,IAAU,OAAO,MAAA,KAAW,QAAA,IAAY,CAAC,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,EAAG;AAClE,MAAA,MAAM,GAAA,GAAM,MAAA;AAMZ,MAAA,IAAI,GAAA,CAAI,cAAc,KAAA,CAAA,EAAW;AAC/B,QAAA,GAAA,CAAI,SAAA,GAAY,iBAAA,CAAkB,GAAA,CAAI,SAAA,EAAW,MAAM,CAAA;AAAA,MACzD;AACA,MAAA,OAAO,GAAA;AAAA,IACT;AACA,IAAA,OAAA,CAAQ,IAAA;AAAA,MACN,oBAAoB,MAAM,CAAA,kDAAA;AAAA,KAC5B;AACA,IAAA,OAAO,EAAC;AAAA,EACV,SAAS,GAAA,EAAK;AAEZ,IAAA,MAAM,OAAQ,GAAA,CAA8B,IAAA;AAC5C,IAAA,IAAI,IAAA,IAAQ,SAAS,QAAA,EAAU;AAC7B,MAAA,OAAA,CAAQ,IAAA;AAAA,QACN,CAAA,gCAAA,EAAmC,MAAM,CAAA,EAAA,EACvC,GAAA,YAAe,QAAQ,GAAA,CAAI,OAAA,GAAU,MAAA,CAAO,GAAG,CACjD,CAAA;AAAA,OACF;AAAA,IACF;AACA,IAAA,OAAO,EAAC;AAAA,EACV;AACF;AAcA,eAAsB,UAAA,CACpB,MACA,IAAA,EACe;AACf,EAAA,MAAM,MAAA,GAAS,QAAQ,gBAAA,EAAiB;AACxC,EAAA,MAAM,OAAA,GAAU,EAAE,GAAG,IAAA,EAAM,SAAS,cAAA,EAAe;AACnD,EAAA,MAAM,GAAA,GAAM,QAAQ,MAAM,CAAA;AAC1B,EAAA,MAAMA,SAAG,KAAA,CAAM,GAAA,EAAK,EAAE,SAAA,EAAW,MAAM,CAAA;AACvC,EAAA,MAAM,GAAA,GAAM,GAAG,MAAM,CAAA,IAAA,CAAA;AACrB,EAAA,MAAMA,QAAA,CAAG,SAAA,CAAU,GAAA,EAAK,IAAA,CAAK,SAAA,CAAU,SAAS,IAAA,EAAM,CAAC,CAAA,GAAI,IAAA,EAAM,MAAM,CAAA;AACvE,EAAA,MAAMA,QAAA,CAAG,MAAA,CAAO,GAAA,EAAK,MAAM,CAAA;AAC7B;AAMO,SAAS,YAAA,CACd,KACA,MAAA,EACS;AACT,EAAA,IAAI,GAAA,GAAe,GAAA;AACnB,EAAA,KAAA,MAAW,IAAA,IAAQ,MAAA,CAAO,KAAA,CAAM,GAAG,CAAA,EAAG;AACpC,IAAA,IAAI,GAAA,IAAO,IAAA,IAAQ,OAAO,GAAA,KAAQ,UAAU,OAAO,MAAA;AACnD,IAAA,GAAA,GAAO,IAAgC,IAAI,CAAA;AAAA,EAC7C;AACA,EAAA,OAAO,GAAA;AACT;AAOO,SAAS,YAAA,CACd,GAAA,EACA,MAAA,EACA,KAAA,EACkB;AAClB,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,KAAA,CAAM,GAAG,CAAA;AAC9B,EAAA,MAAM,GAAA,GAAwB,EAAE,GAAG,GAAA,EAAI;AACvC,EAAA,IAAI,GAAA,GAA+B,GAAA;AACnC,EAAA,KAAA,IAAS,IAAI,CAAA,EAAG,CAAA,GAAI,KAAA,CAAM,MAAA,GAAS,GAAG,CAAA,EAAA,EAAK;AACzC,IAAA,MAAM,CAAA,GAAI,MAAM,CAAC,CAAA;AACjB,IAAA,MAAM,IAAA,GAAO,IAAI,CAAC,CAAA;AAClB,IAAA,IAAI,IAAA,IAAQ,OAAO,IAAA,KAAS,QAAA,IAAY,CAAC,KAAA,CAAM,OAAA,CAAQ,IAAI,CAAA,EAAG;AAC5D,MAAA,GAAA,CAAI,CAAC,CAAA,GAAI,EAAE,GAAI,IAAA,EAAiC;AAAA,IAClD,CAAA,MAAO;AACL,MAAA,GAAA,CAAI,CAAC,IAAI,EAAC;AAAA,IACZ;AACA,IAAA,GAAA,GAAM,IAAI,CAAC,CAAA;AAAA,EACb;AACA,EAAA,MAAM,IAAA,GAAO,KAAA,CAAM,KAAA,CAAM,MAAA,GAAS,CAAC,CAAA;AACnC,EAAA,IAAI,UAAU,MAAA,EAAW;AACvB,IAAA,OAAO,IAAI,IAAI,CAAA;AAAA,EACjB,CAAA,MAAO;AACL,IAAA,GAAA,CAAI,IAAI,CAAA,GAAI,KAAA;AAAA,EACd;AACA,EAAA,OAAO,GAAA;AACT","file":"config.mjs","sourcesContent":["/**\n * `~/.agentproto/config.json` — single hand-editable JSON for the\n * agentproto control plane's defaults. Sits alongside the existing\n * surface files (workspaces.json, credentials.json, sessions.json):\n *\n * workspaces.json which directories are workspaces + which is active\n * credentials.json tunnel host bearer tokens (mode 0600)\n * sessions.json last-known snapshot of the registry (informational)\n * config.json daemon defaults: port, bind, allowed origins,\n * tunnel host, feature toggles\n *\n * Resolution order for every daemon knob is:\n * 1. CLI flag (e.g. --port)\n * 2. Env var (where one exists, e.g. AGENTPROTO_TOKEN)\n * 3. config.json\n * 4. Hardcoded default\n *\n * This means a user can call `agentproto config set daemon.port 18791`\n * once and never re-pass `--port 18791` to `serve install` etc. CLI\n * flags still win for one-off overrides.\n *\n * Schema is intentionally narrow + extensible — unknown keys are\n * preserved on save (deep-merge), so a newer CLI writing a new\n * field won't drop one an older CLI doesn't know about. No secrets\n * here; credentials stay in credentials.json (mode 0600).\n */\n\nimport { promises as fs } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { dirname, join } from \"node:path\"\nimport type { SpawnDefaultsConfig } from \"./spawn-defaults.js\"\n\nexport const CONFIG_VERSION = 1 as const\n\nexport interface DaemonConfig {\n /** Absolute path to the workspace the daemon binds to at boot. */\n workspace?: string\n /** HTTP port. Default 18790. */\n port?: number\n /** Bind addr. Default 127.0.0.1. */\n bind?: string\n /** Trusted browser origins for mutating /sessions/* routes (in\n * addition to the hardcoded localhost defaults). */\n allowedOrigins?: string[]\n /** When true, the daemon does NOT auto-trust localhost-on-any-port.\n * Only origins explicitly listed in `allowedOrigins` are allowed.\n * Pair with a curated list (e.g. `[\"http://localhost:3000\"]`) for\n * hardened setups. Default false. */\n strictOrigins?: boolean\n /** Server label sent in tunnel hello frames. */\n label?: string\n /** Bearer token gating the gateway at boot (`AuthOptions` with\n * `mode: \"bearer\"`). Unlike `remote_enable`'s ephemeral quick-tunnel\n * token, this one lives in config.json and survives daemon restarts.\n * Set via `agentproto config set daemon.authToken <token>` (e.g.\n * `$(openssl rand -hex 32)`). Unset ⇒ the gateway boots with\n * `mode: \"none\"` — fully open on loopback, same as today. */\n authToken?: string\n /** Opt-in eager resume-on-boot (session-survivability plan §5\n * \"Opt-in vs automatic\", PR-4). When true, a daemon restart doesn't just\n * leave agent-cli sessions dead-but-lazy-resumable — the boot pass eagerly\n * re-spawns the eligible ones (those that died with\n * `endedReason: \"daemon-restart\"` and still pass `canResume`) IN PLACE,\n * restoring liveness without waiting for a prompt. Lazy resume-on-prompt is\n * always on regardless of this flag (it costs nothing until someone acts);\n * this flag only controls the *automatic* boot-time pass. Default false: a\n * box-wide restart should not silently relaunch every adapter (cost, spawn\n * storm, credential re-resolution), and default-off also keeps two daemons\n * sharing the workspace buckets from racing to resume the same rows. Set via\n * `agentproto config set daemon.resumeSessionsOnBoot true`. Surfaced in\n * `daemon_health` / `GET /health`. */\n resumeSessionsOnBoot?: boolean\n /** Idle agent-session reaper (PR-6). When set to a positive number of\n * milliseconds, the daemon periodically retires agent-cli sessions that have\n * been idle (not busy, not awaiting input, `status:\"running\"`) longer than\n * this: it SIGTERMs the adapter child to free the process and flips the row\n * to `killed`/`endedReason:\"idle-reaped\"` — dead-but-lazy-resumable (a later\n * prompt revives it) and, critically, excluded from eager resume-on-boot\n * (#638 gates on `endedReason === \"daemon-restart\"`), so enabling this makes\n * eager resume safe: only genuinely-recent work survives a restart. Default\n * OFF (unset / 0 / negative ⇒ the reaper never runs). Resolution order\n * mirrors the module docblock: `AGENTPROTO_IDLE_REAP_AFTER_MS` env > this\n * field > off. Set via `agentproto config set daemon.idleReapAfterMs\n * <ms>`. Surfaced in `daemon_health` / `GET /health`. */\n idleReapAfterMs?: number\n /** Crash-detect sweep interval in ms (crash-detect PR-1). Unlike\n * `idleReapAfterMs`, this is DEFAULT ON (non-destructive observability):\n * the daemon periodically probes every live agent-cli session's OS\n * process (`process.kill(pid, 0)`) and, when it's provably gone with no\n * exit event ever emitted for it, flips the row to\n * `error`/`endedReason:\"crashed\"` with a `lastError` string — surfacing a\n * death that would otherwise sit silently as `status:\"running\"` until the\n * next prompt's RPC throws (or forever, for a parked session with no next\n * prompt). Detects only; never restarts or notifies (later PRs). Set to a\n * non-positive value to disable the sweep entirely. Resolution order\n * mirrors `idleReapAfterMs`: `AGENTPROTO_CRASH_DETECT_INTERVAL_MS` env >\n * this field > the hardcoded default (30s). Set via `agentproto config\n * set daemon.crashDetectIntervalMs <ms>`. Surfaced in `daemon_health` /\n * `GET /health`. */\n crashDetectIntervalMs?: number\n /** Restart-scheduler sweep interval in ms (restart-scheduler PR-2). OFF by\n * default (unset / 0 / negative ⇒ the sweep never runs), unlike\n * `crashDetectIntervalMs` — a positive value here only arms the periodic\n * sweep that EXECUTES an already-scheduled restart; it never opts a\n * session in by itself (that's the per-session `agent_start.restartPolicy`\n * field). Resolution order mirrors `idleReapAfterMs`:\n * `AGENTPROTO_RESTART_SWEEP_INTERVAL_MS` env > this field > off. Set via\n * `agentproto config set daemon.restartSweepIntervalMs <ms>`. Surfaced in\n * `daemon_health` / `GET /health`. */\n restartSweepIntervalMs?: number\n /** Turn-liveness watchdog threshold in ms (turn-liveness-watchdog\n * chantier). Same shape as `crashDetectIntervalMs`: DEFAULT ON\n * (non-destructive observability). The daemon periodically sweeps every\n * BUSY agent-cli session and, for one that is mid-turn, NOT legitimately\n * `blockedOn` a subagent/command, and has had no adapter activity\n * (`lastActivityAt`) for longer than this threshold, stamps\n * `stalledSinceMs` on the descriptor and emits `session:stalled` —\n * surfacing a dead adapter stream (network drop, hung child — zero\n * frames mid-turn) that would otherwise sit indistinguishable from\n * healthy long work: `status:\"running\"`, `lastError:null`, no other\n * signal. Conservative by design: a session legitimately blocked on a\n * long tool call is NEVER a candidate, no matter how long it's silent —\n * see `stall-watchdog.ts`'s docblock for why. Detects only; never kills\n * or restarts (this is a later concern, if ever). Set to a non-positive\n * value to disable the sweep entirely. Resolution order mirrors\n * `idleReapAfterMs`: `AGENTPROTO_TURN_STALL_AFTER_MS` env > this field >\n * the hardcoded default (5 min). Set via `agentproto config set\n * daemon.turnStallAfterMs <ms>`. Surfaced in `daemon_health` /\n * `GET /health`. */\n turnStallAfterMs?: number\n}\n\nexport interface TunnelConfig {\n /** Cloud WS URL. When set + autoconnect=true, `agentproto serve`\n * bootstraps with `--connect <host>`. */\n host?: string\n /** apt_ daemon token to present at the tunnel upgrade. When set,\n * `agentproto serve` uses this BEFORE falling back to\n * credentials.json — handy in profiles where the token-per-host\n * mapping in credentials.json doesn't fit (e.g. host = tunnel URL\n * but credentials were minted against the api URL). */\n token?: string\n /** Whether `agentproto daemon start` connects the tunnel by\n * default. v0 only — implementer can ignore until daemon needs it. */\n autoconnect?: boolean\n /**\n * Opt into end-to-end encryption of the outbound `serve --connect` tunnel\n * (design: tunnel-e2e/v1). When true, the daemon negotiates a\n * token-authenticated ephemeral handshake with the host and wraps the tunnel\n * frames in an AEAD box, so even the trusted host loses plaintext visibility.\n * The handshake authenticates both ends against the shared `tunnel.token`, so\n * `token` MUST also be set. Fully backward-compatible: if the host doesn't\n * advertise e2e (an older host), the daemon falls back to today's plaintext\n * tunnel. Unset/false ⇒ plaintext, byte-identical to today. */\n e2e?: boolean\n}\n\nexport interface FeaturesConfig {\n /** Hint that PTY is desired — informational; the daemon still\n * detects node-pty's presence at runtime. */\n pty?: boolean\n /** Enable the local LLM Endpoint proxy sidecar (route registration,\n * MCP tools, child-process lifecycle). Default false — the endpoint is\n * an opt-in feature; when off, the `llm-endpoint` custom route is not\n * registered and the `llm_endpoint_*` MCP tools are not exposed. */\n llmEndpoint?: boolean\n}\n\n/**\n * Policy for `agentproto worktree new` (PLAN.md §1.4 — config carries\n * policy, never state; git itself is the authority for which worktrees\n * exist). This is the fix for the sprawl the plan measured: 31 linked\n * worktrees across 6 different parent directories, because there was no\n * `worktree new` verb and therefore no convention to converge on.\n */\n/**\n * How the daemon isolates a freshly-spawned agent session into its own git\n * worktree (`agent_start.worktree`):\n * - `\"always\"` — every depth-0 spawn is provisioned into a worktree,\n * whether or not the caller asked. A cwd that is not in\n * a git repo has nothing to isolate, so it spawns plain.\n * - `\"on-request\"` — isolate ONLY when the caller passes `worktree`. This\n * is the default and the back-compatible behaviour:\n * today's callers pass nothing and spawn exactly where\n * they asked.\n * - `\"never\"` — isolation is off; an explicit `worktree` field is\n * REJECTED (loud, not silently ignored) so a caller\n * never believes it got an isolated tree it didn't.\n */\nexport type WorktreeIsolationMode = \"always\" | \"on-request\" | \"never\"\n\nexport interface WorktreesConfig {\n /**\n * Absolute path new worktrees are created under. Layout:\n * `<root>/<repoName>/<slug>`. Resolution order (mirrors every other\n * knob in this file, see the module docblock): `--root` flag >\n * `AGENTPROTO_WORKTREES_ROOT` env > this field > the hardcoded default\n * `~/.agentproto/worktrees`. The default is a real single root, not\n * \"unconfigured\" — `worktree new` converges to one place with zero\n * setup, which is the only way the sprawl actually stops (the 6 roots\n * that exist today are 6 people each inventing a default by hand).\n */\n root?: string\n /**\n * Policy for `agent_start.worktree` isolation. Resolution order mirrors\n * the module docblock (there is no CLI flag — this is a daemon-side\n * policy read at spawn, not a per-invocation flag):\n * `AGENTPROTO_WORKTREES_ISOLATION` env > this field > the hardcoded\n * default `\"on-request\"`. `\"on-request\"` is deliberately the default:\n * any other would break back-compat by isolating callers that never\n * asked (see `worktree-isolation.ts`).\n */\n isolation?: WorktreeIsolationMode\n}\n\n/**\n * Policy for `agent_start.attach` — whether a spawn AUTO-attaches to its\n * calling session as parent lineage. `\"always\"` (the default) nests a\n * spawned child under the session that spawned it whenever that identity is\n * derivable (the trusted `?callerSessionId=` on the daemon self-ref URL, or\n * an explicit `parentSessionId` hint), so a supervisor's executors stop\n * landing as depth-0 orphans. `\"on-request\"` reverts to the pre-attach\n * behaviour: auto-attribution is off, and a child nests only when the caller\n * explicitly opts in (`attach: true` / `attach: { parent }` / an explicit\n * `parentSessionId`). Either way a per-call `attach: false` forces an\n * independent root, exactly as `worktree: false` opts out of isolation. See\n * `spawn-attach.ts`.\n */\nexport type SpawnAttachMode = \"always\" | \"on-request\"\n\n/**\n * Policy for `agent_start`'s IMPLICIT dedupe — what happens when a caller\n * spawns with NO `idempotencyKey` at all (see `spawn-dedupe.ts`):\n * - `\"always\"` — the daemon DERIVES an implicit key from the spawn's\n * `label` (required — see below) plus a hash of the\n * initial `prompt`, and dedupes a same-adapter/cwd\n * repeat against it exactly as an explicit key would.\n * This is the default, mirroring `attach`'s own\n * \"opt-in-only guard is not a guard\" precedent above.\n * Deriving needs a `label` to produce anything at all\n * — an unlabelled spawn is untouched, which is what\n * keeps this safe for the fan-out pattern this repo\n * exercises (several agents into one cwd with no\n * shared label): see `spawn-dedupe.ts`'s docblock for\n * the full false-dedup analysis, and PR #803's own\n * no-opt-in label+cwd warning backstop in\n * `session-spawn.ts`, which independently landed on\n * the same label-is-the-signal boundary.\n * - `\"on-request\"` — no implicit derivation; only an explicit\n * `idempotencyKey` dedupes (today's behaviour,\n * unchanged). A per-call `dedupe: true` still opts in\n * under this policy, mirroring `attach: true`.\n * Either way a per-call `dedupe: false` disables implicit derivation for\n * that one spawn regardless of policy — the escape hatch, mirroring\n * `attach: false` / `worktree: false`. An explicit `idempotencyKey` always\n * wins over a derived one (derivation is only attempted when the caller\n * supplied none).\n */\nexport type SpawnDedupeMode = \"always\" | \"on-request\"\n\nexport interface SpawnConfig {\n /**\n * Attach policy for `agent_start`. Resolution order mirrors the module\n * docblock (no CLI flag — a daemon-side policy read at spawn):\n * `AGENTPROTO_SPAWN_ATTACH` env > this field > the hardcoded default\n * `\"always\"`. Unlike `worktrees.isolation` (whose default preserves\n * back-compat by NOT isolating), attach defaults ON: an orphaned executor\n * is a bug, not a feature, and the auto-parent is descriptor-only lineage\n * that never relaxes a privilege gate.\n */\n attach?: SpawnAttachMode\n /**\n * Implicit-dedupe policy for `agent_start`. Resolution order mirrors\n * `attach` above (no CLI flag — a daemon-side policy read at spawn):\n * `AGENTPROTO_SPAWN_DEDUPE` env > this field > the hardcoded default\n * `\"always\"`. See {@link SpawnDedupeMode} and `spawn-dedupe.ts` for the\n * full reasoning: a retry-safety guard that only works when a caller\n * remembers to ask for it (`idempotencyKey`) is not a guard — the same\n * argument `attach` already settled for parent lineage.\n */\n dedupe?: SpawnDedupeMode\n}\n\n/**\n * Provenance policy — the opt-in `gh` PATH shim (`gh-provenance-shim.ts`).\n * Off by default. When `wrapGh` is on, every agent session the daemon spawns\n * gets a shim directory prepended to its PATH so that any `gh pr create` the\n * session (or an adapter subprocess shelling out — claude-code, codex, …) runs\n * has a deterministic `@agentproto-bot` provenance footer appended to the\n * created PR's BODY, matching the footer the cloud runner stamps. The TOOL\n * stamps, never the model; commit messages are never touched (the repo's\n * hygiene-check forbids attribution there). Resolution order mirrors\n * `spawn.attach`: `AGENTPROTO_PROVENANCE_WRAP_GH` env > this field > default\n * `false`.\n */\nexport interface ProvenanceConfig {\n /** Enable the opt-in `gh` provenance PATH shim for spawned sessions. */\n wrapGh?: boolean\n}\n\n/**\n * Session-presence policy — how the dashboard triages a session into its\n * `running` / `tending` / `attention` / `quiet` presence state (see\n * `session-presence.ts`). Optional block on `AgentprotoConfig.sessions`.\n */\nexport interface SessionsConfig {\n /**\n * How long (seconds) a session stays shown as `running` after its last\n * turn ends, before it settles into `attention`/`quiet`. The grace window\n * is measured from the session's most recent `lastActivityAt` — ANY new\n * event resets it — so a session that just answered does not read as\n * \"quiet\" the instant its turn finishes, but something that went idle long\n * ago, with nothing waiting on it, settles grey. Resolution order mirrors\n * every other knob in this file (module docblock): env var\n * `AGENTPROTO_SESSIONS_ATTENTION_DELAY_SEC` > this field > the hardcoded\n * default `60`. Absent ⇒ 60s.\n */\n attentionDelaySec?: number\n /**\n * Root directory the daemon stores per-session transcripts under — each\n * session's `events.jsonl` lands at `<eventsDir>/<sessionId>/events.jsonl`\n * (and a terminal session's `terminal.jsonl` alongside it). Every\n * reader/writer (transcript writer, `/sessions/:id/events` HTTP routes,\n * exports, tool-call/usage logs) resolves through this single knob.\n * Resolution order mirrors every other knob in this file: relative paths\n * resolve against the home directory. Absent ⇒ the hardcoded default\n * `~/.agentproto/sessions` — zero behaviour change out of the box. A\n * change requires a daemon restart to take effect (and existing sessions'\n * transcripts do NOT move).\n */\n eventsDir?: string\n}\n\n/**\n * Policy for the daemon's own AGENTS.md resolution + injection (WP-R2) —\n * how a spawned session's initial prompt carries the repo's `AGENTS.md`\n * (see `agents-md.ts`). The daemon resolves the nearest `AGENTS.md` walking\n * up from the session's `cwd`, bounded by that repo's git toplevel, and\n * injects one AGENTS.md block after the role disposition whenever the spawn\n * carries a prompt.\n */\nexport interface AgentsMdConfig {\n /**\n * Inline-vs-pointer threshold in KiB. A resolved AGENTS.md whose byte size\n * is STRICTLY LESS than `inlineMaxKb` KiB is inlined in full into the\n * spawn's initial prompt, clearly delimited; `>=` the threshold is injected\n * as a short pointer telling the agent to read the file before its first\n * tool call instead. An absent AGENTS.md (nothing found up the walk) injects\n * nothing but is still reported on the descriptor via\n * `SessionDescriptor.agentsMdMode === \"absent\"`. Resolution order mirrors\n * the module docblock (no CLI flag, and no env override — not part of the\n * approved design): this field > the hardcoded default `8` (KiB).\n */\n inlineMaxKb?: number\n}\n\nexport interface PairingConfig {\n /** Rendezvous broker WS URL (ws:// or wss://) used by `pair offer` and by\n * autoconnect on boot. When unset, `pair offer` requires an explicit\n * `--rendezvous`. Mirrors `tunnel.host`. */\n rendezvous?: string\n /** Whether the daemon opens standing rendezvous connections for every\n * persisted pairing on boot (so a paired client can reconnect anytime).\n * Mirrors `tunnel.autoconnect`. Default true when a rendezvous is set. */\n autoconnect?: boolean\n}\n\n/**\n * A user-defined generic ACP agent — the config-file half of\n * `AcpAgentSpec` (the slug is the record key in `acpAgents`, so it's\n * omitted here). Any CLI that already speaks the Agent Client Protocol\n * can be wired with zero code by declaring one of these under\n * `acpAgents.<slug>` in `~/.agentproto/config.json`; the CLI's\n * `acpHandleFromSpec` mints a runnable `AgentCliHandle` from it at\n * resolve time (see `packages/cli/src/registry/acp-generic.ts`). Kept\n * in this package (not the CLI's) so `config.ts` stays the single\n * source of truth for the config surface without a cli→runtime→cli\n * import cycle — the CLI's `AcpAgentSpec` extends this shape.\n */\nexport interface AcpAgentConfigEntry {\n /** Display name. Defaults to the slug when omitted. */\n name?: string\n /** One-line description surfaced in `agentproto acp ls`. */\n description?: string\n /** Executable to spawn, e.g. \"gemini\". */\n bin: string\n /** Extra argv appended after `bin`, e.g. [\"--experimental-acp\"]. */\n bin_args?: string[]\n /** Extra environment variables for the spawned process. */\n env?: Record<string, string>\n /** Flag the CLI uses to receive the working directory, if it needs\n * one passed explicitly (most ACP agents take cwd over the wire). */\n cwd_flag?: string\n /** When true, advertise resumable + native-resume continuation. */\n resumable?: boolean\n /** Known model ids for the agent (informational + validation hints). */\n models?: { default?: string; allowed?: string[] }\n /** Billing endpoint this CLI's own auth bills (e.g. \"mistral\",\n * \"moonshot\", \"google\") — the provider whose wallet/auth-profile the\n * agent consumes. Lets clients link the harness to that provider's\n * wallets even when no model list is declared. Unset when the CLI's\n * billing target isn't a single known endpoint. */\n provider?: string\n /** Shown when `bin` is missing from PATH (how to install the CLI). */\n install_hint?: string\n}\n\n/**\n * Per-environment connection bundle. A profile overrides specific\n * fields of the top-level `daemon` / `tunnel` / `features` blocks\n * when selected via `--profile <name>` (or the top-level\n * `activeProfile` setting). Missing fields fall through to the\n * top-level config, so a profile only needs to declare what's\n * different — typically just `tunnel.host` + `tunnel.token`.\n *\n * Example:\n * {\n * \"daemon\": { \"workspace\": \"/code\", \"port\": 18790 },\n * \"activeProfile\": \"local\",\n * \"profiles\": {\n * \"local\": { \"tunnel\": { \"host\": \"ws://localhost:3200/connect\",\n * \"token\": \"apt_local\", \"autoconnect\": true } },\n * \"prod\": { \"tunnel\": { \"host\": \"wss://tunnel.guilde.work/connect\",\n * \"token\": \"apt_prod\", \"autoconnect\": true },\n * \"daemon\": { \"port\": 18791 } }\n * }\n * }\n *\n * Sandbox daemons generate per-sandbox profile entries at provision\n * time so the daemon inside the sandbox boots with\n * `agentproto serve --profile sandbox-<id>` and no extra plumbing.\n */\nexport interface ProfileConfig {\n daemon?: DaemonConfig\n tunnel?: TunnelConfig\n features?: FeaturesConfig\n}\n\n/**\n * A user-defined named terminal/TUI preset stored in\n * `~/.agentproto/config.json` under `terminalPresets`. Presets keep\n * local launch recipes (argv, env, cwd, name/label) out of shared\n * adapter manifests — e.g. pointing a Claude Code TUI at a local\n * LLM gateway without retyping proxy env vars every spawn.\n */\nexport interface TerminalPreset {\n /** Command + args to spawn. When provided, `sessions terminal` can\n * be used without `-- <argv...>`. */\n argv?: string[]\n /** Extra environment variables layered on top of the daemon's\n * inherited process.env. Values MUST be strings. */\n env?: Record<string, string>\n /** Working directory for the PTY session. Relative paths are\n * resolved against the current working directory at CLI time. */\n cwd?: string\n /** Workspace slug used for cwd fallback when `cwd` is omitted. */\n workspace?: string\n /** Stable session name passed to the registry (`name` field). */\n name?: string\n /** Human-readable label surfaced in session listings. */\n label?: string\n}\n\nexport interface AgentprotoConfig {\n version?: number\n daemon?: DaemonConfig\n tunnel?: TunnelConfig\n features?: FeaturesConfig\n /** E2E daemon-pairing defaults (rendezvous URL + autoconnect). */\n pairing?: PairingConfig\n /** Where `agentproto worktree new` creates worktrees. See\n * {@link WorktreesConfig}. */\n worktrees?: WorktreesConfig\n /** Spawn-time policy (`agent_start`). See {@link SpawnConfig}. */\n spawn?: SpawnConfig\n /** Session-presence policy for the dashboard. See {@link SessionsConfig}. */\n sessions?: SessionsConfig\n /** Provenance policy — the opt-in `gh` PATH shim. See {@link ProvenanceConfig}. */\n provenance?: ProvenanceConfig\n /** Daemon-side AGENTS.md resolution/injection policy. See {@link AgentsMdConfig}. */\n agentsMd?: AgentsMdConfig\n /** Named connection profiles. See `ProfileConfig` for the merge\n * semantics — a profile's fields shallow-override the top-level\n * defaults for the selected run. */\n profiles?: Record<string, ProfileConfig>\n /** Profile name to use when `--profile` isn't passed. When unset,\n * the top-level `daemon` / `tunnel` blocks are used directly. */\n activeProfile?: string\n /** Default `skills` + `options` auto-applied to every `agent_start`\n * spawn — global and per-adapter. See `resolveSpawnDefaults` in\n * `spawn-defaults.ts` for the merge precedence with an explicit call.\n * Absent ⇒ current behaviour exactly (no regression). */\n defaults?: SpawnDefaultsConfig\n /** User-defined generic ACP agents, keyed by adapter slug. Each entry\n * is minted into a runnable handle by the CLI's `acpHandleFromSpec`\n * when `resolveAdapter(slug)` finds no npm adapter package. User\n * entries shadow the curated `ACP_CATALOG` on slug collision. */\n acpAgents?: Record<string, AcpAgentConfigEntry>\n /** User-defined named terminal/TUI presets. Local-only; never\n * packaged in shared adapter manifests or defaults. */\n terminalPresets?: Record<string, TerminalPreset>\n /** Unknown keys preserved across save round-trips. */\n [unknown: string]: unknown\n}\n\nexport const CONFIG_FILE_PATH = (): string =>\n join(homedir(), \".agentproto\", \"config.json\")\n\n/**\n * Drop any `acpAgents` entries that aren't a shape we can turn into a\n * handle. The one hard requirement is a non-empty string `bin` (the\n * executable to spawn); everything else is optional and defaulted\n * downstream. Invalid entries are removed rather than throwing so the\n * daemon still boots — one warning names the offending slug so the\n * user can fix their config. Returns `undefined` when nothing valid\n * remains, keeping the key absent (== \"no generic agents\").\n */\nfunction sanitizeAcpAgents(\n raw: unknown,\n target: string,\n): Record<string, AcpAgentConfigEntry> | undefined {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) {\n console.warn(\n `[runtime/config] ${target}: 'acpAgents' is not an object — ignoring`,\n )\n return undefined\n }\n const out: Record<string, AcpAgentConfigEntry> = {}\n for (const [slug, value] of Object.entries(raw as Record<string, unknown>)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n typeof (value as { bin?: unknown }).bin === \"string\" &&\n (value as { bin: string }).bin.length > 0\n ) {\n out[slug] = value as AcpAgentConfigEntry\n } else {\n console.warn(\n `[runtime/config] ${target}: acpAgents.${slug} is missing a string 'bin' — ignoring`,\n )\n }\n }\n return Object.keys(out).length > 0 ? out : undefined\n}\n\n/**\n * Load config.json. Returns an empty object (NOT null) when the file\n * is missing, malformed, or unreadable — callers can `cfg.daemon?.port`\n * safely without null-guards. Errors during a malformed-read are\n * logged once so the user notices the file is broken without the\n * daemon refusing to boot.\n */\nexport async function loadConfig(path?: string): Promise<AgentprotoConfig> {\n const target = path ?? CONFIG_FILE_PATH()\n try {\n const raw = await fs.readFile(target, \"utf8\")\n const parsed = JSON.parse(raw) as unknown\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n const cfg = parsed as AgentprotoConfig\n // Sanitize `acpAgents` in the same tolerant spirit as the rest of\n // this loader: a malformed entry is dropped (with one warning) so a\n // single bad hand-edit can't make every generic ACP agent\n // unresolvable. Full AIP-45 validation happens later, at\n // `acpHandleFromSpec` time, with precise field-level messages.\n if (cfg.acpAgents !== undefined) {\n cfg.acpAgents = sanitizeAcpAgents(cfg.acpAgents, target)\n }\n return cfg\n }\n console.warn(\n `[runtime/config] ${target}: top-level value is not an object — ignoring`,\n )\n return {}\n } catch (err) {\n // ENOENT is the common case; only warn on other shapes.\n const code = (err as NodeJS.ErrnoException).code\n if (code && code !== \"ENOENT\") {\n console.warn(\n `[runtime/config] failed to read ${target}: ${\n err instanceof Error ? err.message : String(err)\n }`,\n )\n }\n return {}\n }\n}\n\n/**\n * Write config.json atomically (tmp + rename) so a concurrent\n * `agentproto config edit` can't half-truncate the file. Writes\n * `next` AS-IS — callers are expected to pass the full desired\n * state (loaded the existing config, mutated, passed it back).\n *\n * Earlier versions deep-merged with the on-disk file, but that made\n * deletions impossible: `setConfigKey(cfg, \"x\", undefined)` would\n * remove the key from memory, then the deep-merge would silently\n * re-add it from disk. The current design trusts the caller's\n * snapshot and uses atomic rename for crash safety.\n */\nexport async function saveConfig(\n next: AgentprotoConfig,\n path?: string,\n): Promise<void> {\n const target = path ?? CONFIG_FILE_PATH()\n const payload = { ...next, version: CONFIG_VERSION }\n const dir = dirname(target)\n await fs.mkdir(dir, { recursive: true })\n const tmp = `${target}.tmp`\n await fs.writeFile(tmp, JSON.stringify(payload, null, 2) + \"\\n\", \"utf8\")\n await fs.rename(tmp, target)\n}\n\n/**\n * Read a dot-notation key (`daemon.port`) out of a config. Returns\n * `undefined` when any segment is missing.\n */\nexport function getConfigKey(\n cfg: AgentprotoConfig,\n dotted: string,\n): unknown {\n let cur: unknown = cfg\n for (const part of dotted.split(\".\")) {\n if (cur == null || typeof cur !== \"object\") return undefined\n cur = (cur as Record<string, unknown>)[part]\n }\n return cur\n}\n\n/**\n * Set a dot-notation key in a config. Returns a new object — does\n * NOT mutate. Creates intermediate objects as needed. Setting\n * `value: undefined` is treated as a delete.\n */\nexport function setConfigKey(\n cfg: AgentprotoConfig,\n dotted: string,\n value: unknown,\n): AgentprotoConfig {\n const parts = dotted.split(\".\")\n const out: AgentprotoConfig = { ...cfg }\n let cur: Record<string, unknown> = out as Record<string, unknown>\n for (let i = 0; i < parts.length - 1; i++) {\n const k = parts[i]!\n const next = cur[k]\n if (next && typeof next === \"object\" && !Array.isArray(next)) {\n cur[k] = { ...(next as Record<string, unknown>) }\n } else {\n cur[k] = {}\n }\n cur = cur[k] as Record<string, unknown>\n }\n const leaf = parts[parts.length - 1]!\n if (value === undefined) {\n delete cur[leaf]\n } else {\n cur[leaf] = value\n }\n return out\n}\n\n/**\n * Deep merge — objects are recursively combined, everything else\n * (arrays, primitives) is replaced wholesale by `b`. Mirrors what\n * `Object.assign({}, a, b)` does for shallow keys.\n */\nfunction deepMerge<A extends Record<string, unknown>, B extends Record<string, unknown>>(\n a: A,\n b: B,\n): A & B {\n const out: Record<string, unknown> = { ...a }\n for (const [k, v] of Object.entries(b)) {\n const cur = out[k]\n if (\n v &&\n typeof v === \"object\" &&\n !Array.isArray(v) &&\n cur &&\n typeof cur === \"object\" &&\n !Array.isArray(cur)\n ) {\n out[k] = deepMerge(\n cur as Record<string, unknown>,\n v as Record<string, unknown>,\n )\n } else {\n out[k] = v\n }\n }\n return out as A & B\n}\n"]}