agent-trellis 0.4.0 → 0.6.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 (62) hide show
  1. package/dist/adapters/codex.js +4 -1
  2. package/dist/adapters/jsonMcp.js +6 -3
  3. package/dist/adapters/kiro.js +9 -1
  4. package/dist/adapters/mcpPlan.d.ts +15 -0
  5. package/dist/adapters/mcpPlan.js +59 -3
  6. package/dist/adapters/symlinkPlan.js +3 -0
  7. package/dist/cli.js +56 -8
  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 +42 -19
  15. package/dist/commands/memory.js +99 -14
  16. package/dist/commands/migrate.d.ts +18 -1
  17. package/dist/commands/migrate.js +52 -6
  18. package/dist/commands/onboard.d.ts +34 -0
  19. package/dist/commands/onboard.js +110 -10
  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 +8 -6
  24. package/dist/core/canonical.js +26 -3
  25. package/dist/core/types.d.ts +42 -4
  26. package/dist/lib/backup.js +32 -3
  27. package/dist/lib/gatewayBackend.d.ts +75 -0
  28. package/dist/lib/gatewayBackend.js +131 -0
  29. package/dist/lib/mcpConnect.d.ts +53 -0
  30. package/dist/lib/mcpConnect.js +123 -0
  31. package/dist/lib/mcpMigrateRead.d.ts +12 -0
  32. package/dist/lib/mcpMigrateRead.js +47 -12
  33. package/dist/lib/mcpToolRegistry.d.ts +89 -0
  34. package/dist/lib/mcpToolRegistry.js +94 -0
  35. package/dist/lib/memoryGraph.d.ts +35 -0
  36. package/dist/lib/memoryGraph.js +62 -0
  37. package/dist/lib/oauth/discovery.d.ts +53 -0
  38. package/dist/lib/oauth/discovery.js +95 -0
  39. package/dist/lib/oauth/flow.d.ts +47 -0
  40. package/dist/lib/oauth/flow.js +193 -0
  41. package/dist/lib/oauth/lock.d.ts +25 -0
  42. package/dist/lib/oauth/lock.js +98 -0
  43. package/dist/lib/oauth/pkce.d.ts +27 -0
  44. package/dist/lib/oauth/pkce.js +40 -0
  45. package/dist/lib/oauth/refresh.d.ts +33 -0
  46. package/dist/lib/oauth/refresh.js +93 -0
  47. package/dist/lib/oauth/store.d.ts +55 -0
  48. package/dist/lib/oauth/store.js +85 -0
  49. package/dist/lib/slug.d.ts +9 -0
  50. package/dist/lib/slug.js +15 -0
  51. package/dist/lib/tomlSection.js +7 -1
  52. package/dist/pi-bridge/bundle.js +14383 -14338
  53. package/dist/pi-bridge/index.d.ts +5 -9
  54. package/dist/pi-bridge/index.js +6 -99
  55. package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
  56. package/dist/pi-bridge/schemaTranslate.js +8 -4
  57. package/docs/architecture.md +88 -19
  58. package/docs/getting-started.md +174 -11
  59. package/docs/research.md +190 -0
  60. package/docs/roadmap.md +20 -0
  61. package/package.json +4 -3
  62. package/schema/servers.example.yaml +42 -7
@@ -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);
@@ -7,6 +7,7 @@
7
7
  * through untouched.
8
8
  */
9
9
  import { deepEqual } from "../lib/deepEqual.js";
