agent-trellis 0.19.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 (127) 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/cli.js +12 -1
  13. package/dist/commands/doctor.d.ts +9 -1
  14. package/dist/commands/doctor.js +92 -7
  15. package/dist/commands/evolve.d.ts +43 -0
  16. package/dist/commands/evolve.js +479 -0
  17. package/dist/commands/mcp.js +1 -1
  18. package/dist/commands/mcpAuth.js +1 -1
  19. package/dist/commands/mcpGateway.d.ts +30 -2
  20. package/dist/commands/mcpGateway.js +44 -10
  21. package/dist/commands/mcpImport.js +2 -1
  22. package/dist/commands/secretsAudit.d.ts +1 -1
  23. package/dist/commands/secretsAudit.js +36 -13
  24. package/dist/evolve/agentView.d.ts +32 -0
  25. package/dist/evolve/agentView.js +34 -0
  26. package/dist/evolve/apply.d.ts +71 -0
  27. package/dist/evolve/apply.js +162 -0
  28. package/dist/evolve/archive.d.ts +23 -0
  29. package/dist/evolve/archive.js +58 -0
  30. package/dist/evolve/erase.d.ts +17 -0
  31. package/dist/evolve/erase.js +65 -0
  32. package/dist/evolve/extension.d.ts +37 -0
  33. package/dist/evolve/extension.js +63 -0
  34. package/dist/evolve/gate.d.ts +95 -0
  35. package/dist/evolve/gate.js +295 -0
  36. package/dist/evolve/importHistory.d.ts +91 -0
  37. package/dist/evolve/importHistory.js +239 -0
  38. package/dist/evolve/jobMain.d.ts +22 -0
  39. package/dist/evolve/jobMain.js +52 -0
  40. package/dist/evolve/jobRun.d.ts +94 -0
  41. package/dist/evolve/jobRun.js +273 -0
  42. package/dist/evolve/jobShell.d.ts +46 -0
  43. package/dist/evolve/jobShell.js +75 -0
  44. package/dist/evolve/l2.d.ts +93 -0
  45. package/dist/evolve/l2.js +81 -0
  46. package/dist/evolve/lesson.d.ts +57 -0
  47. package/dist/evolve/lesson.js +97 -0
  48. package/dist/evolve/pending.d.ts +69 -0
  49. package/dist/evolve/pending.js +116 -0
  50. package/dist/evolve/probeWorkspace.d.ts +57 -0
  51. package/dist/evolve/probeWorkspace.js +69 -0
  52. package/dist/evolve/propose.d.ts +73 -0
  53. package/dist/evolve/propose.js +136 -0
  54. package/dist/evolve/recorder.d.ts +68 -0
  55. package/dist/evolve/recorder.js +169 -0
  56. package/dist/evolve/report.d.ts +26 -0
  57. package/dist/evolve/report.js +37 -0
  58. package/dist/evolve/reportView.d.ts +96 -0
  59. package/dist/evolve/reportView.js +311 -0
  60. package/dist/evolve/risk.d.ts +106 -0
  61. package/dist/evolve/risk.js +224 -0
  62. package/dist/evolve/sources.d.ts +19 -0
  63. package/dist/evolve/sources.js +29 -0
  64. package/dist/evolve/stateDir.d.ts +21 -0
  65. package/dist/evolve/stateDir.js +56 -0
  66. package/dist/evolve/surface.d.ts +34 -0
  67. package/dist/evolve/surface.js +72 -0
  68. package/dist/evolve/trajectory.d.ts +25 -0
  69. package/dist/evolve/trajectory.js +88 -0
  70. package/dist/evolve/treeToCanonical.d.ts +28 -0
  71. package/dist/evolve/treeToCanonical.js +79 -0
  72. package/dist/evolve/trellisSubstrate.d.ts +65 -0
  73. package/dist/evolve/trellisSubstrate.js +154 -0
  74. package/dist/evolve/trigger.d.ts +177 -0
  75. package/dist/evolve/trigger.js +400 -0
  76. package/dist/evolve/types.d.ts +126 -0
  77. package/dist/evolve/types.js +15 -0
  78. package/dist/evolve/validate.d.ts +139 -0
  79. package/dist/evolve/validate.js +315 -0
  80. package/dist/host/checks.d.ts +57 -0
  81. package/dist/host/checks.js +126 -0
  82. package/dist/host/delivery.d.ts +42 -0
  83. package/dist/host/delivery.js +59 -0
  84. package/dist/host/edits.d.ts +87 -0
  85. package/dist/host/edits.js +102 -0
  86. package/dist/host/hostEvents.d.ts +74 -0
  87. package/dist/host/hostEvents.js +45 -0
  88. package/dist/host/index.d.ts +22 -0
  89. package/dist/host/index.js +17 -0
  90. package/dist/host/levels.d.ts +106 -0
  91. package/dist/host/levels.js +206 -0
  92. package/dist/host/staging.d.ts +56 -0
  93. package/dist/host/staging.js +106 -0
  94. package/dist/host/surface.d.ts +36 -0
  95. package/dist/host/surface.js +20 -0
  96. package/dist/lib/backup.js +12 -3
  97. package/dist/lib/envVarNames.js +7 -0
  98. package/dist/lib/fsAtomic.d.ts +7 -0
  99. package/dist/lib/fsAtomic.js +14 -0
  100. package/dist/lib/hostEventBus.d.ts +47 -0
  101. package/dist/lib/hostEventBus.js +94 -0
  102. package/dist/lib/hostExtensions.d.ts +75 -0
  103. package/dist/lib/hostExtensions.js +196 -0
  104. package/dist/lib/mcpConnect.d.ts +1 -1
  105. package/dist/lib/mcpConnect.js +19 -8
  106. package/dist/lib/mcpMigrateRead.js +14 -2
  107. package/dist/lib/mcpRuntime.d.ts +12 -1
  108. package/dist/lib/mcpRuntime.js +34 -2
  109. package/dist/lib/mcpToolRegistry.d.ts +18 -2
  110. package/dist/lib/mcpToolRegistry.js +29 -9
  111. package/dist/lib/secretEnv.d.ts +53 -8
  112. package/dist/lib/secretEnv.js +78 -15
  113. package/dist/lib/tomlSection.js +3 -2
  114. package/dist/lib/workspaceDoctor.d.ts +1 -1
  115. package/dist/lib/workspaceDoctor.js +8 -4
  116. package/dist/lib/workspaceMcp.d.ts +1 -1
  117. package/dist/lib/workspaceMcp.js +5 -5
  118. package/dist/pi-bridge/bundle.js +42236 -2701
  119. package/dist/pi-bridge/index.js +2 -2
  120. package/docs/magpie-gateway-research.md +422 -0
  121. package/docs/research.md +4 -1
  122. package/docs/roadmap.md +22 -12
  123. package/docs/runtime-infra.md +41 -22
  124. package/docs/self-evolution.md +138 -44
  125. package/package.json +8 -2
  126. package/schema/secrets.policy.example.yaml +5 -0
  127. package/schema/servers.example.yaml +5 -1
