agent-trellis 0.20.0 → 0.21.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 (37) hide show
  1. package/dist/adapters/claude-code.js +2 -2
  2. package/dist/adapters/codex.js +3 -3
  3. package/dist/adapters/jsonMcp.d.ts +1 -1
  4. package/dist/adapters/jsonMcp.js +2 -2
  5. package/dist/adapters/kimi-code.js +2 -2
  6. package/dist/adapters/kiro.js +11 -9
  7. package/dist/adapters/mcpPlan.d.ts +1 -1
  8. package/dist/adapters/mcpPlan.js +35 -12
  9. package/dist/adapters/pi.js +5 -5
  10. package/dist/adapters/zcode.d.ts +1 -1
  11. package/dist/adapters/zcode.js +6 -6
  12. package/dist/commands/doctor.d.ts +1 -1
  13. package/dist/commands/doctor.js +5 -5
  14. package/dist/commands/mcp.js +1 -1
  15. package/dist/commands/mcpAuth.js +1 -1
  16. package/dist/commands/mcpGateway.d.ts +2 -2
  17. package/dist/commands/mcpGateway.js +4 -4
  18. package/dist/commands/mcpImport.js +2 -1
  19. package/dist/commands/secretsAudit.d.ts +1 -1
  20. package/dist/commands/secretsAudit.js +36 -13
  21. package/dist/lib/envVarNames.js +7 -0
  22. package/dist/lib/mcpConnect.d.ts +1 -1
  23. package/dist/lib/mcpConnect.js +19 -8
  24. package/dist/lib/mcpMigrateRead.js +14 -2
  25. package/dist/lib/secretEnv.d.ts +53 -8
  26. package/dist/lib/secretEnv.js +78 -15
  27. package/dist/lib/tomlSection.js +3 -2
  28. package/dist/lib/workspaceDoctor.d.ts +1 -1
  29. package/dist/lib/workspaceDoctor.js +8 -4
  30. package/dist/lib/workspaceMcp.d.ts +1 -1
  31. package/dist/lib/workspaceMcp.js +5 -5
  32. package/dist/pi-bridge/bundle.js +42220 -2695
  33. package/dist/pi-bridge/index.js +2 -2
  34. package/docs/research.md +4 -1
  35. package/package.json +2 -1
  36. package/schema/secrets.policy.example.yaml +5 -0
  37. package/schema/servers.example.yaml +5 -1
@@ -48,10 +48,10 @@ export class ClaudeCodeAdapter {
48
48
  canonicalRoot,
49
49
  kind: "instructions",
50
50
  });
51
- const mcpItems = this.planMcp(canonical);
51
+ const mcpItems = await this.planMcp(canonical);
52
52
  return [...skillItems, ...instructionsItems, ...mcpItems];
53
53
  }
54
- planMcp(canonical) {
54
+ async planMcp(canonical) {
55
55
  const configPath = join(this.homeDir, ".claude.json");
56
56
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
57
57
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
@@ -50,7 +50,7 @@ export class CodexAdapter {
50
50
  canonicalRoot: join(canonicalRoot, "skills"),
51
51
  kind: "skill",
52
52
  });
53
- const mcpItems = this.planMcp(canonical);
53
+ const mcpItems = await this.planMcp(canonical);
54
54
  const configuredInstructionsPath = readInstructionsPath(join(this.homeDir, ".codex", "config.toml"));
55
55
  if (!configuredInstructionsPath) {
56
56
  // Codex has no `instructions` key set — not Trellis's to decide.
@@ -65,10 +65,10 @@ export class CodexAdapter {
65
65
  });
66
66
  return [...skillItems, ...instructionsItems, ...mcpItems];
67
67
  }