10
+ import { resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
10
11
  import { resolveMcpPlan } from "./mcpPlan.js";
11
12
  /** Renders the native JSON shape Claude Code/Kiro's `mcpServers` map
12
13
  * expects — `env` names render as `${VAR}` references, `staticEnv`
@@ -23,8 +24,7 @@ export function renderJsonServerEntry(def) {
23
24
  return entry;
24
25
  }
25
26
  const entry = { type: "stdio", command: def.command, args: def.args ?? [] };
26
- const nameRefs = Object.fromEntries((def.env ?? []).map((name) => [name, `\${${name}}`]));
27
- const env = { ...nameRefs, ...(def.staticEnv ?? {}) };
27
+ const env = resolvedEnvTextMap(def);
28
28
  if (Object.keys(env).length > 0) {
29
29
  entry.env = env;
30
30
  }
@@ -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,6 +37,9 @@ 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[];
@@ -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
@@ -53,18 +75,49 @@ function findLiteralSecret(def) {
53
75
  * proposal.md "Why"). Uses the identical `resolveSecretEnv` `secrets
54
76
  * audit`/the pi bridge already call, so this and a later `secrets audit`
55
77
  * run can never disagree about what resolves.
78
+ *
79
+ * `envAliases`' values are themselves source variable names needing the
80
+ * exact same resolution — checked in the same pass so a differently-named
81
+ * reference gets the same pre-write refusal `env` already has, naming the
82
+ * unresolved *source* name (what the caller must find a value for), not
83
+ * the target key it would have been written under
84
+ * (trellis-migrate-env-var-alias).
56
85
  */
57
86
  function findUnresolvedEnvName(def, policy) {
58
- const names = def.env ?? [];
87
+ const names = [...(def.env ?? []), ...Object.values(def.envAliases ?? {})];
59
88
  if (names.length === 0)
60
89
  return undefined;
61
90
  const resolved = resolveSecretEnv(names, policy);
62
91
  return names.find((name) => !resolved[name]);
63
92
  }
64
93
  export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
94
+ // Checked before `hub`: only one entry can be "the" MCP entry for an
95
+ // agent, and gateway is the newer, purpose-built path (design.md D6).
96
+ // `hub` stays available, unchanged, for anyone pointing an agent at an
97
+ // externally-operated endpoint — including a different agent in the
98
+ // same canonical source.
99
+ if (isGatewayAgent(agentId, mcp, managedAgents)) {
100
+ if (mcp.knownHostInjected.includes(GATEWAY_ENTRY_NAME)) {
101
+ return {
102
+ desired: [],
103
+ conflicts: [{ name: GATEWAY_ENTRY_NAME, message: collisionMessage(GATEWAY_ENTRY_NAME, agentId), remediation: collisionRemediation(GATEWAY_ENTRY_NAME) }],
104
+ };
105
+ }
106
+ // Every per-server check (enabled, scope, literal secrets, unresolved
107
+ // env names, Codex's header shape) still runs — but at gateway
108
+ // *startup*, inside the subcommand, since no adapter sees an
109
+ // individual server in this mode (design.md D6).
110
+ return {
111
+ desired: [{ name: GATEWAY_ENTRY_NAME, def: { transport: "stdio", command: GATEWAY_COMMAND, args: ["mcp-gateway", "--agent", agentId] } }],
112
+ conflicts: [],
113
+ };
114
+ }
65
115
  if (mcp.hub) {
66
116
  if (mcp.knownHostInjected.includes(HUB_ENTRY_NAME)) {
67
- return { desired: [], conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId) }] };
117
+ return {
118
+ desired: [],
119
+ conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId), remediation: collisionRemediation(HUB_ENTRY_NAME) }],
120
+ };
68
121
  }
69
122
  return { desired: [{ name: HUB_ENTRY_NAME, def: { transport: "http", url: mcp.hub.url } }], conflicts: [] };
70
123
  }
@@ -78,7 +131,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
78
131
  continue;
79
132
  }
80
133
  if (mcp.knownHostInjected.includes(name)) {
81
- conflicts.push({ name, message: collisionMessage(name, agentId) });
134
+ conflicts.push({ name, message: collisionMessage(name, agentId), remediation: collisionRemediation(name) });
82
135
  continue;
83
136
  }
84
137
  const secretLabel = findLiteralSecret(def);
@@ -86,6 +139,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
86
139
  conflicts.push({
87
140
  name,
88
141
  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")`,
142
+ 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`,
89
143
  });
90
144
  continue;
91
145
  }
@@ -98,6 +152,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
98
152
  conflicts.push({
99
153
  name,
100
154
  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.`,
155
+ 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`,
101
156
  });
102
157
  continue;
103
158
  }
@@ -107,6 +162,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
107
162
  conflicts.push({
108
163
  name,
109
164
  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)`,
165
+ remediation: `set "${unresolvedName}" in ${source}, or fix the name in servers.yaml if it was a typo, then re-run mcp sync`,
110
166
  });
111
167
  continue;
112
168
  }
@@ -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
- import { runMemorySync } from "./commands/memory.js";
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
 
@@ -77,6 +79,14 @@ Commands:
77
79
  [--enabled true|false]
78
80
  --dry-run preview the plan, write nothing
79
81
  --json machine-readable output, no report text
