agent-trellis 0.5.2 → 0.6.2

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 (57) hide show
  1. package/dist/adapters/codex.js +4 -1
  2. package/dist/adapters/jsonMcp.js +4 -1
  3. package/dist/adapters/kiro.js +9 -1
  4. package/dist/adapters/mcpPlan.d.ts +31 -0
  5. package/dist/adapters/mcpPlan.js +67 -3
  6. package/dist/adapters/symlinkPlan.js +3 -0
  7. package/dist/cli.js +65 -3
  8. package/dist/commands/doctor.d.ts +13 -3
  9. package/dist/commands/doctor.js +20 -10
  10. package/dist/commands/mcpAuth.d.ts +28 -0
  11. package/dist/commands/mcpAuth.js +99 -0
  12. package/dist/commands/mcpGateway.d.ts +53 -0
  13. package/dist/commands/mcpGateway.js +148 -0
  14. package/dist/commands/memory.d.ts +15 -0
  15. package/dist/commands/memory.js +19 -1
  16. package/dist/commands/migrate.d.ts +4 -0
  17. package/dist/commands/migrate.js +46 -4
  18. package/dist/commands/onboard.d.ts +67 -0
  19. package/dist/commands/onboard.js +283 -14
  20. package/dist/commands/onboardVerdict.d.ts +55 -0
  21. package/dist/commands/onboardVerdict.js +175 -0
  22. package/dist/core/adapter.d.ts +7 -0
  23. package/dist/core/canonical.d.ts +27 -1
  24. package/dist/core/canonical.js +62 -0
  25. package/dist/core/types.d.ts +30 -4
  26. package/dist/lib/backup.js +32 -3
  27. package/dist/lib/envVarNames.d.ts +23 -6
  28. package/dist/lib/envVarNames.js +26 -7
  29. package/dist/lib/gatewayBackend.d.ts +75 -0
  30. package/dist/lib/gatewayBackend.js +131 -0
  31. package/dist/lib/mcpConnect.d.ts +53 -0
  32. package/dist/lib/mcpConnect.js +123 -0
  33. package/dist/lib/mcpToolRegistry.d.ts +89 -0
  34. package/dist/lib/mcpToolRegistry.js +94 -0
  35. package/dist/lib/oauth/discovery.d.ts +53 -0
  36. package/dist/lib/oauth/discovery.js +95 -0
  37. package/dist/lib/oauth/flow.d.ts +47 -0
  38. package/dist/lib/oauth/flow.js +193 -0
  39. package/dist/lib/oauth/lock.d.ts +25 -0
  40. package/dist/lib/oauth/lock.js +98 -0
  41. package/dist/lib/oauth/pkce.d.ts +27 -0
  42. package/dist/lib/oauth/pkce.js +40 -0
  43. package/dist/lib/oauth/refresh.d.ts +33 -0
  44. package/dist/lib/oauth/refresh.js +93 -0
  45. package/dist/lib/oauth/store.d.ts +55 -0
  46. package/dist/lib/oauth/store.js +85 -0
  47. package/dist/pi-bridge/bundle.js +14376 -14336
  48. package/dist/pi-bridge/index.d.ts +5 -9
  49. package/dist/pi-bridge/index.js +6 -106
  50. package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
  51. package/dist/pi-bridge/schemaTranslate.js +8 -4
  52. package/docs/architecture.md +108 -25
  53. package/docs/getting-started.md +156 -7
  54. package/docs/research.md +166 -0
  55. package/docs/roadmap.md +18 -0
  56. package/package.json +4 -3
  57. package/schema/servers.example.yaml +33 -11
@@ -83,7 +83,10 @@ export class CodexAdapter {
83
83
  });
84
84
  }
85
85
  for (const conflict of conflicts) {
86
- items.push({ action: "conflict", kind: "mcp", target: configTomlPath, description: conflict.message });
86
+ const item = { action: "conflict", kind: "mcp", target: configTomlPath, description: conflict.message };
87
+ if (conflict.remediation)
88
+ item.remediation = conflict.remediation;
89
+ items.push(item);
87
90
  }
88
91
  const desiredNames = new Set(desired.map((d) => d.name));
89
92
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
@@ -50,7 +50,10 @@ export function planJsonMcp(opts) {
50
50
  });
51
51
  }
52
52
  for (const conflict of conflicts) {
53
- items.push({ action: "conflict", kind: "mcp", target: configPath, description: conflict.message });
53
+ const item = { action: "conflict", kind: "mcp", target: configPath, description: conflict.message };
54
+ if (conflict.remediation)
55
+ item.remediation = conflict.remediation;
56
+ items.push(item);
54
57
  }
