agent-trellis 0.2.0 → 0.3.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.
Files changed (50) hide show
  1. package/README.md +12 -0
  2. package/dist/adapters/claude-code.d.ts +3 -2
  3. package/dist/adapters/claude-code.js +21 -8
  4. package/dist/adapters/codex.d.ts +6 -3
  5. package/dist/adapters/codex.js +41 -10
  6. package/dist/adapters/jsonMcp.d.ts +15 -5
  7. package/dist/adapters/jsonMcp.js +38 -29
  8. package/dist/adapters/kiro.d.ts +3 -2
  9. package/dist/adapters/kiro.js +22 -9
  10. package/dist/adapters/mcpPlan.d.ts +11 -6
  11. package/dist/adapters/mcpPlan.js +39 -6
  12. package/dist/cli.js +123 -10
  13. package/dist/commands/mcp.d.ts +101 -7
  14. package/dist/commands/mcp.js +227 -10
  15. package/dist/commands/memory.d.ts +39 -0
  16. package/dist/commands/memory.js +78 -0
  17. package/dist/commands/migrate.d.ts +30 -4
  18. package/dist/commands/migrate.js +83 -16
  19. package/dist/commands/onboard.d.ts +18 -8
  20. package/dist/commands/onboard.js +124 -14
  21. package/dist/commands/skill.d.ts +51 -0
  22. package/dist/commands/skill.js +104 -0
  23. package/dist/core/adapter.d.ts +18 -8
  24. package/dist/core/canonical.d.ts +26 -1
  25. package/dist/core/canonical.js +81 -3
  26. package/dist/core/types.d.ts +17 -0
  27. package/dist/lib/deepEqual.d.ts +8 -0
  28. package/dist/lib/deepEqual.js +26 -0
  29. package/dist/lib/dirEquals.d.ts +9 -0
  30. package/dist/lib/dirEquals.js +15 -1
  31. package/dist/lib/mcpMigrateRead.d.ts +69 -0
  32. package/dist/lib/mcpMigrateRead.js +188 -0
  33. package/dist/lib/mcpOwnership.d.ts +25 -0
  34. package/dist/lib/mcpOwnership.js +50 -0
  35. package/dist/lib/memoryGraph.d.ts +60 -0
  36. package/dist/lib/memoryGraph.js +101 -0
  37. package/dist/lib/realHomeSnapshot.d.ts +26 -0
  38. package/dist/lib/realHomeSnapshot.js +77 -0
  39. package/dist/lib/terminalPicker.d.ts +45 -0
  40. package/dist/lib/terminalPicker.js +193 -0
  41. package/dist/lib/tomlSection.d.ts +20 -6
  42. package/dist/lib/tomlSection.js +78 -12
  43. package/dist/pi-bridge/bundle.js +76 -46
  44. package/dist/pi-bridge/index.js +7 -2
  45. package/dist/probes/codex.js +10 -2
  46. package/docs/architecture.md +7 -4
  47. package/docs/getting-started.md +166 -10
  48. package/docs/roadmap.md +311 -0
  49. package/package.json +1 -1
  50. package/schema/servers.example.yaml +39 -2
package/README.md CHANGED
@@ -148,6 +148,18 @@ result is named future work, not built yet.
148
148
  `mcp sync`/`onboard` run that performs a real write adds one more run
149
149
  directory, with no cap. Delete old ones by hand for now.
150
150
 
151
+ A canonical MCP server can also declare `enabled: false` (kept defined,
152
+ never written to any agent) and `static_env` (a value that was never a
153
+ secret — an email, an environment tag — written verbatim instead of
154
+ resolved by name; still scanned against `reject_patterns` like every
155
+ other literal field). Before ever writing a name-only `env` entry,
156
+ `mcp sync` now checks it actually resolves through the same source
157
+ `secrets audit` and the pi bridge use — an unresolvable name is refused
158
+ as a conflict, not silently written and left to break that server's
159
+ connection once the agent starts it. Both found via real-machine
160
+ dogfooding, not a synthetic fixture — see
161
+ [`schema/servers.example.yaml`](schema/servers.example.yaml).
162
+
151
163
  ## Design principles