82
+ mcp auth <server-name>
83
+ Authorize a remote (http/sse) MCP server that uses OAuth —
84
+ opens a browser once, stores the result 0600 under
85
+ ~/.trellis/mcp/oauth/. A still-valid token is a no-op; an
86
+ expired one is refreshed without a browser. The gateway
87
+ refreshes silently on its own and never prompts.
88
+ --force re-authorize even if the stored token is valid
89
+ --json machine-readable output, no report text
80
90
  mcp remove <name>
81
91
  Remove a canonical MCP server (canonical-side only — does
82
92
  not touch any agent's already-synced native config)
@@ -104,6 +114,14 @@ Commands:
104
114
  or relation this didn't create.
105
115
  --dry-run preview the plan, write nothing
106
116
  --json machine-readable output, no report text
117
+ memory extract
118
+ The reverse direction: read the same graph file's real,
119
+ non-Trellis entities (agent-accumulated content, never
120
+ Trellis's own) and write each as a new canonical
121
+ memories/*.md file — readable markdown, not meant to
122
+ round-trip byte-for-byte back through memory sync.
123
+ --dry-run preview the plan, write nothing
124
+ --json machine-readable output, no report text
107
125
  secrets audit
108
126
  Scan each present agent's real MCP config for leaked
109
127
  credentials and unexpected env var names
@@ -160,6 +178,20 @@ async function main(argv) {
160
178
  process.exitCode = exitCode;
161
179
  return;
162
180
  }
181
+ // Spawned by an agent, never typed by a person: stdout is the MCP
182
+ // protocol stream for the rest of this process's life, so nothing here
183
+ // may print to it (trellis-mcp-gateway-hosting design.md D2).
184
+ if (command === "mcp-gateway") {
185
+ const parsed = parseMcpGatewayArgs(rest);
186
+ if ("error" in parsed) {
187
+ console.error(parsed.error);
188
+ process.exitCode = 1;
189
+ return;
190
+ }
191
+ const { exitCode } = await runMcpGateway({ agentId: parsed.agentId });
192
+ process.exitCode = exitCode;
193
+ return;
194
+ }
163
195
  if (command === "sync") {
164
196
  const { target, unknownArg } = parseSyncArgs(rest);
165
197
  if (unknownArg) {
@@ -200,7 +232,18 @@ async function main(argv) {
200
232
  process.exitCode = runMcpRemove(name, { json, dryRun }).exitCode;
201
233
  return;
202
234
  }
203
- console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|remove <name>\n`);
235
+ if (subcommand === "auth") {
236
+ const [serverName] = mcpRest;
237
+ if (!serverName || serverName.startsWith("--")) {
238
+ console.error("Usage: trellis mcp auth <server-name>");
239
+ process.exitCode = 1;
240
+ return;
241
+ }
242
+ const { exitCode } = await runMcpAuth({ serverName, force: mcpRest.includes("--force"), json });
243
+ process.exitCode = exitCode;
244
+ return;
245
+ }
246
+ console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|remove <name>|auth <name>\n`);
204
247
  process.exitCode = 1;
205
248
  return;
206
249
  }
@@ -240,13 +283,18 @@ async function main(argv) {
240
283
  }
241
284
  if (command === "memory") {
242
285
  const [subcommand, ...memoryRest] = rest;
243
- if (subcommand !== "sync") {
244
- console.error(`Unknown memory subcommand: ${subcommand ?? "(none)"}\nUsage: trellis memory sync\n`);
245
- process.exitCode = 1;
286
+ const json = memoryRest.includes("--json");
287
+ const dryRun = memoryRest.includes("--dry-run");
288
+ if (subcommand === "sync") {
289
+ process.exitCode = runMemorySync({ json, dryRun }).exitCode;
246
290
  return;
247
291
  }
248
- const { exitCode } = runMemorySync({ json: memoryRest.includes("--json"), dryRun: memoryRest.includes("--dry-run") });
249
- process.exitCode = exitCode;
292
+ if (subcommand === "extract") {
293
+ process.exitCode = runMemoryExtraction({ json, dryRun }).exitCode;
294
+ return;
295
+ }
296
+ console.error(`Unknown memory subcommand: ${subcommand ?? "(none)"}\nUsage: trellis memory sync|extract\n`);
297
+ process.exitCode = 1;
250
298
  return;
251
299
  }
252
300
  if (command === "secrets") {
@@ -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
+ }>;