@@ -81,12 +81,23 @@ export function allocateExposedToolNames(candidates) {
81
81
  // registry (for example `fixture__env` reaching the Runtime provider).
82
82
  // Do not add a second provider prefix around it.
83
83
  const sourceQualified = normalized.includes("__") || normalized === source || normalized.startsWith(`${source}_`) || normalized.startsWith(`${source}__`);
84
+ // Narrower than `sourceQualified`: for forced-prefix candidates the
85
+ // exemption applies only when the tool name already starts with ITS
86
+ // OWN server's name (a second prefix would be redundant, e.g.
87
+ // `tanka__tanka_memo_search`). A tool that merely contains `__`
88
+ // (`b__c`) or gained a `__hash` length bound still needs its server
89
+ // prefix to be identifiable.
90
+ const serverRedundant = normalized === source || normalized.startsWith(`${source}_`) || normalized.startsWith(`${source}__`);
84
91
  const unique = (counts.get(candidate.toolName) ?? 0) === 1;
85
- const base = unique && normalized.length > SHORT_EXPOSED_TOOL_NAME_LENGTH
86
- ? normalized
87
- : unique && sourceQualified
92
+ const base = candidate.alwaysPrefix
93
+ ? serverRedundant
88
94
  ? normalized
89
- : prefixedToolName(candidate.serverName, candidate.toolName);
95
+ : prefixedToolName(candidate.serverName, candidate.toolName)
96
+ : unique && normalized.length > SHORT_EXPOSED_TOOL_NAME_LENGTH
97
+ ? normalized
98
+ : unique && sourceQualified
99
+ ? normalized
100
+ : prefixedToolName(candidate.serverName, candidate.toolName);
90
101
  let exposed = base;
91
102
  let suffix = 2;
92
103
  while (used.has(exposed)) {
@@ -122,7 +133,7 @@ export class McpToolRegistry {
122
133
  */
123
134
  add(serverName, upstream, tools) {
124
135
  for (const tool of tools)
125
- this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
136
+ this.candidates.push({ serverName, toolName: tool.name, upstream, tool, alwaysPrefix: true });
126
137
  this.rebuild();
127
138
  const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
128
139
  return { added, skipped: [] };
@@ -141,7 +152,7 @@ export class McpToolRegistry {
141
152
  this.candidates.length = 0;
142
153
  this.candidates.push(...kept);
143
154
  for (const tool of tools)
144
- this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
155
+ this.candidates.push({ serverName, toolName: tool.name, upstream, tool, alwaysPrefix: true });
145
156
  this.rebuild();
146
157
  const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
147
158
  return { added, skipped: [] };
@@ -154,8 +165,13 @@ export class McpToolRegistry {
154
165
  return undefined;
155
166
  }
156
167
  /** Every registered tool, under its prefixed name, in registration
157
- * order. Each tool's own `description`/`inputSchema` and any additional
158
- * fields the upstream sent are preserved verbatim; only `name` changes. */
168
+ * order. Each tool's own `inputSchema` and any additional fields the
169
+ * upstream sent are preserved verbatim; `name` changes, and the
170
+ * description gains a trailing `Source: <server>` line so an Agent can
171
+ * confirm the owning server without relying on the name alone
172
+ * (mcp-gateway-hosting: "Resold tool metadata identifies its upstream
173
+ * source"). The upstream's own description text is never altered or
174
+ * removed — the source line is appended. */
159
175
  listTools() {
160
176
  this.rebuild();
161
177
  return this.order.map((name) => {
@@ -163,7 +179,11 @@ export class McpToolRegistry {
163
179
  const title = name === entry.toolName || entry.tool.title !== undefined
164
180
  ? entry.tool.title
165
181
  : `${entry.serverName} / ${entry.toolName}`;
166
- return { ...entry.tool, name, ...(title === undefined ? {} : { title }) };
182
+ const sourceLine = `Source: ${entry.serverName}`;
183
+ const description = entry.tool.description
184
+ ? `${entry.tool.description}\n\n${sourceLine}`
185
+ : sourceLine;
186
+ return { ...entry.tool, name, description, ...(title === undefined ? {} : { title }) };
167
187
  });
168
188
  }
169
189
  /** The owning upstream and original tool name for a prefixed name, or
@@ -4,9 +4,15 @@
4
4
  * `process.env[name]` lookup — so they can never disagree about where a
5
5
  * declared name's value comes from (design.md D1).
6
6
  *
7
- * Dependency-free by design, same as src/lib/envVarNames.ts: a full
8
- * dotenv library's quoting/escaping/interpolation rules are more than
9
- * this narrow need requires (design.md D4).
7
+ * Entries are either bare environment variable names (byte-identical
8
+ * legacy semantics: `env_file` sole-source when set, otherwise ambient
9
+ * `process.env`) or `mxenv://<profile>/<KEY>` references resolved
10
+ * through the `@morphixai/mxenv-core` SDK and delivered under the `KEY`
11
+ * segment (mx-env-secret-source spec). The dotenv half stays
12
+ * dependency-free — a full dotenv library's quoting/escaping/
13
+ * interpolation rules are more than this narrow need requires — while
14
+ * the reference grammar is owned by the SDK and never re-implemented
15
+ * here.
10
16
  */
11
17
  import type { SecretsPolicy } from "../core/types.js";
12
18
  import type { BackupSession } from "./backup.js";
@@ -17,12 +23,51 @@ import type { BackupSession } from "./backup.js";
17
23
  export declare function isValidSecretVarName(name: string): boolean;
18
24
  export declare function parseDotenv(content: string): Record<string, string>;
19
25
  /**
20
- * `policy.envFile` set: it is the SOLE source — a name absent from it
21
- * resolves to `undefined`, `process.env` is never consulted (a silent
22
- * fallback would defeat the isolation this exists to offer). Unset:
23
- * reads `process.env` directly, identical to every pre-existing caller.
26
+ * One declared `env` entry / `envAliases` source value, classified.
27
+ * Strict whole-string matching: an entry is a reference only when the
28
+ * entire entry is exactly one `mxenv://<profile>/<KEY>` (reconstructed
29
+ * from the SDK parser's match). Anything containing reference-shaped
30
+ * text that does not fully match — including the rejected legacy
31
+ * `mx-env://` scheme, which the SDK parser throws on — is `invalid`,
32
+ * never a silently-resolved bare name (mx-env-secret-source D6).
24
33
  */
25
- export declare function resolveSecretEnv(names: string[], policy: SecretsPolicy): Record<string, string | undefined>;
34
+ export type SecretEnvEntry = {
35
+ kind: "ref";
36
+ profile: string;
37
+ key: string;
38
+ delivered: string;
39
+ } | {
40
+ kind: "name";
41
+ delivered: string;
42
+ } | {
43
+ kind: "invalid";
44
+ entry: string;
45
+ };
46
+ /** Delivered name for an entry (KEY segment for a reference, the entry
47
+ * itself otherwise; an invalid entry passes through unchanged so error
48
+ * paths can still name it). The single derivation every renderer and
49
+ * consumer uses — never re-derive from the raw text. */
50
+ export declare function deliveredName(entry: string): string;
51
+ export declare function parseEnvEntry(entry: string): SecretEnvEntry;
52
+ /**
53
+ * Result keyed by DELIVERED name: the `KEY` segment for a reference,
54
+ * the entry itself for a bare name (mx-env-secret-source spec).
55
+ *
56
+ * `policy.envFile` set: it is the SOLE source for bare names — a name
57
+ * absent from it resolves to `undefined`, `process.env` is never
58
+ * consulted (a silent fallback would defeat the isolation this exists
59
+ * to offer). Unset: bare names read `process.env` directly, identical
60
+ * to every pre-existing caller. References NEVER consult `env_file` or
61
+ * ambient env: they resolve from the composed mxenv profile only, so a
62
+ * same-named ambient variable cannot shadow the composed value.
63
+ *
64
+ * Invalid entries reject the batch with a descriptive error; audit and
65
+ * the plan pre-check pre-classify to turn them into findings/conflicts
66
+ * before this point (the bridge only ever sees pre-checked defs).
67
+ * Compositions are cached per batch (one `ReferenceResolver` per call,
68
+ * not per name).
69
+ */
70
+ export declare function resolveSecretEnv(entries: string[], policy: SecretsPolicy): Promise<Record<string, string | undefined>>;
26
71
  export type WriteLocalSecretOutcome = "created" | "already-present" | "conflict";
27
72
  /**
28
73
  * `trellis migrate`'s static-env secret extraction
@@ -4,12 +4,20 @@
4
4
  * `process.env[name]` lookup — so they can never disagree about where a
5
5
  * declared name's value comes from (design.md D1).
6
6
  *
7
- * Dependency-free by design, same as src/lib/envVarNames.ts: a full
8
- * dotenv library's quoting/escaping/interpolation rules are more than
9
- * this narrow need requires (design.md D4).
7
+ * Entries are either bare environment variable names (byte-identical
8
+ * legacy semantics: `env_file` sole-source when set, otherwise ambient
9
+ * `process.env`) or `mxenv://<profile>/<KEY>` references resolved
10
+ * through the `@morphixai/mxenv-core` SDK and delivered under the `KEY`
11
+ * segment (mx-env-secret-source spec). The dotenv half stays
12
+ * dependency-free — a full dotenv library's quoting/escaping/
13
+ * interpolation rules are more than this narrow need requires — while
14
+ * the reference grammar is owned by the SDK and never re-implemented
15
+ * here.
10
16
  */
11
17
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
12
19
  import { dirname } from "node:path";
20
+ import { findMxEnvReferences, loadConfigLayers, ReferenceResolver } from "@morphixai/mxenv-core";
13
21
  const LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
14
22
  const VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
23
  /** The name half of LINE_RE, standing alone: what counts as a variable
@@ -34,22 +42,77 @@ export function parseDotenv(content) {
34
42
  function loadEnvFile(path) {
35
43
  return existsSync(path) ? parseDotenv(readFileSync(path, "utf-8")) : {};
36
44
  }
45
+ /** Delivered name for an entry (KEY segment for a reference, the entry
46
+ * itself otherwise; an invalid entry passes through unchanged so error
47
+ * paths can still name it). The single derivation every renderer and
48
+ * consumer uses — never re-derive from the raw text. */
49
+ export function deliveredName(entry) {
50
+ const parsed = parseEnvEntry(entry);
51
+ return parsed.kind === "invalid" ? entry : parsed.delivered;
52
+ }
53
+ export function parseEnvEntry(entry) {
54
+ if (entry.includes("mxenv://") || entry.includes("mx-env://")) {
55
+ try {
56
+ const refs = findMxEnvReferences(entry);
57
+ if (refs.length === 1 && `mxenv://${refs[0].profile}/${refs[0].key}` === entry) {
58
+ return { kind: "ref", profile: refs[0].profile, key: refs[0].key, delivered: refs[0].key };
59
+ }
60
+ }
61
+ catch {
62
+ // legacy scheme rejection from the SDK parser — falls through to invalid
63
+ }
64
+ return { kind: "invalid", entry };
65
+ }
66
+ return { kind: "name", delivered: entry };
67
+ }
37
68
  /**
38
- * `policy.envFile` set: it is the SOLE source — a name absent from it
39
- * resolves to `undefined`, `process.env` is never consulted (a silent
40
- * fallback would defeat the isolation this exists to offer). Unset:
41
- * reads `process.env` directly, identical to every pre-existing caller.
69
+ * Result keyed by DELIVERED name: the `KEY` segment for a reference,
70
+ * the entry itself for a bare name (mx-env-secret-source spec).
71
+ *
72
+ * `policy.envFile` set: it is the SOLE source for bare names — a name
73
+ * absent from it resolves to `undefined`, `process.env` is never
74
+ * consulted (a silent fallback would defeat the isolation this exists
75
+ * to offer). Unset: bare names read `process.env` directly, identical
76
+ * to every pre-existing caller. References NEVER consult `env_file` or
77
+ * ambient env: they resolve from the composed mxenv profile only, so a
78
+ * same-named ambient variable cannot shadow the composed value.
79
+ *
80
+ * Invalid entries reject the batch with a descriptive error; audit and
81
+ * the plan pre-check pre-classify to turn them into findings/conflicts
82
+ * before this point (the bridge only ever sees pre-checked defs).
83
+ * Compositions are cached per batch (one `ReferenceResolver` per call,
84
+ * not per name).
42
85
  */
43
- export function resolveSecretEnv(names, policy) {
86
+ export async function resolveSecretEnv(entries, policy) {
87
+ const parsed = entries.map(parseEnvEntry);
88
+ const invalid = parsed.find((p) => p.kind === "invalid");
89
+ if (invalid) {
90
+ throw new Error(`[trellis] invalid mxenv:// reference entry: ${invalid.entry} — expected mxenv://<profile>/<KEY>`);
91
+ }
44
92
  const result = {};
45
- if (policy.envFile) {
46
- const map = loadEnvFile(policy.envFile);
47
- for (const name of names)
48
- result[name] = map[name];
93
+ // Delivered-name collisions (several entries delivering one name):
94
+ // references are authoritative — a composed value never loses to a bare
95
+ // name's ambient/file value (ambient must not shadow references); a
96
+ // DEFINED value never loses to undefined; within one class the first
97
+ // declaration wins; bare names only fill keys nothing has filled.
98
+ const refs = parsed.filter((p) => p.kind === "ref");
99
+ if (refs.length > 0) {
100
+ const resolver = new ReferenceResolver(loadConfigLayers({ cwd: homedir(), homeDir: homedir() }), {});
101
+ for (const ref of refs) {
102
+ if (result[ref.delivered] !== undefined)
103
+ continue;
104
+ const { value } = await resolver.valueOf(ref.profile, ref.key);
105
+ result[ref.delivered] = value;
106
+ }
49
107
  }
50
- else {
51
- for (const name of names)
52
- result[name] = process.env[name];
108
+ const names = parsed.filter((p) => p.kind === "name");
109
+ if (names.length > 0) {
110
+ const fileMap = policy.envFile ? loadEnvFile(policy.envFile) : undefined;
111
+ for (const name of names) {
112
+ if (result[name.delivered] !== undefined)
113
+ continue;
114
+ result[name.delivered] = fileMap ? fileMap[name.delivered] : process.env[name.delivered];
115
+ }
53
116
  }
54
117
  return result;
55
118
  }
@@ -8,6 +8,7 @@
8
8
  * ever needs to recognize table headers and render a bounded, known
9
9
  * shape (`McpServerDef`), never general TOML.
10
10
  */
11
+ import { deliveredName } from "./secretEnv.js";
11
12
  /**
12
13
  * Matches a TOML table header line, and *only* a table header line — the
13
14
  * entire trimmed line must be exactly `[key.path]` or `[[key.path]]`,
@@ -146,7 +147,7 @@ export function renderServerSection(name, def) {
146
147
  if (def.args && def.args.length > 0)
147
148
  lines.push(`args = ${tomlStringArray(def.args)}`);
148
149
  if (def.env && def.env.length > 0)
149
- lines.push(`env_vars = ${tomlStringArray(def.env)}`);
150
+ lines.push(`env_vars = ${tomlStringArray(def.env.map(deliveredName))}`);
150
151
  }
151
152
  else {
152
153
  if (def.url)
@@ -160,7 +161,7 @@ export function renderServerSection(name, def) {
160
161
  // expands it from its own ambient environment, the same mechanism
161
162
  // `env_vars` already relies on for a same-named reference
162
163
  // (trellis-migrate-env-var-alias D3).
163
- const aliasEntries = Object.entries(def.envAliases ?? {}).map(([targetKey, sourceName]) => [targetKey, `\${${sourceName}}`]);
164
+ const aliasEntries = Object.entries(def.envAliases ?? {}).map(([targetKey, sourceName]) => [targetKey, `\${${deliveredName(sourceName)}}`]);
164
165
  const staticEnvEntries = [...aliasEntries, ...Object.entries(def.staticEnv ?? {})];
165
166
  if (staticEnvEntries.length > 0) {
166
167
  lines.push(`[${envHeader(name)}]`);
@@ -56,4 +56,4 @@ export interface WorkspaceDoctorSection {
56
56
  /** zcode's user-level-first deviation notices (empty otherwise). */
57
57
  precedenceWarnings: string[];
58
58
  }
59
- export declare function collectWorkspaceDoctorSection(canonical: CanonicalSource): WorkspaceDoctorSection | undefined;
59
+ export declare function collectWorkspaceDoctorSection(canonical: CanonicalSource): Promise<WorkspaceDoctorSection | undefined>;
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { existsSync, readFileSync } from "node:fs";
13
13
  import { parse as parseToml } from "smol-toml";
14
- import { resolveSecretEnv } from "./secretEnv.js";
14
+ import { deliveredName, parseEnvEntry, resolveSecretEnv } from "./secretEnv.js";
15
15
  import { loadWorkspaceRendered, workspaceMcpConfigPath, workspacePrecedenceWarnings } from "./workspaceMcp.js";
16
16
  const VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
17
17
  function collectVarRefs(canonical) {
@@ -86,7 +86,7 @@ function unmarkedEntries(agentId, configPath, recorded) {
86
86
  }
87
87
  return { kind: "ok", names: names.filter((name) => recorded[name] === undefined) };
88
88
  }
89
- export function collectWorkspaceDoctorSection(canonical) {
89
+ export async function collectWorkspaceDoctorSection(canonical) {
90
90
  const workspace = canonical.workspace;
91
91
  if (!workspace)
92
92
  return undefined;
@@ -101,8 +101,12 @@ export function collectWorkspaceDoctorSection(canonical) {
101
101
  // overridden.
102
102
  const tombstoned = workspace.shadowed.filter((entry) => entry.startsWith("mcp:") && canonical.mcp.servers[entry.slice(4)] === undefined);
103
103
  const refs = collectVarRefs(canonical);
104
- const resolved = resolveSecretEnv([...new Set(refs.map((r) => r.name))], canonical.secretsPolicy);
105
- const unresolvedVars = refs.filter((r) => resolved[r.name] === undefined);
104
+ // Malformed `mxenv://` entries are unresolved as-is (they name themselves);
105
+ // they must never reach resolveSecretEnv, which rejects the batch.
106
+ const invalid = refs.filter((r) => parseEnvEntry(r.name).kind === "invalid");
107
+ const resolvableRefs = refs.filter((r) => parseEnvEntry(r.name).kind !== "invalid");
108
+ const resolved = await resolveSecretEnv([...new Set(resolvableRefs.map((r) => r.name))], canonical.secretsPolicy);
109
+ const unresolvedVars = [...invalid, ...resolvableRefs.filter((r) => resolved[deliveredName(r.name)] === undefined)];
106
110
  const manifest = loadWorkspaceRendered(workspace.root);
107
111
  const unmigrated = [];
108
112
  const tomlFindings = [];
@@ -53,5 +53,5 @@ export declare function renderWorkspaceManifestEntry(agentId: AgentId, name: str
53
53
  * `applyWorkspaceMcp`; this exists only for adoption, where no delivery
54
54
  * write to that agent's file has happened yet. */
55
55
  export declare function recordWorkspaceRendered(workspaceRoot: string, agentId: AgentId, name: string, rendered: unknown, backup: BackupSession): void;
56
- export declare function planWorkspaceMcp(agentId: AgentId, canonical: CanonicalSource): AdapterPlanItem[];
56
+ export declare function planWorkspaceMcp(agentId: AgentId, canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
57
57
  export declare function applyWorkspaceMcp(agentId: AgentId, canonical: CanonicalSource, items: AdapterPlanItem[], backup: BackupSession): void;
@@ -140,7 +140,7 @@ export function recordWorkspaceRendered(workspaceRoot, agentId, name, rendered,
140
140
  mkdirSync(dirname(path), { recursive: true });
141
141
  backup.writeFile(path, `${JSON.stringify(next, null, 2)}\n`);
142
142
  }
143
- export function planWorkspaceMcp(agentId, canonical) {
143
+ export async function planWorkspaceMcp(agentId, canonical) {
144
144
  const workspace = canonical.workspace;
145
145
  if (!workspace)
146
146
  return [];
@@ -151,7 +151,7 @@ export function planWorkspaceMcp(agentId, canonical) {
151
151
  const layer = (item) => ({ ...item, layer: "workspace" });
152
152
  if (agentId === "codex") {
153
153
  const content = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "";
154
- const { desired, conflicts } = resolveMcpPlan(agentId, mcp, canonical.managedAgents, canonical.secretsPolicy);
154
+ const { desired, conflicts } = await resolveMcpPlan(agentId, mcp, canonical.managedAgents, canonical.secretsPolicy);
155
155
  const items = [];
156
156
  for (const { name, def } of desired) {
157
157
  const current = currentServerSectionText(content, name);
@@ -183,13 +183,13 @@ export function planWorkspaceMcp(agentId, canonical) {
183
183
  }
184
184
  if (agentId === "zcode") {
185
185
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
186
- const items = planZcodeMcp({ configPath, parsed, mcp, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership }).map(layer);
186
+ const items = (await planZcodeMcp({ configPath, parsed, mcp, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership })).map(layer);
187
187
  const servers = (parsed?.mcp?.servers ?? {});
188
188
  return guardUnmarkedOverwrite(items, (item) => servers[item.mcpWrite.name] !== undefined && ownership[item.mcpWrite.name] === undefined);
189
189
  }
190
190
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
191
191
  const render = agentId === "pi" ? piRenderer : undefined;
192
- const planned = planJsonMcp({
192
+ const planned = (await planJsonMcp({
193
193
  configPath,
194
194
  parsed,
195
195
  mcp,
@@ -198,7 +198,7 @@ export function planWorkspaceMcp(agentId, canonical) {
198
198
  policy: canonical.secretsPolicy,
199
199
  ownership,
200
200
  ...(render ? { render } : {}),
201
- }).map(layer);
201
+ })).map(layer);
202
202
  const existing = (parsed?.mcpServers ?? {});
203
203
  const guarded = guardUnmarkedOverwrite(planned, (item) => existing[item.mcpWrite.name] !== undefined && ownership[item.mcpWrite.name] === undefined);
204
204
  if (agentId !== "pi")