55
58
  const desiredNames = new Set(desired.map((d) => d.name));
56
59
  for (const [name, expected] of Object.entries(ownership ?? {})) {
@@ -97,7 +97,15 @@ export class KiroAdapter {
97
97
  parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
98
98
  }
99
99
  catch {
100
- return [{ action: "conflict", kind: "kiro-approved-env-vars", target: settingsPath, description: `${settingsPath} is not valid JSON — refusing to touch it` }];
100
+ return [
101
+ {
102
+ action: "conflict",
103
+ kind: "kiro-approved-env-vars",
104
+ target: settingsPath,
105
+ description: `${settingsPath} is not valid JSON — refusing to touch it`,
106
+ remediation: `fix or restore valid JSON at ${settingsPath} (a backup under ~/.trellis/backups/ may have the last version Trellis wrote), then re-run sync`,
107
+ },
108
+ ];
101
109
  }
102
110
  const current = parsed[APPROVED_ENV_VARS_KEY];
103
111
  if (Array.isArray(current)) {
@@ -18,6 +18,18 @@
18
18
  */
19
19
  import type { AgentId, McpConfig, McpServerDef, SecretsPolicy } from "../core/types.js";
20
20
  export declare const HUB_ENTRY_NAME = "trellis-hub";
21
+ export declare const GATEWAY_ENTRY_NAME = "trellis-gateway";
22
+ /** The command an agent spawns in gateway mode. Bare `trellis` rather than
23
+ * an absolute path: the entry has to keep working across reinstalls and
24
+ * version bumps, and every agent resolves it through the same PATH the
25
+ * user installed the CLI onto. */
26
+ export declare const GATEWAY_COMMAND = "trellis";
27
+ /**
28
+ * Gateway mode applies to every managed agent when `agents` is omitted —
29
+ * the expected normal case (design.md D14). An explicit list narrows it,
30
+ * letting some agents stay in direct or hub mode.
31
+ */
32
+ export declare function isGatewayAgent(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[]): boolean;
21
33
  export interface DesiredMcpEntry {
22
34
  name: string;
23
35
  def: McpServerDef;
@@ -25,9 +37,28 @@ export interface DesiredMcpEntry {
25
37
  export interface McpConflict {
26
38
  name: string;
27
39
  message: string;
40
+ /** The concrete next action, distinct from `message`'s restatement of
41
+ * why (trellis-onboard-closed-loop design.md D7). */
42
+ remediation?: string;
28
43
  }
29
44
  export interface McpPlanResult {
30
45
  desired: DesiredMcpEntry[];
31
46
  conflicts: McpConflict[];
32
47
  }
48
+ /**
49
+ * Exported so `migrate`'s own read path (`src/commands/migrate.ts`) can
50
+ * apply the identical check on the way *into* canonical, not just on the
51
+ * way out to an agent's native config. Found via real-machine dogfooding:
52
+ * `migrate --from kiro --only mcp` happily copied a real, live
53
+ * `MCPR_TOKEN` literal into `~/.trellis/mcp/servers.yaml` because this
54
+ * check previously existed only here, in `resolveMcpPlan` — canonical
55
+ * itself was never guarded, only the sync-out boundary was. A canonical
56
+ * file that briefly held a real secret before a later `mcp sync` refused
57
+ * to propagate it further already violates "never write a literal
58
+ * secret, ever" — the write already happened, in a file meant to be
59
+ * read, diffed, and committed as plain text. One shared implementation,
60
+ * not two that can drift, for the same reason `mcpConnect.ts`/
61
+ * `mcpToolRegistry.ts` are shared between pi-bridge and the gateway.
62
+ */
63
+ export declare function findLiteralSecret(def: McpServerDef): string | undefined;
33
64
  export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[], policy: SecretsPolicy): McpPlanResult;
@@ -20,10 +20,32 @@ import { isInScope } from "../core/adapter.js";
20
20
  import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
21
21
  import { resolveSecretEnv } from "../lib/secretEnv.js";
22
22
  export const HUB_ENTRY_NAME = "trellis-hub";
23
+ export const GATEWAY_ENTRY_NAME = "trellis-gateway";
24
+ /** The command an agent spawns in gateway mode. Bare `trellis` rather than
25
+ * an absolute path: the entry has to keep working across reinstalls and
26
+ * version bumps, and every agent resolves it through the same PATH the
27
+ * user installed the CLI onto. */
28
+ export const GATEWAY_COMMAND = "trellis";
29
+ /**
30
+ * Gateway mode applies to every managed agent when `agents` is omitted —
31
+ * the expected normal case (design.md D14). An explicit list narrows it,
32
+ * letting some agents stay in direct or hub mode.
33
+ */
34
+ export function isGatewayAgent(agentId, mcp, managedAgents) {
35
+ const gateway = mcp.gateway;
36
+ if (!gateway?.enabled)
37
+ return false;
38
+ if (gateway.agents)
39
+ return gateway.agents.includes(agentId);
40
+ return managedAgents.includes(agentId);
41
+ }
23
42
  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.';
24
43
  function collisionMessage(name, agentId) {
25
44
  return `refusing to write MCP server "${name}": also appears in known_host_injected.${agentId === "codex" ? CODEX_STDIO_URL_CRASH_NOTE : ""}`;
26
45
  }
46
+ function collisionRemediation(name) {
47
+ return `rename this server in servers.yaml, or remove "${name}" from known_host_injected if it's no longer actually host-injected on this machine`;
48
+ }
27
49
  /**
28
50
  * Known-dangerous literal patterns (design.md D5) — mirrors
29
51
  * schema/secrets.policy.example.yaml's own examples. A narrow, hardcoded
@@ -35,7 +57,22 @@ const DANGEROUS_LITERAL_PATTERNS = [
35
57
  { label: "GitHub personal access token (ghp_)", pattern: /ghp_/ },
36
58
  { label: "mcp-router token (mcpr_)", pattern: /mcpr_/ },
37
59
  ];
38
- function findLiteralSecret(def) {
60
+ /**
61
+ * Exported so `migrate`'s own read path (`src/commands/migrate.ts`) can
62
+ * apply the identical check on the way *into* canonical, not just on the
63
+ * way out to an agent's native config. Found via real-machine dogfooding:
64
+ * `migrate --from kiro --only mcp` happily copied a real, live
65
+ * `MCPR_TOKEN` literal into `~/.trellis/mcp/servers.yaml` because this
66
+ * check previously existed only here, in `resolveMcpPlan` — canonical
67
+ * itself was never guarded, only the sync-out boundary was. A canonical
68
+ * file that briefly held a real secret before a later `mcp sync` refused
69
+ * to propagate it further already violates "never write a literal
70
+ * secret, ever" — the write already happened, in a file meant to be
71
+ * read, diffed, and committed as plain text. One shared implementation,
72
+ * not two that can drift, for the same reason `mcpConnect.ts`/
73
+ * `mcpToolRegistry.ts` are shared between pi-bridge and the gateway.
74
+ */
75
+ export function findLiteralSecret(def) {
39
76
  const candidates = [def.command, def.url, ...(def.args ?? []), ...Object.values(def.headers ?? {}), ...Object.values(def.staticEnv ?? {})].filter((v) => typeof v === "string");
40
77
  for (const candidate of candidates) {
41
78
  for (const { label, pattern } of DANGEROUS_LITERAL_PATTERNS) {
@@ -69,9 +106,33 @@ function findUnresolvedEnvName(def, policy) {
69
106
  return names.find((name) => !resolved[name]);
70
107
  }
71
108
  export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
109
+ // Checked before `hub`: only one entry can be "the" MCP entry for an
110
+ // agent, and gateway is the newer, purpose-built path (design.md D6).
111
+ // `hub` stays available, unchanged, for anyone pointing an agent at an
112
+ // externally-operated endpoint — including a different agent in the
113
+ // same canonical source.
114
+ if (isGatewayAgent(agentId, mcp, managedAgents)) {
115
+ if (mcp.knownHostInjected.includes(GATEWAY_ENTRY_NAME)) {
116
+ return {
117
+ desired: [],
118
+ conflicts: [{ name: GATEWAY_ENTRY_NAME, message: collisionMessage(GATEWAY_ENTRY_NAME, agentId), remediation: collisionRemediation(GATEWAY_ENTRY_NAME) }],
119
+ };
120
+ }
121
+ // Every per-server check (enabled, scope, literal secrets, unresolved
122
+ // env names, Codex's header shape) still runs — but at gateway
123
+ // *startup*, inside the subcommand, since no adapter sees an
124
+ // individual server in this mode (design.md D6).
125
+ return {
126
+ desired: [{ name: GATEWAY_ENTRY_NAME, def: { transport: "stdio", command: GATEWAY_COMMAND, args: ["mcp-gateway", "--agent", agentId] } }],
127
+ conflicts: [],
128
+ };
129
+ }
72
130
  if (mcp.hub) {
73
131
  if (mcp.knownHostInjected.includes(HUB_ENTRY_NAME)) {
74
- return { desired: [], conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId) }] };
132
+ return {
133
+ desired: [],
134
+ conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId), remediation: collisionRemediation(HUB_ENTRY_NAME) }],
135
+ };
75
136
  }
76
137
  return { desired: [{ name: HUB_ENTRY_NAME, def: { transport: "http", url: mcp.hub.url } }], conflicts: [] };
77
138
  }