152
164
 
153
165
  1. **One canonical source, many adapters.** Each agent's native config file is
@@ -5,8 +5,9 @@
5
5
  * before the other three adapters commit to reusing `symlinkPlan.ts`.
6
6
  *
7
7
  * MCP servers: plain JSON parse → merge under `mcpServers` → stringify
8
- * (trellis-mcp-sync-p2 design.md D4) create/repair only, no automatic
9
- * removal (D7).
8
+ * (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
9
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
10
+ * the current entry is still exactly what Trellis itself last wrote.
10
11
  */
11
12
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
12
13
  import type { CanonicalSource } from "../core/types.js";
@@ -5,8 +5,9 @@
5
5
  * before the other three adapters commit to reusing `symlinkPlan.ts`.
6
6
  *
7
7
  * MCP servers: plain JSON parse → merge under `mcpServers` → stringify
8
- * (trellis-mcp-sync-p2 design.md D4) create/repair only, no automatic
9
- * removal (D7).
8
+ * (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
9
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
10
+ * the current entry is still exactly what Trellis itself last wrote.
10
11
  */
11
12
  import { existsSync, readFileSync } from "node:fs";
12
13
  import { dirname, join } from "node:path";
@@ -14,7 +15,8 @@ import { homedir } from "node:os";
14
15
  import { isInScope } from "../core/adapter.js";
15
16
  import * as claudeCodeProbe from "../probes/claude-code.js";
16
17
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
17
- import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
18
+ import { applyJsonMcp, planJsonMcp, renderJsonServerEntry } from "./jsonMcp.js";
19
+ import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
18
20
  export class ClaudeCodeAdapter {
19
21
  homeDir;
20
22
  name = "Claude Code";
@@ -50,18 +52,29 @@ export class ClaudeCodeAdapter {
50
52
  planMcp(canonical) {
51
53
  const configPath = join(this.homeDir, ".claude.json");
52
54
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
53
- return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents });
55
+ const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
56
+ return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership });
54
57
  }
55
58
  async apply(plan, backup) {
56
59
  await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
57
- const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
58
- if (mcpCreates.length === 0) {
60
+ const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.action === "create" || item.action === "remove"));
61
+ if (mcpWrites.length === 0) {
59
62
  return;
60
63
  }
61
- const configPath = mcpCreates[0].target;
64
+ const configPath = mcpWrites[0].target;
62
65
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
63
- const merged = applyJsonMcp(parsed, mcpCreates);
66
+ const merged = applyJsonMcp(parsed, mcpWrites);
64
67
  backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
68
+ let ownership = loadMcpOwnership(this.homeDir);
69
+ for (const item of mcpWrites) {
70
+ if (item.action === "create" && item.mcpWrite) {
71
+ ownership = recordOwned(ownership, this.id, item.mcpWrite.name, renderJsonServerEntry(item.mcpWrite.def));
72
+ }
73
+ else if (item.action === "remove" && item.mcpRemove) {
74
+ ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
75
+ }
76
+ }
77
+ saveMcpOwnership(this.homeDir, ownership);
65
78
  }