68
- planMcp(canonical) {
68
+ async planMcp(canonical) {
69
69
  const configTomlPath = join(this.homeDir, ".codex", "config.toml");
70
70
  const content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
71
- const { desired, conflicts } = resolveMcpPlan(this.id, globalLayerMcp(canonical), canonical.managedAgents, canonical.secretsPolicy);
71
+ const { desired, conflicts } = await resolveMcpPlan(this.id, globalLayerMcp(canonical), canonical.managedAgents, canonical.secretsPolicy);
72
72
  const items = [];
73
73
  for (const { name, def } of desired) {
74
74
  const current = currentServerSectionText(content, name);
@@ -29,7 +29,7 @@ export declare function planJsonMcp(opts: {
29
29
  * zero removal candidates. */
30
30
  ownership?: Record<string, unknown>;
31
31
  render?: JsonMcpEntryRenderer;
32
- }): AdapterPlanItem[];
32
+ }): Promise<AdapterPlanItem[]>;
33
33
  /** Merges every `"create"` MCP item into `parsed` (or a fresh `{}` if the
34
34
  * file didn't exist) and deletes every `"remove"` item's key, returning
35
35
  * the object to stringify — every sibling top-level key on `parsed` is
@@ -30,10 +30,10 @@ export function renderJsonServerEntry(def) {
30
30
  }
31
31
  return entry;
32
32
  }
33
- export function planJsonMcp(opts) {
33
+ export async function planJsonMcp(opts) {
34
34
  const { configPath, parsed, mcp, agentId, managedAgents, policy, ownership } = opts;
35
35
  const render = opts.render ?? ((def, _name) => renderJsonServerEntry(def));
36
- const { desired, conflicts } = resolveMcpPlan(agentId, mcp, managedAgents, policy);
36
+ const { desired, conflicts } = await resolveMcpPlan(agentId, mcp, managedAgents, policy);
37
37
  const existingServers = parsed?.mcpServers ?? {};
38
38
  const items = [];
39
39
  for (const { name, def } of desired) {
@@ -63,7 +63,7 @@ export class KimiCodeAdapter {
63
63
  const configPath = join(this.homeDir, ".kimi-code", "mcp.json");
64
64
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
65
65
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
66
- items.push(...planJsonMcp({
66
+ items.push(...(await planJsonMcp({
67
67
  configPath,
68
68
  parsed,
69
69
  mcp: globalLayerMcp(canonical),
@@ -72,7 +72,7 @@ export class KimiCodeAdapter {
72
72
  policy: canonical.secretsPolicy,
73
73
  ownership,
74
74
  render: renderKimiServerEntry,
75
- }));
75
+ })));
76
76
  return items;
77
77
  }
78
78
  async apply(plan, backup) {
@@ -18,6 +18,7 @@
18
18
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
19
19
  import { dirname, join } from "node:path";
20
20
  import { homedir } from "node:os";
21
+ import { deliveredName } from "../lib/secretEnv.js";
21
22
  import { isInScope } from "../core/adapter.js";
22
23
  import { usesNativeCapabilityDelivery } from "../core/types.js";
23
24
  import * as kiroProbe from "../probes/kiro.js";
@@ -64,11 +65,11 @@ export class KiroAdapter {
64
65
  canonicalRoot,
65
66
  kind: "instructions",
66
67
  });
67
- const mcpItems = this.planMcp(canonical);
68
- const approvedEnvVarsItems = this.planApprovedEnvVars(canonical);
68
+ const mcpItems = await this.planMcp(canonical);
69
+ const approvedEnvVarsItems = await this.planApprovedEnvVars(canonical);
69
70
  return [...skillItems, ...instructionsItems, ...mcpItems, ...approvedEnvVarsItems];
70
71
  }
71
- planMcp(canonical) {
72
+ async planMcp(canonical) {
72
73
  const configPath = join(this.homeDir, ".kiro", "settings", "mcp.json");
73
74
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
74
75
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
@@ -77,17 +78,18 @@ export class KiroAdapter {
77
78
  /** Names come from `resolveMcpPlan`, not a raw scan of `canonical.mcp.servers`
78
79
  * (design.md D2) — a server scoped away from Kiro, or refused for a
79
80
  * known_host_injected collision, never contributes a name here either. */
80
- desiredApprovedEnvVars(canonical) {
81
- const { desired } = resolveMcpPlan(this.id, globalLayerMcp(canonical), canonical.managedAgents, canonical.secretsPolicy);
81
+ async desiredApprovedEnvVars(canonical) {
82
+ const { desired } = await resolveMcpPlan(this.id, globalLayerMcp(canonical), canonical.managedAgents, canonical.secretsPolicy);
82
83
  const names = new Set();
83
84
  for (const { def } of desired) {
85
+ // Approved list holds DELIVERED names (what renderers write), not raw entries.
84
86
  for (const name of declaredEnvNames(def))
85
- names.add(name);
87
+ names.add(deliveredName(name));
86
88
  }
87
89
  return [...names];
88
90
  }
89
- planApprovedEnvVars(canonical) {
90
- const desired = this.desiredApprovedEnvVars(canonical);
91
+ async planApprovedEnvVars(canonical) {
92
+ const desired = await this.desiredApprovedEnvVars(canonical);
91
93
  if (desired.length === 0) {
92
94
  return [];
93
95
  }
@@ -188,7 +190,7 @@ export class KiroAdapter {
188
190
  return [];
189
191
  }
190
192
  })();
191
- for (const name of this.desiredApprovedEnvVars(canonical)) {
193
+ for (const name of await this.desiredApprovedEnvVars(canonical)) {
192
194
  if (!approved.includes(name)) {
193
195
  mismatches.push(`env var "${name}" is needed by a canonical MCP server for kiro but missing from ${APPROVED_ENV_VARS_KEY}`);
194
196
  }
@@ -91,4 +91,4 @@ export interface LiteralSecretMatch {
91
91
  * as before this function reported a field at all.
92
92
  */
93
93
  export declare function findLiteralSecret(def: McpServerDef): LiteralSecretMatch | undefined;
94
- export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[], policy: SecretsPolicy): McpPlanResult;
94
+ export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[], policy: SecretsPolicy): Promise<McpPlanResult>;
@@ -20,7 +20,7 @@ import { isInScope } from "../core/adapter.js";
20
20
  import { capabilityDeliveryForAgent } from "../core/types.js";
21
21
  import { isOAuthAuth, oauthOwner } from "../core/types.js";
22
22
  import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
23
- import { resolveSecretEnv } from "../lib/secretEnv.js";
23
+ import { parseEnvEntry, resolveSecretEnv } from "../lib/secretEnv.js";
24
24
  export const HUB_ENTRY_NAME = "trellis-hub";
25
25
  export const GATEWAY_ENTRY_NAME = "trellis";
26
26
  export const LEGACY_GATEWAY_ENTRY_NAME = "trellis-gateway";
@@ -138,14 +138,25 @@ export function findLiteralSecret(def) {
138
138
  * the target key it would have been written under
139
139
  * (trellis-migrate-env-var-alias).
140
140
  */
141
- function findUnresolvedEnvName(def, policy) {
141
+ async function findUnresolvedEnvName(def, policy) {
142
142
  const names = [...(def.env ?? []), ...Object.values(def.envAliases ?? {})];
143
143
  if (names.length === 0)
144
144
  return undefined;
145
- const resolved = resolveSecretEnv(names, policy);
146
- return names.find((name) => !resolved[name]);
145
+ // A malformed `mxenv://` entry conflicts here too — it is rejected input,
146
+ // never a silently-resolved bare name (mx-env-secret-source D6). The
147
+ // returned text is the DECLARED entry; resolution checks use delivered
148
+ // names (the `KEY` segment for a reference).
149
+ const parsed = names.map(parseEnvEntry);
150
+ const invalid = parsed.find((p) => p.kind === "invalid");
151
+ if (invalid)
152
+ return invalid.entry;
153
+ const resolved = await resolveSecretEnv(names, policy);
154
+ return names.find((name) => {
155
+ const p = parseEnvEntry(name);
156
+ return !resolved[p.kind === "invalid" ? name : p.delivered];
157
+ });
147
158
  }
148
- export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
159
+ export async function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
149
160
  const route = routeForAgent(agentId, mcp, managedAgents);
150
161
  const runtimeEnabled = capabilityDeliveryForAgent(agentId, mcp) !== "native";
151
162
  // Checked before `hub`: only one entry can be "the" MCP entry for an
@@ -161,7 +172,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
161
172
  conflicts: [{ name: entryName, message: collisionMessage(entryName, agentId), remediation: collisionRemediation(entryName) }],
162
173
  };
163
174
  }
164
- const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
175
+ const oauth = await planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
165
176
  // Every ordinary per-server check (enabled, scope, literal secrets,
166
177
  // unresolved env names, Codex's header shape) still runs at gateway
167
178
  // startup. OAuth servers are the deliberate exception: they are written
@@ -237,7 +248,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
237
248
  // Runtime mode owns the agent-facing MCP edge. It resolves the same
238
249
  // direct/gateway-compatible upstream route at startup, so native
239
250
  // per-server entries are not duplicated here.
240
- const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
251
+ const oauth = await planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
241
252
  return {
242
253
  desired: [{
243
254
  name: RUNTIME_ENTRY_NAME,
@@ -246,14 +257,14 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
246
257
  conflicts: oauth.conflicts,
247
258
  };
248
259
  }
249
- const native = planDirectServers(agentId, mcp, managedAgents, policy, route);
260
+ const native = await planDirectServers(agentId, mcp, managedAgents, policy, route);
250
261
  const hosted = native.desired.filter(({ def }) => isOAuthMcpServer(def) && oauthOwner(def.auth) === "trellis");
251
262
  return {
252
263
  desired: native.desired.filter(({ def }) => !isOAuthMcpServer(def) || oauthOwner(def.auth) === "agent"),
253
264
  conflicts: [...native.conflicts, ...hosted.map(({ name }) => ({ name, message: `Trellis-owned OAuth server "${name}" requires gateway or runtime delivery for ${agentId}`, remediation: "enable gateway/runtime delivery or set auth.owner to agent" }))],
254
265
  };
255
266
  }
256
- function planDirectServers(agentId, mcp, managedAgents, policy, route, filter) {
267
+ async function planDirectServers(agentId, mcp, managedAgents, policy, route, filter) {
257
268
  const desired = [];
258
269
  const conflicts = [];
259
270
  for (const [name, def] of Object.entries(mcp.servers)) {
@@ -306,13 +317,25 @@ function planDirectServers(agentId, mcp, managedAgents, policy, route, filter) {
306
317
  });
307
318
  continue;
308
319
  }
309
- const unresolvedName = findUnresolvedEnvName(def, policy);
320
+ const unresolvedName = await findUnresolvedEnvName(def, policy);
310
321
  if (unresolvedName) {
311
- const source = policy.envFile ?? "process environment";
322
+ const entry = parseEnvEntry(unresolvedName);
323
+ if (entry.kind === "invalid") {
324
+ conflicts.push({
325
+ name,
326
+ message: `refusing to write MCP server "${name}": its declared env entry "${unresolvedName}" is not a valid mxenv://<profile>/<KEY> reference (mx-env-secret-source)`,
327
+ remediation: `fix the entry in servers.yaml to the mxenv://<profile>/<KEY> form or a plain variable name, then re-run mcp sync`,
328
+ });
329
+ continue;
330
+ }
331
+ const source = entry.kind === "ref"
332
+ ? `the mxenv composition (profile "${entry.profile}")`
333
+ : (policy.envFile ?? "process environment");
334
+ const remediationTarget = entry.kind === "ref" ? entry.key : unresolvedName;
312
335
  conflicts.push({
313
336
  name,
314
337
  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)`,
315
- remediation: `set "${unresolvedName}" in ${source}, or fix the name in servers.yaml if it was a typo, then re-run mcp sync`,
338
+ remediation: `set "${remediationTarget}" in ${source}, or fix the name in servers.yaml if it was a typo, then re-run mcp sync`,
316
339
  });
317
340
  continue;
318
341
  }
@@ -114,7 +114,7 @@ export class PiAdapter {
114
114
  kind: "instructions",
115
115
  });
116
116
  if (supportsNativeMcp(this.versionProbe())) {
117
- return [...skillItems, ...instructionsItems, ...this.planNativeMcp(canonical)];
117
+ return [...skillItems, ...instructionsItems, ...await this.planNativeMcp(canonical)];
118
118
  }
119
119
  const { file: bridgeFile, symlinkName } = resolveBridgeFile();
120
120
  const extensionItems = planSymlinks({
@@ -139,12 +139,12 @@ export class PiAdapter {
139
139
  * apply, and a bare skills/instructions sync can neither remove the
140
140
  * bridge early nor resurrect it afterwards.
141
141
  */
142
- planNativeMcp(canonical) {
142
+ async planNativeMcp(canonical) {
143
143
  const agentDir = join(this.homeDir, ".pi", "agent");
144
144
  const configPath = join(agentDir, "mcp.json");
145
145
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
146
146
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
147
- const items = planJsonMcp({
147
+ const items = (await planJsonMcp({
148
148
  configPath,
149
149
  parsed,
150
150
  mcp: globalLayerMcp(canonical),
@@ -153,7 +153,7 @@ export class PiAdapter {
153
153
  policy: canonical.secretsPolicy,
154
154
  ownership,
155
155
  render: piRenderer,
156
- }).map((item) => {
156
+ })).map((item) => {
157
157
  // pi speaks stdio and streamable HTTP only — an sse server gets an
158
158
  // honest conflict instead of an entry pi could never connect.
159
159
  const write = item.mcpWrite;
@@ -251,7 +251,7 @@ export class PiAdapter {
251
251
  // definition drift. The plan only ever names Trellis-owned entries
252
252
  // (the ownership ledger gates removals), so user-authored servers
253
253
  // can never surface here.
254
- for (const item of this.planNativeMcp(canonical)) {
254
+ for (const item of await this.planNativeMcp(canonical)) {
255
255
  if (item.action === "create" && item.mcpWrite) {
256
256
  mismatches.push(`mcp.json entry "${item.mcpWrite.name}" is missing or differs from canonical — run trellis mcp sync`);
257
257
  }
@@ -16,7 +16,7 @@ export declare function planZcodeMcp(opts: {
16
16
  managedAgents: readonly import("../core/types.js").AgentId[];
17
17
  policy: SecretsPolicy;
18
18
  ownership: JsonRecord;
19
- }): AdapterPlanItem[];
19
+ }): Promise<AdapterPlanItem[]>;
20
20
  export declare function applyZcodeMcp(parsed: JsonRecord | undefined, items: readonly AdapterPlanItem[]): JsonRecord;
21
21
  export declare class ZcodeAdapter implements TrellisAdapter {
22
22
  private readonly homeDir;
@@ -29,8 +29,8 @@ function record(value) {
29
29
  function zcodeServers(parsed) {
30
30
  return record(record(parsed?.mcp).servers);
31
31
  }
32
- export function planZcodeMcp(opts) {
33
- const { desired, conflicts } = resolveMcpPlan("zcode", opts.mcp, opts.managedAgents, opts.policy);
32
+ export async function planZcodeMcp(opts) {
33
+ const { desired, conflicts } = await resolveMcpPlan("zcode", opts.mcp, opts.managedAgents, opts.policy);
34
34
  const existing = zcodeServers(opts.parsed);
35
35
  const items = [];
36
36
  for (const { name, def } of desired) {
@@ -136,14 +136,14 @@ export class ZcodeAdapter {
136
136
  canonicalRoot,
137
137
  kind: "instructions",
138
138
  }));
139
- items.push(...planZcodeMcp({
139
+ items.push(...(await planZcodeMcp({
140
140
  configPath: profile.configPath,
141
141
  parsed,
142
142
  mcp: globalLayerMcp(canonical),
143
143
  managedAgents: canonical.managedAgents,
144
144
  policy: canonical.secretsPolicy,
145
145
  ownership: ownedByAgent(loadMcpOwnership(this.homeDir), this.id),
146
- }));
146
+ })));
147
147
  const current = currentSkillControls(parsed);
148
148
  const ownership = loadZcodeSettingsOwnership(this.homeDir);
149
149
  if (runtimeOnly && (current.featuresSkill !== false || current.skillsEnabled !== false)) {
@@ -214,14 +214,14 @@ export class ZcodeAdapter {
214
214
  for (const name of desiredNativeSkills)
215
215
  if (!actualNativeSkills.has(name))
216
216
  mismatches.push(`skill "${name}" is missing on ZCode`);
217
- const expectedMcp = planZcodeMcp({
217
+ const expectedMcp = (await planZcodeMcp({
218
218
  configPath: profile.configPath,
219
219
  parsed: readConfig(profile.configPath),
220
220
  mcp: globalLayerMcp(canonical),
221
221
  managedAgents: canonical.managedAgents,
222
222
  policy: canonical.secretsPolicy,
223
223
  ownership: ownedByAgent(loadMcpOwnership(this.homeDir), this.id),
224
- }).filter((item) => item.action === "create" || item.action === "conflict");
224
+ })).filter((item) => item.action === "create" || item.action === "conflict");
225
225
  mismatches.push(...expectedMcp.map((item) => item.description));
226
226
  if (!snapshot.instructionsFile?.isSymlink || snapshot.instructionsFile.target !== canonical.instructionsFile)
227
227
  mismatches.push("shared instructions are not linked into ~/.zcode/AGENTS.md");
@@ -137,7 +137,7 @@ export declare function detectMcpUnreachable(snapshots: AgentSnapshot[]): Findin
137
137
  /** Compares runtime entries against the same canonical MCP plan that sync
138
138
  * uses. Only missing Trellis-owned runtime edges are reported; extra
139
139
  * user-authored MCP entries remain outside doctor ownership. */
140
- export declare function detectRuntimeDrift(snapshots: AgentSnapshot[], canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): Finding[];
140
+ export declare function detectRuntimeDrift(snapshots: AgentSnapshot[], canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): Promise<Finding[]>;
141
141
  /** Exported so `trellis onboard` can reuse it verbatim for its own final
142
142
  * stage (trellis-onboard-closed-loop) rather than a second copy of this
143
143
  * formatting. */
@@ -178,7 +178,7 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
178
178
  // `collectWorkspaceDoctorSection`'s per-file inspection is what we want.
179
179
  let workspace;
180
180
  if (canonical?.workspace) {
181
- workspace = collectWorkspaceDoctorSection(canonical);
181
+ workspace = await collectWorkspaceDoctorSection(canonical);
182
182
  }
183
183
  else {
184
184
  const workspaceRoot = workspaceDir != null ? findWorkspaceRoot(workspaceDir) : null;
@@ -198,7 +198,7 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
198
198
  diagnostics: [],
199
199
  workspace: { root: workspaceRoot, mcpProvenance: {}, mcpGlobalServers: {}, skillProvenance: {}, globalSkills: [], shadowed: [] },
200
200
  };
201
- workspace = collectWorkspaceDoctorSection(minimal);
201
+ workspace = await collectWorkspaceDoctorSection(minimal);
202
202
  }
203
203
  }
204
204
  // Workspace TOML findings become top-level findings so the exit code
@@ -215,7 +215,7 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
215
215
  }
216
216
  }
217
217
  const oauth = canonical ? collectOAuthHealth(homeDir, canonical) : [];
218
- findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots), ...oauth.flatMap((entry) => (entry.status === "valid" ? [] : [oauthFinding(entry)])));
218
+ findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? await detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots), ...oauth.flatMap((entry) => (entry.status === "valid" ? [] : [oauthFinding(entry)])));
219
219
  // 分级开启(trellis-evolve 10.2):级别策略是显式用户选择,读取零副作用。
220
220
  // 未配置(或全空)时整段缺席 —— 从未开启自我进化的机器输出与未安装时
221
221
  // 一致;损坏的策略文件不拖垮扫描,降级为段内诊断呈现。
@@ -427,12 +427,12 @@ export function detectMcpUnreachable(snapshots) {
427
427
  /** Compares runtime entries against the same canonical MCP plan that sync
428
428
  * uses. Only missing Trellis-owned runtime edges are reported; extra
429
429
  * user-authored MCP entries remain outside doctor ownership. */
430
- export function detectRuntimeDrift(snapshots, canonical) {
430
+ export async function detectRuntimeDrift(snapshots, canonical) {
431
431
  const findings = [];
432
432
  for (const snap of snapshots) {
433
433
  if (!snap.present || !canonical.managedAgents.includes(snap.agent) || snap.agent === "pi")
434
434
  continue;
435
- const expected = resolveMcpPlan(snap.agent, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy).desired
435
+ const expected = (await resolveMcpPlan(snap.agent, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy)).desired
436
436
  .map((entry) => entry.name)
437
437
  .filter((name) => name === RUNTIME_ENTRY_NAME || name === GATEWAY_ENTRY_NAME);
438
438
  const actual = new Set(snap.mcpServers.map((server) => server.name));
@@ -65,7 +65,7 @@ export async function collectMcpSyncReport(opts = {}) {
65
65
  continue;
66
66
  }
67
67
  const globalItems = (await adapter.plan(canonical)).filter((item) => item.kind === "mcp");
68
- const wsItems = planWorkspaceMcp(adapter.id, canonical);
68
+ const wsItems = await planWorkspaceMcp(adapter.id, canonical);
69
69
  if (writeMode) {
70
70
  await adapter.apply(globalItems, backup);
71
71
  applyWorkspaceMcp(adapter.id, canonical, wsItems, backup);
@@ -114,7 +114,7 @@ export async function authorizeMcpServer(opts) {
114
114
  const client = oauthClientMetadata(def.auth);
115
115
  let clientSecret;
116
116
  if (client.clientSecretEnv) {
117
- const { [client.clientSecretEnv]: value } = resolveSecretEnv([client.clientSecretEnv], canonical.secretsPolicy);
117
+ const { [client.clientSecretEnv]: value } = await resolveSecretEnv([client.clientSecretEnv], canonical.secretsPolicy);
118
118
  if (value === undefined) {
119
119
  throw new Error(`auth.client_secret_env is "${client.clientSecretEnv}", but no value for that variable was found in ${secretSourceLabel(canonical.secretsPolicy)} — set it there, or drop the field if this provider issues a public client`);
120
120
  }
@@ -46,11 +46,11 @@ export declare function parseMcpGatewayArgs(argv: readonly string[]): {
46
46
  * connect to itself. Resolving against a gateway-less view of the same
47
47
  * canonical gives the set gateway mode replaced.
48
48
  */
49
- export declare function resolveGatewayUpstreams(agentId: AgentId, canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): {
49
+ export declare function resolveGatewayUpstreams(agentId: AgentId, canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): Promise<{
50
50
  upstreams: UpstreamSpec[];
51
51
  oauthServers: string[];
52
52
  conflicts: string[];
53
- };
53
+ }>;
54
54
  export declare function runMcpGateway(opts: RunMcpGatewayOptions): Promise<{
55
55
  exitCode: number;
56
56
  }>;
@@ -59,7 +59,7 @@ export function parseMcpGatewayArgs(argv) {
59
59
  * connect to itself. Resolving against a gateway-less view of the same
60
60
  * canonical gives the set gateway mode replaced.
61
61
  */
62
- export function resolveGatewayUpstreams(agentId, canonical) {
62
+ export async function resolveGatewayUpstreams(agentId, canonical) {
63
63
  const route = canonical.mcp.routes?.[agentId];
64
64
  const hubActive = route ? route.mode === "hub" : Boolean(canonical.mcp.hub) && !isGatewayAgent(agentId, canonical.mcp, canonical.managedAgents);
65
65
  if (hubActive) {
@@ -90,7 +90,7 @@ export function resolveGatewayUpstreams(agentId, canonical) {
90
90
  // ownership-gated removal clears it. Serving that stale entry would hand
91
91
  // every server, and every secret resolved into it, to the one agent the
92
92
  // user just said to stop involving.
93
- const { desired, conflicts } = resolveMcpPlan(agentId, direct, canonical.managedAgents, canonical.secretsPolicy);
93
+ const { desired, conflicts } = await resolveMcpPlan(agentId, direct, canonical.managedAgents, canonical.secretsPolicy);
94
94
  // Agent-owned OAuth servers bypass the gateway as direct entries, so no
95
95
  // connection state exists for them here — but Trellis owns the token
96
96
  // store, so `trellis.mcp.status` reports their credential health for
@@ -101,7 +101,7 @@ export function resolveGatewayUpstreams(agentId, canonical) {
101
101
  const agentOwnedServers = Object.fromEntries(Object.entries(canonical.mcp.servers).filter(([, def]) => isAgentOwnedOAuth(def)));
102
102
  const oauthServers = Object.keys(agentOwnedServers).length === 0
103
103
  ? []
104
- : resolveMcpPlan(agentId, { ...direct, servers: agentOwnedServers }, canonical.managedAgents, canonical.secretsPolicy).desired.map(({ name }) => name);
104
+ : (await resolveMcpPlan(agentId, { ...direct, servers: agentOwnedServers }, canonical.managedAgents, canonical.secretsPolicy)).desired.map(({ name }) => name);
105
105
  return {
106
106
  upstreams: desired.map(({ name }) => ({ name, def: canonical.mcp.servers[name] })),
107
107
  oauthServers,
@@ -131,7 +131,7 @@ async function runMcpEdge(opts, clientInfo, serverInfo) {
131
131
  console.error(`trellis-mcp-gateway: ${err instanceof Error ? err.message : String(err)}`);
132
132
  return { exitCode: 1 };
133
133
  }
134
- const { upstreams, oauthServers, conflicts } = resolveGatewayUpstreams(opts.agentId, canonical);
134
+ const { upstreams, oauthServers, conflicts } = await resolveGatewayUpstreams(opts.agentId, canonical);
135
135
  for (const message of conflicts) {
136
136
  console.error(`trellis-mcp-gateway: ${message}`);
137
137
  }
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { loadCanonicalSource, ensureGitignoreEntry, ensureShellEnvSource, upsertServerYaml, writeSecretsPolicyExtraction } from "../core/canonical.js";
12
+ import { deliveredName } from "../lib/secretEnv.js";
12
13
  import { openBackupSession } from "../lib/backup.js";
13
14
  import { parseDotenv, writeLocalSecretValue } from "../lib/secretEnv.js";
14
15
  import { collectMcpSyncReport, printReport as printMcpSyncReport } from "./mcp.js";
@@ -115,7 +116,7 @@ function semanticIdentity(def) {
115
116
  enabled: def.enabled,
116
117
  agents: def.agents ? sorted(def.agents) : undefined,
117
118
  staticEnv: def.staticEnv,
118
- envKeys: sorted([...(def.env ?? []), ...Object.keys(def.envAliases ?? {})]),
119
+ envKeys: sorted([...(def.env ?? []).map(deliveredName), ...Object.keys(def.envAliases ?? {})]),
119
120
  headerKeys: sorted(Object.keys(def.headers ?? {})),
120
121
  });
121
122
  }
@@ -35,7 +35,7 @@ export interface SecretsFinding {
35
35
  * `~/.trellis/mcp/servers.yaml` itself (see module doc comment). */
36
36
  agent: AgentId | "environment" | "canonical";
37
37
  file: string;
38
- kind: "literal-secret" | "unexpected-var-name" | "missing-env-value";
38
+ kind: "literal-secret" | "unexpected-var-name" | "missing-env-value" | "malformed-reference";
39
39
  detail: string;
40
40
  }
41
41
  export interface SecretsAuditReport {
@@ -29,7 +29,7 @@ import { KiroAdapter } from "../adapters/kiro.js";
29
29
  import { ZcodeAdapter } from "../adapters/zcode.js";
30
30
  import { declaredEnvNames, extractJsonEnvVarNames, extractTomlEnvVarNames, extractZcodeEnvVarNames } from "../lib/envVarNames.js";
31
31
  import { resolveZcodeProfile } from "../probes/zcode.js";
32
- import { resolveSecretEnv } from "../lib/secretEnv.js";
32
+ import { parseEnvEntry, resolveSecretEnv } from "../lib/secretEnv.js";
33
33
  /** Only agents in `managedAgents` — a present-but-unmanaged agent's config
34
34
  * file is never even read (trellis-managed-agents), same restriction as
35
35
  * `sync`/`mcp sync`. pi has no static, generated MCP config file to audit
@@ -49,7 +49,7 @@ function auditedAgents(homeDir, managedAgents) {
49
49
  * a problem regardless of which agents' `servers.yaml` `agents:` field
50
50
  * would route it to (trellis-secrets-env-management design.md D3).
51
51
  */
52
- function findMissingEnvValues(servers, policy) {
52
+ async function findMissingEnvValues(servers, policy) {
53
53
  const names = new Set();
54
54
  for (const def of Object.values(servers)) {
55
55
  for (const name of declaredEnvNames(def))
@@ -57,16 +57,39 @@ function findMissingEnvValues(servers, policy) {
57
57
  }
58
58
  if (names.size === 0)
59
59
  return [];
60
- const resolved = resolveSecretEnv([...names], policy);
61
- const source = policy.envFile ?? "process environment";
62
- return [...names]
63
- .filter((name) => !resolved[name])
64
- .map((name) => ({
65
- agent: "environment",
66
- file: source,
67
- kind: "missing-env-value",
68
- detail: `"${name}" is declared by a canonical MCP server's env but has no resolvable value`,
69
- }));
60
+ // Malformed `mxenv://` entries are findings in their own right — never
61
+ // skipped, never treated as bare names (mx-env-secret-source spec).
62
+ const entries = [...names];
63
+ const parsed = entries.map(parseEnvEntry);
64
+ const findings = [];
65
+ for (const p of parsed) {
66
+ if (p.kind === "invalid") {
67
+ findings.push({
68
+ agent: "environment",
69
+ file: policy.envFile ?? "process environment",
70
+ kind: "malformed-reference",
71
+ detail: `"${p.entry}" is not a valid mxenv://<profile>/<KEY> reference`,
72
+ });
73
+ }
74
+ }
75
+ const resolvable = entries.filter((_, i) => parsed[i].kind !== "invalid");
76
+ if (resolvable.length === 0)
77
+ return findings;
78
+ // Findings name the DELIVERED name (the KEY segment for a reference).
79
+ const resolved = await resolveSecretEnv(resolvable, policy);
80
+ for (const entry of resolvable) {
81
+ const p = parseEnvEntry(entry);
82
+ const delivered = p.kind === "invalid" ? entry : p.delivered;
83
+ if (!resolved[delivered]) {
84
+ findings.push({
85
+ agent: "environment",
86
+ file: p.kind === "ref" ? `the mxenv composition (profile "${p.profile}")` : (policy.envFile ?? "process environment"),
87
+ kind: "missing-env-value",
88
+ detail: `"${delivered}" is declared by a canonical MCP server's env but has no resolvable value`,
89
+ });
90
+ }
91
+ }
92
+ return findings;
70
93
  }
71
94
  /**
72
95
  * Reject-pattern-only scan of canonical's own `mcp/servers.yaml` (design.md
@@ -118,7 +141,7 @@ export async function collectSecretsAuditReport(opts = {}) {
118
141
  const content = readFileSync(file, "utf-8");
119
142
  findings.push(...auditFile(agent.id, file, content, canonical.secretsPolicy, agent.extractNames));
120
143
  }
121
- findings.push(...findMissingEnvValues(canonical.mcp.servers, canonical.secretsPolicy));
144
+ findings.push(...await findMissingEnvValues(canonical.mcp.servers, canonical.secretsPolicy));
122
145
  findings.push(...auditCanonicalServersYaml(homeDir, canonical.secretsPolicy));
123
146
  return { findings };
124
147
  }
@@ -22,7 +22,14 @@ export function extractTemplateVarNames(value) {
22
22
  * same answer to "what names does this def reference," not two
23
23
  * separately-maintained collections. */
24
24
  export function declaredEnvNames(def) {
25
+ // RAW entries, including `envAliases` sources (mx-env-secret-source
26
+ // secrets-audit delta): consumers that RESOLVE must see the original
27
+ // `mxenv://` text so references go through composition, then look the
28
+ // result up by delivered name; consumers that want the delivered NAMES
29
+ // map through `deliveredName` at their own use site (e.g. Kiro).
25
30
  const names = new Set(def.env ?? []);
31
+ for (const source of Object.values(def.envAliases ?? {}))
32
+ names.add(source);
26
33
  for (const value of Object.values(def.headers ?? {})) {
27
34
  for (const name of extractTemplateVarNames(value))
28
35
  names.add(name);
@@ -45,7 +45,7 @@ export declare function connectWithCleanup<T, TTransport extends {
45
45
  connect(transport: TTransport): Promise<unknown>;
46
46
  }, transport: TTransport, timeoutMs: number, message: string): Promise<T>;
47
47
  export declare function connectStdio(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
48
- export declare function resolveHeaders(def: McpServerDef, secretsPolicy: SecretsPolicy): Record<string, string> | undefined;
48
+ export declare function resolveHeaders(def: McpServerDef, secretsPolicy: SecretsPolicy): Promise<Record<string, string> | undefined>;
49
49
  export declare function connectHttp(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo, authProvider?: AuthProvider): Promise<Client>;
50
50
  export declare function connectSse(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo, authProvider?: AuthProvider): Promise<Client>;
51
51
  /**