@@ -85,7 +146,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
85
146
  continue;
86
147
  }
87
148
  if (mcp.knownHostInjected.includes(name)) {
88
- conflicts.push({ name, message: collisionMessage(name, agentId) });
149
+ conflicts.push({ name, message: collisionMessage(name, agentId), remediation: collisionRemediation(name) });
89
150
  continue;
90
151
  }
91
152
  const secretLabel = findLiteralSecret(def);
@@ -93,6 +154,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
93
154
  conflicts.push({
94
155
  name,
95
156
  message: `refusing to write MCP server "${name}": a value matches a known-dangerous literal pattern (${secretLabel}) — configs must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
157
+ remediation: `replace the literal value in servers.yaml with a \`\${VAR_NAME}\` reference and put the real value wherever secrets.policy.yaml resolves it from`,
96
158
  });
97
159
  continue;
98
160
  }
@@ -105,6 +167,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
105
167
  conflicts.push({
106
168
  name,
107
169
  message: `refusing to write MCP server "${name}" for codex: its "headers" field isn't the single { Authorization: "Bearer \${VAR}" } shape Codex's own config format can express — Codex has no generic headers concept, only \`bearer_token_env_var\`. Still written normally for every other in-scope agent.`,
170
+ remediation: `reduce this server's headers to a single Authorization bearer token for Codex, enable gateway mode for Codex (which resolves headers itself and never hits this check), or accept that this server stays unreachable from Codex`,
108
171
  });