66
79
  async verify(canonical) {
67
80
  const snapshot = await claudeCodeProbe.probe(this.homeDir);
@@ -8,9 +8,12 @@
8
8
  *
9
9
  * MCP servers are written via `src/lib/tomlSection.ts`'s line-based
10
10
  * section splicer, never a TOML library (trellis-mcp-sync-p2 design.md
11
- * D1/D2 — both rejected with evidence) create/repair only, no
12
- * automatic removal (D7: no ownership marker exists for a bare TOML key
13
- * the way a symlink's realpath provides for skills).
11
+ * D1/D2 — both rejected with evidence). Removal (trellis-mcp-lifecycle-
12
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
13
+ * the current section text is still exactly what Trellis itself last
14
+ * wrote — a bare TOML key has no ownership marker of its own the way a
15
+ * symlink's realpath provides for skills, so this ledger is what makes
16
+ * it provably safe.
14
17
  */
15
18
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
16
19
  import type { CanonicalSource } from "../core/types.js";
@@ -8,9 +8,12 @@
8
8
  *
9
9
  * MCP servers are written via `src/lib/tomlSection.ts`'s line-based
10
10
  * section splicer, never a TOML library (trellis-mcp-sync-p2 design.md
11
- * D1/D2 — both rejected with evidence) create/repair only, no
12
- * automatic removal (D7: no ownership marker exists for a bare TOML key
13
- * the way a symlink's realpath provides for skills).
11
+ * D1/D2 — both rejected with evidence). Removal (trellis-mcp-lifecycle-
12
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
13
+ * the current section text is still exactly what Trellis itself last
14
+ * wrote — a bare TOML key has no ownership marker of its own the way a
15
+ * symlink's realpath provides for skills, so this ledger is what makes
16
+ * it provably safe.
14
17
  */
15
18
  import { existsSync, readFileSync } from "node:fs";
16
19
  import { basename, dirname, join } from "node:path";
@@ -20,7 +23,8 @@ import * as codexProbe from "../probes/codex.js";
20
23
  import { readInstructionsPath } from "../probes/codex.js";
21
24
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
22
25
  import { resolveMcpPlan } from "./mcpPlan.js";
23
- import { currentServerSectionText, renderServerSection, upsertSection } from "../lib/tomlSection.js";
26
+ import { currentServerSectionText, removeSection, renderServerSection, upsertSection } from "../lib/tomlSection.js";
27
+ import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
24
28
  export class CodexAdapter {
25
29
  homeDir;
26
30
  name = "Codex";
@@ -62,7 +66,7 @@ export class CodexAdapter {
62
66
  planMcp(canonical) {
63
67
  const configTomlPath = join(this.homeDir, ".codex", "config.toml");
64
68
  const content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
65
- const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents);
69
+ const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy);
66
70
  const items = [];
67
71
  for (const { name, def } of desired) {
68
72
  const current = currentServerSectionText(content, name);
@@ -81,20 +85,47 @@ export class CodexAdapter {
81
85
  for (const conflict of conflicts) {
82
86
  items.push({ action: "conflict", kind: "mcp", target: configTomlPath, description: conflict.message });
83
87
  }
88
+ const desiredNames = new Set(desired.map((d) => d.name));
89
+ const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
90
+ for (const [name, expected] of Object.entries(ownership)) {
91
+ if (desiredNames.has(name))
92
+ continue; // still wanted — not a removal candidate
93
+ const current = currentServerSectionText(content, name);
94
+ if (current === null)
95
+ continue; // already gone
96
+ if (current !== expected)
97
+ continue; // hand-edited since Trellis wrote it — no longer ours to touch
98
+ items.push({
99
+ action: "remove",
100
+ kind: "mcp",
101
+ target: configTomlPath,
102
+ mcpRemove: { name },
103
+ description: `MCP server "${name}" removed from ${configTomlPath} — no longer in canonical`,
104
+ });
105
+ }
84
106
  return items;
85
107
  }
86
108
  async apply(plan, backup) {
87
109
  await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
88
- const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
89
- if (mcpCreates.length === 0) {
110
+ const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.action === "create" || item.action === "remove"));
111
+ if (mcpWrites.length === 0) {
90
112
  return;
91
113
  }
92
- const configTomlPath = mcpCreates[0].target;
114
+ const configTomlPath = mcpWrites[0].target;
93
115
  let content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
94
- for (const item of mcpCreates) {
95
- content = upsertSection(content, item.mcpWrite.name, item.mcpWrite.def);
116
+ let ownership = loadMcpOwnership(this.homeDir);
117
+ for (const item of mcpWrites) {
118
+ if (item.action === "create" && item.mcpWrite) {
119
+ content = upsertSection(content, item.mcpWrite.name, item.mcpWrite.def);
120
+ ownership = recordOwned(ownership, this.id, item.mcpWrite.name, renderServerSection(item.mcpWrite.name, item.mcpWrite.def));
121
+ }
122
+ else if (item.action === "remove" && item.mcpRemove) {
123
+ content = removeSection(content, item.mcpRemove.name);
124
+ ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
125
+ }
96
126
  }
97
127
  backup.writeFile(configTomlPath, content);
128
+ saveMcpOwnership(this.homeDir, ownership);
98
129
  }
99
130
  async verify(canonical) {
100
131
  const snapshot = await codexProbe.probe(this.homeDir);
@@ -6,11 +6,14 @@
6
6
  * round-trip is safe as long as every sibling top-level key is carried
7
7
  * through untouched.
8
8
  */
9
- import type { AgentId, McpConfig, McpServerDef } from "../core/types.js";
9
+ import type { AgentId, McpConfig, McpServerDef, SecretsPolicy } from "../core/types.js";
10
10
  import type { AdapterPlanItem } from "../core/adapter.js";
11
11
  /** Renders the native JSON shape Claude Code/Kiro's `mcpServers` map
12
- * expects — `env` values are always `${VAR}` references, never literals
13
- * (docs/research.md "Secrets"). */
12
+ * expects — `env` names render as `${VAR}` references, `staticEnv`
13
+ * entries render as their literal values, both in the same map (JSON's
14
+ * `env` object has no structural distinction between the two the way
15
+ * Codex's TOML does — see tomlSection.ts's two-table rendering)
16
+ * (trellis-mcp-static-env-and-disabled-servers design.md D4). */
14
17
  export declare function renderJsonServerEntry(def: McpServerDef): Record<string, unknown>;
15
18
  export declare function planJsonMcp(opts: {
16
19
  configPath: string;
@@ -18,8 +21,15 @@ export declare function planJsonMcp(opts: {
18
21
  mcp: McpConfig;
19
22
  agentId: AgentId;
20
23
  managedAgents: readonly AgentId[];
24
+ policy: SecretsPolicy;
25
+ /** This agent's own slice of `src/lib/mcpOwnership.ts`'s ledger — what
26
+ * Trellis itself last wrote for each name, keyed by name. Omit (or
27
+ * pass `{}`) to get today's create/repair/conflict-only behavior with
28
+ * zero removal candidates. */
29
+ ownership?: Record<string, unknown>;
21
30
  }): AdapterPlanItem[];
22
31
  /** Merges every `"create"` MCP item into `parsed` (or a fresh `{}` if the
23
- * file didn't exist), returning the object to stringify every sibling
24
- * top-level key on `parsed` is spread through untouched. */
32
+ * file didn't exist) and deletes every `"remove"` item's key, returning
33
+ * the object to stringify — every sibling top-level key on `parsed` is
34
+ * spread through untouched. */
25
35
  export declare function applyJsonMcp(parsed: Record<string, unknown> | undefined, items: AdapterPlanItem[]): Record<string, unknown>;
@@ -6,29 +6,14 @@
6
6
  * round-trip is safe as long as every sibling top-level key is carried
7
7
  * through untouched.
8
8
  */
9
+ import { deepEqual } from "../lib/deepEqual.js";
9
10
  import { resolveMcpPlan } from "./mcpPlan.js";
10
- function deepEqual(a, b) {
11
- if (a === b)
12
- return true;
13
- if (a === null || b === null || typeof a !== typeof b)
14
- return false;
15
- if (Array.isArray(a) || Array.isArray(b)) {
16
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
17
- return false;
18
- return a.every((value, index) => deepEqual(value, b[index]));
19
- }
20
- if (typeof a === "object" && typeof b === "object") {
21
- const aKeys = Object.keys(a);
22
- const bKeys = Object.keys(b);
23
- if (aKeys.length !== bKeys.length)
24
- return false;
25
- return aKeys.every((key) => deepEqual(a[key], b[key]));
26
- }
27
- return false;
28
- }
29
11
  /** Renders the native JSON shape Claude Code/Kiro's `mcpServers` map
30
- * expects — `env` values are always `${VAR}` references, never literals
31
- * (docs/research.md "Secrets"). */
12
+ * expects — `env` names render as `${VAR}` references, `staticEnv`
13
+ * entries render as their literal values, both in the same map (JSON's
14
+ * `env` object has no structural distinction between the two the way
15
+ * Codex's TOML does — see tomlSection.ts's two-table rendering)
16
+ * (trellis-mcp-static-env-and-disabled-servers design.md D4). */
32
17
  export function renderJsonServerEntry(def) {
33
18
  if (def.transport === "http" || def.transport === "sse") {
34
19
  const entry = { type: def.transport, url: def.url };
@@ -38,14 +23,16 @@ export function renderJsonServerEntry(def) {
38
23
  return entry;
39
24
  }
40
25
  const entry = { type: "stdio", command: def.command, args: def.args ?? [] };
41
- if (def.env && def.env.length > 0) {
42
- entry.env = Object.fromEntries(def.env.map((name) => [name, `\${${name}}`]));
26
+ const nameRefs = Object.fromEntries((def.env ?? []).map((name) => [name, `\${${name}}`]));
27
+ const env = { ...nameRefs, ...(def.staticEnv ?? {}) };
28
+ if (Object.keys(env).length > 0) {
29
+ entry.env = env;
43
30
  }
44
31
  return entry;
45
32
  }
46
33
  export function planJsonMcp(opts) {
47
- const { configPath, parsed, mcp, agentId, managedAgents } = opts;
48
- const { desired, conflicts } = resolveMcpPlan(agentId, mcp, managedAgents);
34
+ const { configPath, parsed, mcp, agentId, managedAgents, policy, ownership } = opts;
35
+ const { desired, conflicts } = resolveMcpPlan(agentId, mcp, managedAgents, policy);
49
36
  const existingServers = parsed?.mcpServers ?? {};
50
37
  const items = [];
51
38
  for (const { name, def } of desired) {
@@ -65,20 +52,42 @@ export function planJsonMcp(opts) {
65
52
  for (const conflict of conflicts) {
66
53
  items.push({ action: "conflict", kind: "mcp", target: configPath, description: conflict.message });
67
54
  }
55
+ const desiredNames = new Set(desired.map((d) => d.name));
56
+ for (const [name, expected] of Object.entries(ownership ?? {})) {
57
+ if (desiredNames.has(name))
58
+ continue; // still wanted — not a removal candidate at all
59
+ const current = existingServers[name];
60
+ if (current === undefined)
61
+ continue; // already gone — nothing to remove, ledger is pruned separately
62
+ if (!deepEqual(current, expected))
63
+ continue; // hand-edited since Trellis wrote it — no longer ours to touch
64
+ items.push({
65
+ action: "remove",
66
+ kind: "mcp",
67
+ target: configPath,
68
+ mcpRemove: { name },
69
+ description: `MCP server "${name}" removed from ${configPath} — no longer in canonical`,
70
+ });
71
+ }
68
72
  return items;
69
73
  }
70
74
  /** Merges every `"create"` MCP item into `parsed` (or a fresh `{}` if the
71
- * file didn't exist), returning the object to stringify every sibling
72
- * top-level key on `parsed` is spread through untouched. */
75
+ * file didn't exist) and deletes every `"remove"` item's key, returning
76
+ * the object to stringify — every sibling top-level key on `parsed` is
77
+ * spread through untouched. */
73
78
  export function applyJsonMcp(parsed, items) {
74
79
  const base = parsed ?? {};
75
80
  const existingServers = base.mcpServers ?? {};
76
81
  const mergedServers = { ...existingServers };
77
82
  for (const item of items) {
78
- if (item.kind !== "mcp" || item.action !== "create" || !item.mcpWrite) {
83
+ if (item.kind !== "mcp")
79
84
  continue;
85
+ if (item.action === "create" && item.mcpWrite) {
86
+ mergedServers[item.mcpWrite.name] = renderJsonServerEntry(item.mcpWrite.def);
87
+ }
88
+ else if (item.action === "remove" && item.mcpRemove) {
89
+ delete mergedServers[item.mcpRemove.name];
80
90
  }
81
- mergedServers[item.mcpWrite.name] = renderJsonServerEntry(item.mcpWrite.def);
82
91
  }
83
92
  return { ...base, mcpServers: mergedServers };
84
93
  }
@@ -3,8 +3,9 @@
3
3
  * symlinks, `~/.kiro/steering/CLAUDE.md` symlinked to canonical `agents.md`.
4
4
  *
5
5
  * MCP servers: plain JSON parse → merge under `mcpServers` → stringify
6
- * (trellis-mcp-sync-p2 design.md D4) create/repair only, no automatic
7
- * removal (D7).
6
+ * (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
7
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
8
+ * the current entry is still exactly what Trellis itself last wrote.
8
9
  *
9
10
  * Kiro's own `${VAR}` substitution (real, found by reading Kiro's
10
11
  * installed extension source — trellis-kiro-approved-env-vars design.md
@@ -3,8 +3,9 @@
3
3
  * symlinks, `~/.kiro/steering/CLAUDE.md` symlinked to canonical `agents.md`.
4
4
  *
5
5
  * MCP servers: plain JSON parse → merge under `mcpServers` → stringify
6
- * (trellis-mcp-sync-p2 design.md D4) create/repair only, no automatic
7
- * removal (D7).
6
+ * (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
7
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
8
+ * the current entry is still exactly what Trellis itself last wrote.
8
9
  *
9
10
  * Kiro's own `${VAR}` substitution (real, found by reading Kiro's
10
11
  * installed extension source — trellis-kiro-approved-env-vars design.md
@@ -20,9 +21,10 @@ import { homedir } from "node:os";
20
21
  import { isInScope } from "../core/adapter.js";
21
22
  import * as kiroProbe from "../probes/kiro.js";
22
23
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
23
- import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
24
+ import { applyJsonMcp, planJsonMcp, renderJsonServerEntry } from "./jsonMcp.js";
24
25
  import { resolveMcpPlan } from "./mcpPlan.js";
25
26
  import { declaredEnvNames } from "../lib/envVarNames.js";
27
+ import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
26
28
  /** VS-Code-family global settings path. macOS only — see
27
29
  * trellis-kiro-approved-env-vars proposal.md Non-Goals: Linux/Windows
28
30
  * equivalents are the well-known convention but unverified against a
@@ -67,13 +69,14 @@ export class KiroAdapter {
67
69
  planMcp(canonical) {
68
70
  const configPath = join(this.homeDir, ".kiro", "settings", "mcp.json");
69
71
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
70
- return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents });
72
+ const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
73
+ return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership });
71
74
  }
72
75
  /** Names come from `resolveMcpPlan`, not a raw scan of `canonical.mcp.servers`
73
76
  * (design.md D2) — a server scoped away from Kiro, or refused for a
74
77
  * known_host_injected collision, never contributes a name here either. */
75
78
  desiredApprovedEnvVars(canonical) {
76
- const { desired } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents);
79
+ const { desired } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy);
77
80
  const names = new Set();
78
81
  for (const { def } of desired) {
79
82
  for (const name of declaredEnvNames(def))
@@ -117,13 +120,23 @@ export class KiroAdapter {
117
120
  }
118
121
  async apply(plan, backup) {
119
122
  await applySymlinkPlan(plan.filter((item) => item.kind === "skill" || item.kind === "instructions"), backup);
120
- const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
121
- if (mcpCreates.length > 0) {
122
- const configPath = mcpCreates[0].target;
123
+ const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.action === "create" || item.action === "remove"));
124
+ if (mcpWrites.length > 0) {
125
+ const configPath = mcpWrites[0].target;
123
126
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
124
- const merged = applyJsonMcp(parsed, mcpCreates);
127
+ const merged = applyJsonMcp(parsed, mcpWrites);
125
128
  mkdirSync(dirname(configPath), { recursive: true });
126
129
  backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
130
+ let ownership = loadMcpOwnership(this.homeDir);
131
+ for (const item of mcpWrites) {
132
+ if (item.action === "create" && item.mcpWrite) {
133
+ ownership = recordOwned(ownership, this.id, item.mcpWrite.name, renderJsonServerEntry(item.mcpWrite.def));
134
+ }
135
+ else if (item.action === "remove" && item.mcpRemove) {
136
+ ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
137
+ }
138
+ }
139
+ saveMcpOwnership(this.homeDir, ownership);
127
140
  }
128
141
  const approvedEnvVarsCreate = plan.find((item) => item.kind === "kiro-approved-env-vars" && item.action === "create" && item.approvedEnvVars);
129
142
  if (approvedEnvVarsCreate) {
@@ -6,12 +6,17 @@
6
6
  * each adapter turns this into plan items using its own read/write
7
7
  * mechanism (JSON merge vs. TOML section splice).
8
8
  *
9
- * No automatic removal here (design.md D7, trellis-mcp-sync-p2): unlike a
10
- * skill's symlink, a plain key has no ownership marker, so "not in
11
- * canonical anymore" can't be distinguished from "the user configured
12
- * this directly." Only create/repair + refuse.
9
+ * Removal is NOT decided here (design.md D7, trellis-mcp-sync-p2): unlike
10
+ * a skill's symlink, a plain key has no ownership marker of its own, so
11
+ * "not in canonical anymore" can't be distinguished from "the user
12
+ * configured this directly" from this function's storage-agnostic view
13
+ * alone. Each format-specific caller (`jsonMcp.ts`'s `planJsonMcp`,
14
+ * `codex.ts`'s own `planMcp`) computes removal candidates itself, against
15
+ * `src/lib/mcpOwnership.ts`'s ledger and that format's own current
16
+ * on-disk content (trellis-mcp-lifecycle-parity) — this function only
17
+ * ever returns create/repair + refuse.
13
18
  */
14
- import type { AgentId, McpConfig, McpServerDef } from "../core/types.js";
19
+ import type { AgentId, McpConfig, McpServerDef, SecretsPolicy } from "../core/types.js";
15
20
  export declare const HUB_ENTRY_NAME = "trellis-hub";
16
21
  export interface DesiredMcpEntry {
17
22
  name: string;
@@ -25,4 +30,4 @@ export interface McpPlanResult {
25
30
  desired: DesiredMcpEntry[];
26
31
  conflicts: McpConflict[];
27
32
  }
28
- export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[]): McpPlanResult;
33
+ export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[], policy: SecretsPolicy): McpPlanResult;
@@ -6,13 +6,19 @@
6
6
  * each adapter turns this into plan items using its own read/write
7
7
  * mechanism (JSON merge vs. TOML section splice).
8
8
  *
9
- * No automatic removal here (design.md D7, trellis-mcp-sync-p2): unlike a
10
- * skill's symlink, a plain key has no ownership marker, so "not in
11
- * canonical anymore" can't be distinguished from "the user configured
12
- * this directly." Only create/repair + refuse.
9
+ * Removal is NOT decided here (design.md D7, trellis-mcp-sync-p2): unlike
10
+ * a skill's symlink, a plain key has no ownership marker of its own, so
11
+ * "not in canonical anymore" can't be distinguished from "the user
12
+ * configured this directly" from this function's storage-agnostic view
13
+ * alone. Each format-specific caller (`jsonMcp.ts`'s `planJsonMcp`,
14
+ * `codex.ts`'s own `planMcp`) computes removal candidates itself, against
15
+ * `src/lib/mcpOwnership.ts`'s ledger and that format's own current
16
+ * on-disk content (trellis-mcp-lifecycle-parity) — this function only
17
+ * ever returns create/repair + refuse.
13
18
  */
14
19
  import { isInScope } from "../core/adapter.js";
15
20
  import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
21
+ import { resolveSecretEnv } from "../lib/secretEnv.js";
16
22
  export const HUB_ENTRY_NAME = "trellis-hub";
17
23
  const CODEX_STDIO_URL_CRASH_NOTE = ' On Codex specifically, this crashes the entire process at startup ("url is not supported for stdio"), not just this one server — see docs/research.md.';
18
24
  function collisionMessage(name, agentId) {
@@ -30,7 +36,7 @@ const DANGEROUS_LITERAL_PATTERNS = [
30
36
  { label: "mcp-router token (mcpr_)", pattern: /mcpr_/ },
31
37
  ];
32
38
  function findLiteralSecret(def) {
33
- const candidates = [def.command, def.url, ...(def.args ?? []), ...Object.values(def.headers ?? {})].filter((v) => typeof v === "string");
39
+ const candidates = [def.command, def.url, ...(def.args ?? []), ...Object.values(def.headers ?? {}), ...Object.values(def.staticEnv ?? {})].filter((v) => typeof v === "string");
34
40
  for (const candidate of candidates) {
35
41
  for (const { label, pattern } of DANGEROUS_LITERAL_PATTERNS) {
36
42
  if (pattern.test(candidate)) {
@@ -40,7 +46,22 @@ function findLiteralSecret(def) {
40
46
  }
41
47
  return undefined;
42
48
  }
43
- export function resolveMcpPlan(agentId, mcp, managedAgents) {
49
+ /**
50
+ * Name-only `env` entries that don't resolve to anything would otherwise
51
+ * be written and silently break the agent's connection to that server —
52
+ * found via real-machine dogfooding (trellis-mcp-static-env-and-disabled-servers
53
+ * proposal.md "Why"). Uses the identical `resolveSecretEnv` `secrets
54
+ * audit`/the pi bridge already call, so this and a later `secrets audit`
55
+ * run can never disagree about what resolves.
56
+ */
57
+ function findUnresolvedEnvName(def, policy) {
58
+ const names = def.env ?? [];
59
+ if (names.length === 0)
60
+ return undefined;
61
+ const resolved = resolveSecretEnv(names, policy);
62
+ return names.find((name) => !resolved[name]);
63
+ }
64
+ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
44
65
  if (mcp.hub) {
45
66
  if (mcp.knownHostInjected.includes(HUB_ENTRY_NAME)) {
46
67
  return { desired: [], conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId) }] };
@@ -50,6 +71,9 @@ export function resolveMcpPlan(agentId, mcp, managedAgents) {
50
71
  const desired = [];
51
72
  const conflicts = [];
52
73
  for (const [name, def] of Object.entries(mcp.servers)) {
74
+ if (def.enabled === false) {
75
+ continue;
76
+ }
53
77
  if (!isInScope(agentId, def.agents, managedAgents)) {
54
78
  continue;
55
79
  }
@@ -77,6 +101,15 @@ export function resolveMcpPlan(agentId, mcp, managedAgents) {
77
101
  });
78
102
  continue;
79
103
  }
104
+ const unresolvedName = findUnresolvedEnvName(def, policy);
105
+ if (unresolvedName) {
106
+ const source = policy.envFile ?? "process environment";
107
+ conflicts.push({
108
+ name,
109
+ message: `refusing to write MCP server "${name}": its declared env var "${unresolvedName}" has no resolvable value in ${source} — writing it now would silently break this server's connection once the agent starts it (trellis-mcp-static-env-and-disabled-servers)`,
110
+ });
111
+ continue;
112
+ }
80
113
  desired.push({ name, def });
81
114
  }
82
115
  return { desired, conflicts };