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.
- package/README.md +12 -0
- package/dist/adapters/claude-code.d.ts +3 -2
- package/dist/adapters/claude-code.js +21 -8
- package/dist/adapters/codex.d.ts +6 -3
- package/dist/adapters/codex.js +41 -10
- package/dist/adapters/jsonMcp.d.ts +15 -5
- package/dist/adapters/jsonMcp.js +38 -29
- package/dist/adapters/kiro.d.ts +3 -2
- package/dist/adapters/kiro.js +22 -9
- package/dist/adapters/mcpPlan.d.ts +11 -6
- package/dist/adapters/mcpPlan.js +39 -6
- package/dist/cli.js +123 -10
- package/dist/commands/mcp.d.ts +101 -7
- package/dist/commands/mcp.js +227 -10
- package/dist/commands/memory.d.ts +39 -0
- package/dist/commands/memory.js +78 -0
- package/dist/commands/migrate.d.ts +30 -4
- package/dist/commands/migrate.js +83 -16
- package/dist/commands/onboard.d.ts +18 -8
- package/dist/commands/onboard.js +124 -14
- package/dist/commands/skill.d.ts +51 -0
- package/dist/commands/skill.js +104 -0
- package/dist/core/adapter.d.ts +18 -8
- package/dist/core/canonical.d.ts +26 -1
- package/dist/core/canonical.js +81 -3
- package/dist/core/types.d.ts +17 -0
- package/dist/lib/deepEqual.d.ts +8 -0
- package/dist/lib/deepEqual.js +26 -0
- package/dist/lib/dirEquals.d.ts +9 -0
- package/dist/lib/dirEquals.js +15 -1
- package/dist/lib/mcpMigrateRead.d.ts +69 -0
- package/dist/lib/mcpMigrateRead.js +188 -0
- package/dist/lib/mcpOwnership.d.ts +25 -0
- package/dist/lib/mcpOwnership.js +50 -0
- package/dist/lib/memoryGraph.d.ts +60 -0
- package/dist/lib/memoryGraph.js +101 -0
- package/dist/lib/realHomeSnapshot.d.ts +26 -0
- package/dist/lib/realHomeSnapshot.js +77 -0
- package/dist/lib/terminalPicker.d.ts +45 -0
- package/dist/lib/terminalPicker.js +193 -0
- package/dist/lib/tomlSection.d.ts +20 -6
- package/dist/lib/tomlSection.js +78 -12
- package/dist/pi-bridge/bundle.js +76 -46
- package/dist/pi-bridge/index.js +7 -2
- package/dist/probes/codex.js +10 -2
- package/docs/architecture.md +7 -4
- package/docs/getting-started.md +166 -10
- package/docs/roadmap.md +311 -0
- package/package.json +1 -1
- 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)
|
|
9
|
-
*
|
|
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)
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
|
58
|
-
if (
|
|
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 =
|
|
64
|
+
const configPath = mcpWrites[0].target;
|
|
62
65
|
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
63
|
-
const merged = applyJsonMcp(parsed,
|
|
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);
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -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)
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
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";
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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)
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
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
|
|
89
|
-
if (
|
|
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 =
|
|
114
|
+
const configTomlPath = mcpWrites[0].target;
|
|
93
115
|
let content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
|
|
94
|
-
|
|
95
|
-
|
|
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`
|
|
13
|
-
*
|
|
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)
|
|
24
|
-
* top-level key on `parsed` is
|
|
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>;
|
package/dist/adapters/jsonMcp.js
CHANGED
|
@@ -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`
|
|
31
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
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)
|
|
72
|
-
* top-level key on `parsed` is
|
|
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"
|
|
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
|
}
|
package/dist/adapters/kiro.d.ts
CHANGED
|
@@ -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)
|
|
7
|
-
*
|
|
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
|
package/dist/adapters/kiro.js
CHANGED
|
@@ -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)
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
121
|
-
if (
|
|
122
|
-
const configPath =
|
|
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,
|
|
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
|
-
*
|
|
10
|
-
* skill's symlink, a plain key has no ownership marker, so
|
|
11
|
-
* canonical anymore" can't be distinguished from "the user
|
|
12
|
-
* this directly
|
|
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;
|
package/dist/adapters/mcpPlan.js
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
* skill's symlink, a plain key has no ownership marker, so
|
|
11
|
-
* canonical anymore" can't be distinguished from "the user
|
|
12
|
-
* this directly
|
|
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
|
-
|
|
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 };
|