109
172
  continue;
110
173
  }
@@ -114,6 +177,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
114
177
  conflicts.push({
115
178
  name,
116
179
  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)`,
180
+ remediation: `set "${unresolvedName}" in ${source}, or fix the name in servers.yaml if it was a typo, then re-run mcp sync`,
117
181
  });
118
182
  continue;
119
183
  }
@@ -56,6 +56,9 @@ export function planSymlinks(opts) {
56
56
  description: isForeign
57
57
  ? `${path} exists as a symlink to ${readlinkSync(path)}, not owned by Trellis — left untouched`
58
58
  : `${path} exists and is not a Trellis-managed symlink — left untouched`,
59
+ remediation: isForeign
60
+ ? `remove the existing symlink at ${path} if you want Trellis to manage it, then re-run sync — otherwise leave it, Trellis will not touch it`
61
+ : `back up ${path}'s real content if you need it, remove the file, then re-run sync — Trellis will not overwrite or merge into an existing real file`,
59
62
  });
60
63
  continue;
61
64
  }
package/dist/cli.js CHANGED
@@ -10,12 +10,14 @@ import { runMigrate } from "./commands/migrate.js";
10
10
  import { runOnboard } from "./commands/onboard.js";
11
11
  import { runSync } from "./commands/sync.js";
12
12
  import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, parseMcpAddArgs } from "./commands/mcp.js";
13
+ import { parseMcpGatewayArgs, runMcpGateway } from "./commands/mcpGateway.js";
14
+ import { runMcpAuth } from "./commands/mcpAuth.js";
13
15
  import { runSecretsAudit } from "./commands/secretsAudit.js";
14
16
  import { runRollback } from "./commands/rollback.js";
15
17
  import { runSkillList, runSkillAdd, runSkillRemove } from "./commands/skill.js";
16
18
  import { runMemoryExtraction, runMemorySync } from "./commands/memory.js";
17
19
  import { parseSyncArgs } from "./lib/syncArgs.js";
18
- const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "skill", "memory", "secrets", "rollback"];
20
+ const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "mcp-gateway", "skill", "memory", "secrets", "rollback"];
19
21
  function printUsage() {
20
22
  console.log(`trellis - a single source of capability for every coding agent
21
23
 
@@ -36,6 +38,16 @@ Commands:
36
38
  means "add nothing new this run"
37
39
  (required with no terminal to prompt
38
40
  in, e.g. --json)
41
+ --mcp-mode <direct|hub|gateway>
42
+ change the MCP mode; omit to leave
43
+ whatever's already configured
44
+ untouched (never prompted)
45
+ --hub-url <url> required with --mcp-mode hub
46
+ --gateway-agents <ids>
47
+ optional with --mcp-mode gateway;
48
+ omit for every managed agent
49
+ --memory <on|off> enable/disable the shared memory MCP
50
+ server; omit to leave it untouched
39
51
  --dry-run preview the whole flow, write nothing
40
52
  --json machine-readable output, no report text
41
53
  init Create ~/.trellis/ with a minimal valid skeleton if missing
@@ -77,6 +89,14 @@ Commands:
77
89
  [--enabled true|false]
78
90
  --dry-run preview the plan, write nothing
79
91
  --json machine-readable output, no report text
92
+ mcp auth <server-name>
93
+ Authorize a remote (http/sse) MCP server that uses OAuth —
94
+ opens a browser once, stores the result 0600 under
95
+ ~/.trellis/mcp/oauth/. A still-valid token is a no-op; an
96
+ expired one is refreshed without a browser. The gateway
97
+ refreshes silently on its own and never prompts.
98
+ --force re-authorize even if the stored token is valid
99
+ --json machine-readable output, no report text
80
100
  mcp remove <name>
81
101
  Remove a canonical MCP server (canonical-side only — does
82
102
  not touch any agent's already-synced native config)
@@ -145,7 +165,24 @@ async function main(argv) {
145
165
  const agent = agentIndex >= 0 ? rest[agentIndex + 1] : undefined;
146
166
  const manageIndex = rest.indexOf("--manage");
147
167
  const manage = manageIndex >= 0 ? rest[manageIndex + 1] : undefined;
148
- const { exitCode } = await runOnboard({ agent, manage, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
168
+ const mcpModeIndex = rest.indexOf("--mcp-mode");
169
+ const mcpMode = mcpModeIndex >= 0 ? rest[mcpModeIndex + 1] : undefined;
170
+ const hubUrlIndex = rest.indexOf("--hub-url");
171
+ const hubUrl = hubUrlIndex >= 0 ? rest[hubUrlIndex + 1] : undefined;
172
+ const gatewayAgentsIndex = rest.indexOf("--gateway-agents");
173
+ const gatewayAgents = gatewayAgentsIndex >= 0 ? rest[gatewayAgentsIndex + 1] : undefined;
174
+ const memoryIndex = rest.indexOf("--memory");
175
+ const memory = memoryIndex >= 0 ? rest[memoryIndex + 1] : undefined;
176
+ const { exitCode } = await runOnboard({
177
+ agent,
178
+ manage,
179
+ mcpMode,
180
+ hubUrl,
181
+ gatewayAgents,
182
+ memory,
183
+ dryRun: rest.includes("--dry-run"),
184
+ json: rest.includes("--json"),
185
+ });
149
186
  process.exitCode = exitCode;
150
187
  return;
151
188
  }
@@ -168,6 +205,20 @@ async function main(argv) {
168
205
  process.exitCode = exitCode;
169
206
  return;
170
207
  }
208
+ // Spawned by an agent, never typed by a person: stdout is the MCP
209
+ // protocol stream for the rest of this process's life, so nothing here
210
+ // may print to it (trellis-mcp-gateway-hosting design.md D2).
211
+ if (command === "mcp-gateway") {
212
+ const parsed = parseMcpGatewayArgs(rest);
213
+ if ("error" in parsed) {
214
+ console.error(parsed.error);
215
+ process.exitCode = 1;
216
+ return;
217
+ }
218
+ const { exitCode } = await runMcpGateway({ agentId: parsed.agentId });
219
+ process.exitCode = exitCode;
220
+ return;
221
+ }
171
222
  if (command === "sync") {
172
223
  const { target, unknownArg } = parseSyncArgs(rest);
173
224
  if (unknownArg) {
@@ -208,7 +259,18 @@ async function main(argv) {
208
259
  process.exitCode = runMcpRemove(name, { json, dryRun }).exitCode;
209
260
  return;
210
261
  }
211
- console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|remove <name>\n`);
262
+ if (subcommand === "auth") {
263
+ const [serverName] = mcpRest;
264
+ if (!serverName || serverName.startsWith("--")) {
265
+ console.error("Usage: trellis mcp auth <server-name>");
266
+ process.exitCode = 1;
267
+ return;
268
+ }
269
+ const { exitCode } = await runMcpAuth({ serverName, force: mcpRest.includes("--force"), json });
270
+ process.exitCode = exitCode;
271
+ return;
272
+ }
273
+ console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|remove <name>|auth <name>\n`);
212
274
  process.exitCode = 1;
213
275
  return;
214
276
  }
@@ -57,9 +57,15 @@ export declare function resolveKnownHostInjected(opts: RunDoctorOptions): readon
57
57
  export declare function runDoctor(opts?: RunDoctorOptions): Promise<{
58
58
  exitCode: number;
59
59
  }>;
60
- /** Exported separately from `runDoctor` so tests can assert on findings
61
- * without capturing stdout. */
62
- export declare function collectDoctorReport(knownHostInjected?: readonly string[], probeMcp?: boolean): Promise<DoctorReport>;
60
+ /**
61
+ * Exported separately from `runDoctor` so tests can assert on findings
62
+ * without capturing stdout — and, since trellis-onboard-closed-loop,
63
+ * so `trellis onboard` can call this against the scratch home its own
64
+ * tests use. `homeDir` defaults to the real `~` so `runDoctor`'s
65
+ * existing call site (which now passes `opts.homeDir`, itself normally
66
+ * `undefined`) is unaffected.
67
+ */
68
+ export declare function collectDoctorReport(homeDir?: string, knownHostInjected?: readonly string[], probeMcp?: boolean): Promise<DoctorReport>;
63
69
  /**
64
70
  * Within one agent, across all of its skill roots: any realpath reached by
65
71
  * more than one entry, where at least one of those entries is NOT a
@@ -86,3 +92,7 @@ export declare function detectCrossAgentDrift(snapshots: AgentSnapshot[]): Findi
86
92
  export declare function detectCaseMismatches(snapshots: AgentSnapshot[]): Finding[];
87
93
  export declare function detectParseDiagnostics(snapshots: AgentSnapshot[]): Finding[];
88
94
  export declare function detectMcpUnreachable(snapshots: AgentSnapshot[]): Finding[];
95
+ /** Exported so `trellis onboard` can reuse it verbatim for its own final
96
+ * stage (trellis-onboard-closed-loop) rather than a second copy of this
97
+ * formatting. */
98
+ export declare function printReport(report: DoctorReport): void;
@@ -4,6 +4,7 @@
4
4
  * current state against each other, the same manual process used
5
5
  * throughout docs/research.md's investigation, now formalized.
6
6
  */
7
+ import { homedir } from "node:os";
7
8
  import * as claudeCodeProbe from "../probes/claude-code.js";
8
9
  import * as codexProbe from "../probes/codex.js";
9
10
  import * as kiroProbe from "../probes/kiro.js";
@@ -47,24 +48,30 @@ export function resolveKnownHostInjected(opts) {
47
48
  }
48
49
  }
49
50
  export async function runDoctor(opts = {}) {
50
- const report = await collectDoctorReport(resolveKnownHostInjected(opts), opts.probeMcp);
51
+ const report = await collectDoctorReport(opts.homeDir, resolveKnownHostInjected(opts), opts.probeMcp);
51
52
  const exitCode = report.findings.some((f) => EXIT_NONZERO_KINDS.has(f.kind)) ? 1 : 0;
52
53
  if (opts.json) {
53
54
  console.log(JSON.stringify(report, null, 2));
54
55
  }
55
56
  else {
56
- printTable(report);
57
+ printReport(report);
57
58
  }
58
59
  return { exitCode };
59
60
  }
60
- /** Exported separately from `runDoctor` so tests can assert on findings
61
- * without capturing stdout. */
62
- export async function collectDoctorReport(knownHostInjected = DEFAULT_KNOWN_HOST_INJECTED, probeMcp = false) {
61
+ /**
62
+ * Exported separately from `runDoctor` so tests can assert on findings
63
+ * without capturing stdout — and, since trellis-onboard-closed-loop,
64
+ * so `trellis onboard` can call this against the scratch home its own
65
+ * tests use. `homeDir` defaults to the real `~` so `runDoctor`'s
66
+ * existing call site (which now passes `opts.homeDir`, itself normally
67
+ * `undefined`) is unaffected.
68
+ */
69
+ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected = DEFAULT_KNOWN_HOST_INJECTED, probeMcp = false) {
63
70
  const probeList = [
64
- { agent: "claude-code", run: () => claudeCodeProbe.probe(undefined, { probeMcp }) },
65
- { agent: "codex", run: () => codexProbe.probe(undefined, { probeMcp }) },
66
- { agent: "kiro", run: () => kiroProbe.probe(undefined, { probeMcp }) },
67
- { agent: "pi", run: piProbe.probe },
71
+ { agent: "claude-code", run: () => claudeCodeProbe.probe(homeDir, { probeMcp }) },
72
+ { agent: "codex", run: () => codexProbe.probe(homeDir, { probeMcp }) },
73
+ { agent: "kiro", run: () => kiroProbe.probe(homeDir, { probeMcp }) },
74
+ { agent: "pi", run: () => piProbe.probe(homeDir) },
68
75
  ];
69
76
  // One agent erroring must not blank the other three's results.
70
77
  const settled = await Promise.allSettled(probeList.map((p) => p.run()));
@@ -226,7 +233,10 @@ export function detectMcpUnreachable(snapshots) {
226
233
  }
227
234
  return findings;
228
235
  }
229
- function printTable(report) {
236
+ /** Exported so `trellis onboard` can reuse it verbatim for its own final
237
+ * stage (trellis-onboard-closed-loop) rather than a second copy of this
238
+ * formatting. */
239
+ export function printReport(report) {
230
240
  const byAgent = new Map();
231
241
  const crossAgent = [];
232
242
  for (const finding of report.findings) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `trellis mcp auth <server-name>` — the one piece of the OAuth story
3
+ * that genuinely requires a human (trellis-mcp-gateway-hosting design.md
4
+ * D9).
5
+ *
6
+ * The initial authorization-code exchange needs a real browser and a
7
+ * loopback callback, so it only makes sense as an explicit, one-time
8
+ * command. Everything after it — silently refreshing an expired token —
9
+ * happens inside the gateway, which never reaches this file.
10
+ */
11
+ export interface RunMcpAuthOptions {
12
+ serverName: string;
13
+ /** Re-authorize even when a valid token is already stored. */
14
+ force?: boolean;
15
+ json?: boolean;
16
+ homeDir?: string;
17
+ /** Test seam — the real one launches the platform browser. */
18
+ openBrowser?: (url: string) => void | Promise<void>;
19
+ }
20
+ export interface McpAuthResult {
21
+ server: string;
22
+ status: "authorized" | "already-valid" | "refreshed";
23
+ tokenFile: string;
24
+ expiresAt?: number;
25
+ }
26
+ export declare function runMcpAuth(opts: RunMcpAuthOptions): Promise<{
27
+ exitCode: number;
28
+ }>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `trellis mcp auth <server-name>` — the one piece of the OAuth story
3
+ * that genuinely requires a human (trellis-mcp-gateway-hosting design.md
4
+ * D9).
5
+ *
6
+ * The initial authorization-code exchange needs a real browser and a
7
+ * loopback callback, so it only makes sense as an explicit, one-time
8
+ * command. Everything after it — silently refreshing an expired token —
9
+ * happens inside the gateway, which never reaches this file.
10
+ */
11
+ import { homedir } from "node:os";
12
+ import { loadCanonicalSource } from "../core/canonical.js";
13
+ import { discoverAuthorizationServer, parseResourceMetadataUrl } from "../lib/oauth/discovery.js";
14
+ import { authorize } from "../lib/oauth/flow.js";
15
+ import { ensureFreshToken } from "../lib/oauth/refresh.js";
16
+ import { isExpired, readToken, tokenPath, writeToken } from "../lib/oauth/store.js";
17
+ export async function runMcpAuth(opts) {
18
+ const homeDir = opts.homeDir ?? homedir();
19
+ let result;
20
+ try {
21
+ result = await authorizeServer(opts, homeDir);
22
+ }
23
+ catch (err) {
24
+ console.error(err instanceof Error ? err.message : String(err));
25
+ return { exitCode: 1 };
26
+ }
27
+ if (opts.json) {
28
+ console.log(JSON.stringify(result, null, 2));
29
+ }
30
+ else {
31
+ const detail = result.expiresAt ? ` (expires ${new Date(result.expiresAt).toISOString()})` : "";
32
+ const verb = result.status === "already-valid"
33
+ ? "already has a valid token"
34
+ : result.status === "refreshed"
35
+ ? "token refreshed"
36
+ : "authorized";
37
+ console.log(`✅ ${result.server} — ${verb}${detail}`);
38
+ console.log(` credentials: ${result.tokenFile}`);
39
+ }
40
+ return { exitCode: 0 };
41
+ }
42
+ async function authorizeServer(opts, homeDir) {
43
+ const canonical = loadCanonicalSource(homeDir);
44
+ const def = canonical.mcp.servers[opts.serverName];
45
+ if (!def) {
46
+ throw new Error(`no MCP server named "${opts.serverName}" in ~/.trellis/mcp/servers.yaml`);
47
+ }
48
+ if (!def.url) {
49
+ // stdio servers authenticate through `env`/`envAliases`, which is a
50
+ // different mechanism entirely — there is nothing to authorize here,
51
+ // and pretending otherwise would send someone hunting for a browser
52
+ // flow that does not exist.
53
+ throw new Error(`MCP server "${opts.serverName}" is a stdio server — OAuth applies to http/sse servers. Its credentials come from \`env\`/\`env_aliases\` and secrets.policy.yaml.`);
54
+ }
55
+ const file = tokenPath(homeDir, opts.serverName);
56
+ if (!opts.force) {
57
+ const existing = readToken(homeDir, opts.serverName);
58
+ if (existing && !isExpired(existing)) {
59
+ // Re-running is a no-op rather than a fresh grant: rotating a
60
+ // perfectly good credential is a way to break a working setup.
61
+ return { server: opts.serverName, status: "already-valid", tokenFile: file, ...(existing.expiresAt ? { expiresAt: existing.expiresAt } : {}) };
62
+ }
63
+ if (existing?.refreshToken) {
64
+ // Expired but renewable — no browser needed.
65
+ const refreshed = await ensureFreshToken(homeDir, opts.serverName);
66
+ if (refreshed) {
67
+ return { server: opts.serverName, status: "refreshed", tokenFile: file, ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}) };
68
+ }
69
+ }
70
+ }
71
+ const metadata = await discoverAuthorizationServer(def.url, { wwwAuthenticate: await probeWwwAuthenticate(def) });
72
+ if (!metadata) {
73
+ throw new Error(`could not discover OAuth metadata for "${opts.serverName}" (${def.url}) — it may not use OAuth, or may not publish RFC 8414/9728 metadata`);
74
+ }
75
+ const token = await authorize(opts.serverName, metadata, {
76
+ ...(opts.openBrowser ? { openBrowser: opts.openBrowser } : {}),
77
+ ...(metadata.scopesSupported?.length ? { scope: metadata.scopesSupported.join(" ") } : {}),
78
+ });
79
+ writeToken(homeDir, opts.serverName, token);
80
+ return { server: opts.serverName, status: "authorized", tokenFile: file, ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}) };
81
+ }
82
+ /**
83
+ * An unauthenticated request to the resource, purely to read its
84
+ * `WWW-Authenticate` challenge — the server naming its own authorization
85
+ * server directly, which beats probing well-known paths. Failure is not
86
+ * an error: discovery falls back to probing.
87
+ */
88
+ async function probeWwwAuthenticate(def) {
89
+ try {
90
+ const response = await fetch(def.url, { method: "GET" });
91
+ if (response.status !== 401)
92
+ return null;
93
+ const header = response.headers.get("www-authenticate");
94
+ return parseResourceMetadataUrl(header) ? header : null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `trellis mcp-gateway --agent <id>` — the stdio MCP server an agent
3
+ * spawns in gateway mode (trellis-mcp-gateway-hosting).
4
+ *
5
+ * Not a user-facing command in the usual sense: nobody types this. An
6
+ * agent's own MCP client spawns it exactly the way it spawns any other
7
+ * stdio server, talks to it for the session, and closes the pipe on exit
8
+ * (design.md D2). There is no daemon, no lifecycle command, and nothing
9
+ * for the user to notice.
10
+ *
11
+ * This file depends on `GatewayBackend` and nothing below it — no
12
+ * connection, no transport, no server definition (design.md D11). That is
13
+ * what lets v2 point the same stdio edge at one shared service instead of
14
+ * an in-process backend without this file, or any agent's config,
15
+ * changing.
16
+ */
17
+ import { loadCanonicalSource } from "../core/canonical.js";
18
+ import { type GatewayBackend, type UpstreamSpec } from "../lib/gatewayBackend.js";
19
+ import type { AgentId } from "../core/types.js";
20
+ export interface RunMcpGatewayOptions {
21
+ agentId: AgentId;
22
+ /** Test seams, exactly as every other Trellis entry point exposes them. */
23
+ homeDir?: string;
24
+ connectTimeoutMs?: number;
25
+ /** Injected so tests can drive the protocol edge without real upstreams,
26
+ * and so v2 can substitute a forwarding backend (design.md D11). */
27
+ backendFactory?: (upstreams: UpstreamSpec[], homeDir: string) => Promise<GatewayBackend>;
28
+ }
29
+ export declare function parseMcpGatewayArgs(argv: readonly string[]): {
30
+ agentId: AgentId;
31
+ } | {
32
+ error: string;
33
+ };
34
+ /**
35
+ * The in-scope upstream set for this agent, resolved through the very same
36
+ * `resolveMcpPlan` that direct mode uses — per-server `agents:` scope,
37
+ * `enabled: false`, host-injected collisions, literal-secret refusals and
38
+ * unresolved `env` names all apply here identically, because it is
39
+ * literally the same function (design.md D6).
40
+ *
41
+ * The one thing that must be stripped is gateway mode itself: asking
42
+ * `resolveMcpPlan` for this agent's plan while gateway mode is on would
43
+ * answer "one entry, pointing at the gateway" — the gateway being told to
44
+ * connect to itself. Resolving against a gateway-less view of the same
45
+ * canonical gives the set gateway mode replaced.
46
+ */
47
+ export declare function resolveGatewayUpstreams(agentId: AgentId, canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): {
48
+ upstreams: UpstreamSpec[];
49
+ conflicts: string[];
50
+ };
51
+ export declare function runMcpGateway(opts: RunMcpGatewayOptions): Promise<{
52
+ exitCode: number;
53
+ }>;