agent-trellis 0.12.0 → 0.14.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.
@@ -3,22 +3,61 @@
3
3
  * symlinked to canonical `agents.md` — specifically `AGENTS.md`, not
4
4
  * `AGENTS.override.md` (design.md D3, trellis-sync-p1: pi checks the
5
5
  * override name first, and Trellis's managed file is the baseline, not an
6
- * override of something else). pi has no native MCP client (docs/research.md),
7
- * so MCP access is delivered as a single symlinked bridge extension
8
- * instead of native config (trellis-pi-mcp-bridge-p4) — a different
9
- * mechanism from every other agent's config-generation adapter, but the
10
- * same symlink create/repair/remove machinery as skills/instructions.
6
+ * override of something else).
7
+ *
8
+ * MCP delivery is version-gated (trellis-pi-native-mcp-migration):
9
+ *
10
+ * - pi >= 1.1.0 has a native MCP client, so `trellis mcp sync` merges
11
+ * Trellis-managed entries into `~/.pi/agent/mcp.json` through the shared
12
+ * `planJsonMcp`/`applyJsonMcp` machinery (ownership ledger, conflict
13
+ * detection, user entries preserved) with the pi-shaped renderer from
14
+ * `piMcp.ts`. Gateway route → the single `trellis` runtime entry;
15
+ * agent-owned OAuth servers → bare `{url}` entries the user authorizes
16
+ * with `pi mcp login`.
17
+ * - Older pis (or an unprobeable version) keep the legacy delivery: a
18
+ * single symlinked bridge extension (`dist/pi-bridge/bundle.js`,
19
+ * trellis-pi-mcp-bridge-p4). The version gate never moves a machine
20
+ * FROM the bridge TO native until the mcp.json write succeeds — the
21
+ * bridge-removal item rides along in the same plan, ordered after the
22
+ * config write, and is withheld entirely when any write conflicts
23
+ * (design.md D3: the two deliveries may never be active at once, every
24
+ * tool would register twice).
11
25
  */
12
26
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
13
27
  import type { CanonicalSource } from "../core/types.js";
14
- import type { BackupSession } from "../lib/backup.js";
28
+ import { type BackupSession } from "../lib/backup.js";
15
29
  export declare class PiAdapter implements TrellisAdapter {
16
30
  private readonly homeDir;
31
+ /** Test seam: the version gate must not depend on whatever pi happens
32
+ * to be installed on the machine running the tests. */
33
+ private readonly versionProbe;
17
34
  readonly name = "pi";
18
35
  readonly id: "pi";
19
- constructor(homeDir?: string);
36
+ constructor(homeDir?: string,
37
+ /** Test seam: the version gate must not depend on whatever pi happens
38
+ * to be installed on the machine running the tests. */
39
+ versionProbe?: () => string | undefined);
20
40
  probe(): Promise<AdapterProbeResult>;
21
41
  plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
42
+ /**
43
+ * Native-delivery plan (design.md D1/D3). The write items come from the
44
+ * shared JSON machinery unchanged; the bridge-removal item is appended
45
+ * last and re-cast to kind "mcp" so it travels through `trellis mcp
46
+ * sync` — the same command that writes mcp.json — never through bare
47
+ * `trellis sync`. That is what makes the migration atomic from the
48
+ * user's point of view: the config lands and the bridge leaves in one
49
+ * apply, and a bare skills/instructions sync can neither remove the
50
+ * bridge early nor resurrect it afterwards.
51
+ */
52
+ private planNativeMcp;
53
+ /**
54
+ * Removal ownership for the bridge, mirroring `planSymlinks`' rule: the
55
+ * raw readlink target must resolve under this installation's
56
+ * `dist/pi-bridge/` directory, or be a byte-identical bundle at the
57
+ * Trellis artifact shape left by a previous install root. Anything else
58
+ * is the user's and stays.
59
+ */
60
+ private isOwnedBridgeSymlink;
22
61
  apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
23
62
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
24
63
  }
@@ -3,20 +3,39 @@
3
3
  * symlinked to canonical `agents.md` — specifically `AGENTS.md`, not
4
4
  * `AGENTS.override.md` (design.md D3, trellis-sync-p1: pi checks the
5
5
  * override name first, and Trellis's managed file is the baseline, not an
6
- * override of something else). pi has no native MCP client (docs/research.md),
7
- * so MCP access is delivered as a single symlinked bridge extension
8
- * instead of native config (trellis-pi-mcp-bridge-p4) — a different
9
- * mechanism from every other agent's config-generation adapter, but the
10
- * same symlink create/repair/remove machinery as skills/instructions.
6
+ * override of something else).
7
+ *
8
+ * MCP delivery is version-gated (trellis-pi-native-mcp-migration):
9
+ *
10
+ * - pi >= 1.1.0 has a native MCP client, so `trellis mcp sync` merges
11
+ * Trellis-managed entries into `~/.pi/agent/mcp.json` through the shared
12
+ * `planJsonMcp`/`applyJsonMcp` machinery (ownership ledger, conflict
13
+ * detection, user entries preserved) with the pi-shaped renderer from
14
+ * `piMcp.ts`. Gateway route → the single `trellis` runtime entry;
15
+ * agent-owned OAuth servers → bare `{url}` entries the user authorizes
16
+ * with `pi mcp login`.
17
+ * - Older pis (or an unprobeable version) keep the legacy delivery: a
18
+ * single symlinked bridge extension (`dist/pi-bridge/bundle.js`,
19
+ * trellis-pi-mcp-bridge-p4). The version gate never moves a machine
20
+ * FROM the bridge TO native until the mcp.json write succeeds — the
21
+ * bridge-removal item rides along in the same plan, ordered after the
22
+ * config write, and is withheld entirely when any write conflicts
23
+ * (design.md D3: the two deliveries may never be active at once, every
24
+ * tool would register twice).
11
25
  */
26
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
12
27
  import { dirname, join, sep } from "node:path";
13
28
  import { fileURLToPath } from "node:url";
14
29
  import { homedir } from "node:os";
15
- import { readFileSync } from "node:fs";
16
30
  import { isInScope } from "../core/adapter.js";
17
31
  import { usesNativeCapabilityDelivery } from "../core/types.js";
18
32
  import * as piProbe from "../probes/pi.js";
33
+ import { probePiVersion } from "../probes/pi.js";
19
34
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
35
+ import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
36
+ import { piRenderer, supportsNativeMcp } from "./piMcp.js";
37
+ import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
38
+ import { currentLinkTarget } from "../lib/backup.js";
20
39
  const BRIDGE_SYMLINK_NAME = "trellis-mcp-bridge.js";
21
40
  /**
22
41
  * A source-checkout bridge can survive a switch to the packaged CLI. Adopt it
@@ -61,10 +80,15 @@ function resolveBridgeFile() {
61
80
  }
62
81
  export class PiAdapter {
63
82
  homeDir;
83
+ versionProbe;
64
84
  name = "pi";
65
85
  id = "pi";
66
- constructor(homeDir = homedir()) {
86
+ constructor(homeDir = homedir(),
87
+ /** Test seam: the version gate must not depend on whatever pi happens
88
+ * to be installed on the machine running the tests. */
89
+ versionProbe = probePiVersion) {
67
90
  this.homeDir = homeDir;
91
+ this.versionProbe = versionProbe;
68
92
  }
69
93
  async probe() {
70
94
  const snapshot = await piProbe.probe(this.homeDir);
@@ -88,6 +112,9 @@ export class PiAdapter {
88
112
  canonicalRoot,
89
113
  kind: "instructions",
90
114
  });
115
+ if (supportsNativeMcp(this.versionProbe())) {
116
+ return [...skillItems, ...instructionsItems, ...this.planNativeMcp(canonical)];
117
+ }
91
118
  const { file: bridgeFile, symlinkName } = resolveBridgeFile();
92
119
  const extensionItems = planSymlinks({
93
120
  rootDir: join(agentDir, "extensions"),
@@ -101,8 +128,104 @@ export class PiAdapter {
101
128
  });
102
129
  return [...skillItems, ...instructionsItems, ...extensionItems];
103
130
  }
131
+ /**
132
+ * Native-delivery plan (design.md D1/D3). The write items come from the
133
+ * shared JSON machinery unchanged; the bridge-removal item is appended
134
+ * last and re-cast to kind "mcp" so it travels through `trellis mcp
135
+ * sync` — the same command that writes mcp.json — never through bare
136
+ * `trellis sync`. That is what makes the migration atomic from the
137
+ * user's point of view: the config lands and the bridge leaves in one
138
+ * apply, and a bare skills/instructions sync can neither remove the
139
+ * bridge early nor resurrect it afterwards.
140
+ */
141
+ planNativeMcp(canonical) {
142
+ const agentDir = join(this.homeDir, ".pi", "agent");
143
+ const configPath = join(agentDir, "mcp.json");
144
+ const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
145
+ const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
146
+ const items = planJsonMcp({
147
+ configPath,
148
+ parsed,
149
+ mcp: canonical.mcp,
150
+ agentId: this.id,
151
+ managedAgents: canonical.managedAgents,
152
+ policy: canonical.secretsPolicy,
153
+ ownership,
154
+ render: piRenderer,
155
+ }).map((item) => {
156
+ // pi speaks stdio and streamable HTTP only — an sse server gets an
157
+ // honest conflict instead of an entry pi could never connect.
158
+ const write = item.mcpWrite;
159
+ if (write?.def.transport !== "sse")
160
+ return item;
161
+ return {
162
+ action: "conflict",
163
+ kind: "mcp",
164
+ target: item.target,
165
+ description: `MCP server "${write.name}" uses the sse transport, which pi does not support (stdio and streamable HTTP only) — no entry written to ${item.target}`,
166
+ remediation: "switch the server to streamable HTTP or stdio, or scope it away from pi",
167
+ };
168
+ });
169
+ const bridgePath = join(agentDir, "extensions", BRIDGE_SYMLINK_NAME);
170
+ const hasConflict = items.some((item) => item.action === "conflict");
171
+ if (!hasConflict && this.isOwnedBridgeSymlink(bridgePath)) {
172
+ items.push({
173
+ action: "remove",
174
+ kind: "mcp",
175
+ target: bridgePath,
176
+ description: `remove Trellis MCP bridge extension ${bridgePath} — pi >= 1.1.0 receives MCP natively via mcp.json`,
177
+ });
178
+ }
179
+ return items;
180
+ }
181
+ /**
182
+ * Removal ownership for the bridge, mirroring `planSymlinks`' rule: the
183
+ * raw readlink target must resolve under this installation's
184
+ * `dist/pi-bridge/` directory, or be a byte-identical bundle at the
185
+ * Trellis artifact shape left by a previous install root. Anything else
186
+ * is the user's and stays.
187
+ */
188
+ isOwnedBridgeSymlink(bridgePath) {
189
+ const stat = lstatSync(bridgePath, { throwIfNoEntry: false });
190
+ if (!stat?.isSymbolicLink())
191
+ return false;
192
+ const rawTarget = readlinkSync(bridgePath);
193
+ const { file } = resolveBridgeFile();
194
+ const bridgeRoot = dirname(file);
195
+ const resolved = rawTarget.startsWith(sep) ? rawTarget : join(dirname(bridgePath), rawTarget);
196
+ if (resolved === file || resolved.startsWith(bridgeRoot.endsWith(sep) ? bridgeRoot : `${bridgeRoot}${sep}`))
197
+ return true;
198
+ return isEquivalentTrellisBridge(resolved, file);
199
+ }
104
200
  async apply(plan, backup) {
105
- await applySymlinkPlan(plan, backup);
201
+ await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
202
+ // Real config writes carry mcpWrite/mcpRemove; the bridge-removal
203
+ // item is the only kind:"mcp" entry without either, and it must run
204
+ // strictly after the config write (design.md D3).
205
+ const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.mcpWrite || item.mcpRemove));
206
+ const bridgeRemovals = plan.filter((item) => item.kind === "mcp" && item.action === "remove" && !item.mcpWrite && !item.mcpRemove);
207
+ if (mcpWrites.length > 0) {
208
+ const configPath = mcpWrites[0].target;
209
+ const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
210
+ const merged = applyJsonMcp(parsed, mcpWrites, piRenderer);
211
+ backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
212
+ let ownership = loadMcpOwnership(this.homeDir);
213
+ for (const item of mcpWrites) {
214
+ if (item.action === "create" && item.mcpWrite) {
215
+ ownership = recordOwned(ownership, this.id, item.mcpWrite.name, piRenderer(item.mcpWrite.def, item.mcpWrite.name));
216
+ }
217
+ else if (item.action === "remove" && item.mcpRemove) {
218
+ ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
219
+ }
220
+ }
221
+ saveMcpOwnership(this.homeDir, ownership);
222
+ }
223
+ for (const item of bridgeRemovals) {
224
+ const oldTarget = await currentLinkTarget(item.target);
225
+ if (oldTarget === undefined)
226
+ continue; // already gone — nothing to record
227
+ await backup.removeSymlink(item.target, oldTarget);
228
+ }
106
229
  }
107
230
  async verify(canonical) {
108
231
  const snapshot = await piProbe.probe(this.homeDir);
@@ -122,6 +245,26 @@ export class PiAdapter {
122
245
  mismatches.push(`skill "${name}" is present on disk but not in canonical scope for pi`);
123
246
  }
124
247
  }
248
+ if (supportsNativeMcp(this.versionProbe())) {
249
+ // Native mode drift: anything the plan still wants to change is by
250
+ // definition drift. The plan only ever names Trellis-owned entries
251
+ // (the ownership ledger gates removals), so user-authored servers
252
+ // can never surface here.
253
+ for (const item of this.planNativeMcp(canonical)) {
254
+ if (item.action === "create" && item.mcpWrite) {
255
+ mismatches.push(`mcp.json entry "${item.mcpWrite.name}" is missing or differs from canonical — run trellis mcp sync`);
256
+ }
257
+ else if (item.action === "remove" && item.mcpRemove) {
258
+ mismatches.push(`mcp.json entry "${item.mcpRemove.name}" is Trellis-owned but no longer in canonical — run trellis mcp sync`);
259
+ }
260
+ else if (item.action === "remove") {
261
+ mismatches.push(`the Trellis MCP bridge extension is still installed but pi >= 1.1.0 uses native mcp.json — run trellis mcp sync`);
262
+ }
263
+ else if (item.action === "conflict") {
264
+ mismatches.push(item.description);
265
+ }
266
+ }
267
+ }
125
268
  return mismatches.length === 0 ? { ok: true } : { ok: false, mismatches };
126
269
  }
127
270
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * pi's native MCP config (pi >= 1.1.0) — the entry renderer and the
3
+ * version gate that chooses between it and the legacy bridge extension
4
+ * (openspec/changes/pi-native-mcp-migration design.md D1/D2).
5
+ *
6
+ * pi's mcp.json has no `type` discriminator: an entry is stdio when it
7
+ * carries `command`, streamable HTTP when it carries `url`. `${VAR}`
8
+ * references in `env`/`headers` values are expanded by pi at spawn
9
+ * (verified empirically against pi 1.1.0 on 2026-10-11), so the
10
+ * zero-plaintext rendering every other JSON agent relies on works
11
+ * unchanged. sse is not a pi transport — the adapter turns sse servers
12
+ * into conflict items rather than writing entries pi cannot connect.
13
+ */
14
+ import type { McpServerDef } from "../core/types.js";
15
+ /** The pi release that introduced the native MCP client (`pi mcp`,
16
+ * `~/.pi/agent/mcp.json`). */
17
+ export declare const PI_NATIVE_MCP_SINCE = "1.1.0";
18
+ /**
19
+ * Version gate (design.md D2): native delivery only when the probed
20
+ * version is known to understand mcp.json. Anything unparseable —
21
+ * including "binary not on PATH" (undefined) — falls back to the bridge,
22
+ * never the other way round: writing config a pi cannot read is worse
23
+ * than keeping a working bridge.
24
+ */
25
+ export declare function supportsNativeMcp(version: string | undefined): boolean;
26
+ /**
27
+ * pi-shaped rendering of a canonical server definition — the same shape
28
+ * `pi mcp add` writes (observed 2026-10-11): stdio as
29
+ * `{command, args, env?}`, http as `{url, headers?}`, no `type` field.
30
+ * `env` names render as `${VAR}` references exactly like the other JSON
31
+ * agents; OAuth `auth` metadata is Trellis-side routing information and
32
+ * is never rendered (an agent-owned OAuth server becomes a bare `{url}`
33
+ * entry that `pi mcp login <name>` authorizes into pi's own token store).
34
+ */
35
+ export declare function piRenderer(def: McpServerDef, _name: string): Record<string, unknown>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * pi's native MCP config (pi >= 1.1.0) — the entry renderer and the
3
+ * version gate that chooses between it and the legacy bridge extension
4
+ * (openspec/changes/pi-native-mcp-migration design.md D1/D2).
5
+ *
6
+ * pi's mcp.json has no `type` discriminator: an entry is stdio when it
7
+ * carries `command`, streamable HTTP when it carries `url`. `${VAR}`
8
+ * references in `env`/`headers` values are expanded by pi at spawn
9
+ * (verified empirically against pi 1.1.0 on 2026-10-11), so the
10
+ * zero-plaintext rendering every other JSON agent relies on works
11
+ * unchanged. sse is not a pi transport — the adapter turns sse servers
12
+ * into conflict items rather than writing entries pi cannot connect.
13
+ */
14
+ import { resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
15
+ /** The pi release that introduced the native MCP client (`pi mcp`,
16
+ * `~/.pi/agent/mcp.json`). */
17
+ export const PI_NATIVE_MCP_SINCE = "1.1.0";
18
+ /**
19
+ * Version gate (design.md D2): native delivery only when the probed
20
+ * version is known to understand mcp.json. Anything unparseable —
21
+ * including "binary not on PATH" (undefined) — falls back to the bridge,
22
+ * never the other way round: writing config a pi cannot read is worse
23
+ * than keeping a working bridge.
24
+ */
25
+ export function supportsNativeMcp(version) {
26
+ if (version === undefined)
27
+ return false;
28
+ const match = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(version.trim());
29
+ if (!match)
30
+ return false;
31
+ const major = Number(match[1]);
32
+ const minor = Number(match[2]);
33
+ return major > 1 || (major === 1 && minor >= 1);
34
+ }
35
+ /**
36
+ * pi-shaped rendering of a canonical server definition — the same shape
37
+ * `pi mcp add` writes (observed 2026-10-11): stdio as
38
+ * `{command, args, env?}`, http as `{url, headers?}`, no `type` field.
39
+ * `env` names render as `${VAR}` references exactly like the other JSON
40
+ * agents; OAuth `auth` metadata is Trellis-side routing information and
41
+ * is never rendered (an agent-owned OAuth server becomes a bare `{url}`
42
+ * entry that `pi mcp login <name>` authorizes into pi's own token store).
43
+ */
44
+ export function piRenderer(def, _name) {
45
+ if (def.transport === "http" || def.transport === "sse") {
46
+ const entry = { url: def.url };
47
+ if (def.headers && Object.keys(def.headers).length > 0) {
48
+ entry.headers = def.headers;
49
+ }
50
+ return entry;
51
+ }
52
+ const entry = { command: def.command, args: def.args ?? [] };
53
+ const env = resolvedEnvTextMap(def);
54
+ if (Object.keys(env).length > 0) {
55
+ entry.env = env;
56
+ }
57
+ return entry;
58
+ }
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { parseScopeArgs } from "./lib/scopeSelection.js";
14
14
  import { runMcpImport } from "./commands/mcpImport.js";
15
15
  import { parseMcpGatewayArgs, runMcpGateway, runMcpRuntime } from "./commands/mcpGateway.js";
16
16
  import { runMcpAuth } from "./commands/mcpAuth.js";
17
+ import { runMcpRenew } from "./commands/mcpRenew.js";
17
18
  import { runSecretsAudit } from "./commands/secretsAudit.js";
18
19
  import { runRollback } from "./commands/rollback.js";
19
20
  import { runSkillList, runSkillAdd, runSkillRemove, runSkillScope, runSkillUpdateBuiltin } from "./commands/skill.js";
@@ -159,6 +160,16 @@ Commands:
159
160
  refreshes silently on its own and never prompts.
160
161
  --force re-authorize even if the stored token is valid
161
162
  --json machine-readable output, no report text
163
+ mcp renew
164
+ Silently refresh every Trellis-owned stored OAuth token that
165
+ needs it —
166
+ never opens a browser, never performs an initial grant.
167
+ Reports per-server health and exits non-zero when any
168
+ classified server needs 'mcp auth'. Safe under a scheduler
169
+ (launchd): idempotent on valid tokens, refreshes serialized
170
+ per server, overlapping runs degrade to one refresh.
171
+ --dry-run report status without any token-endpoint call
172
+ --json machine-readable output, no report text
162
173
  mcp-runtime --agent <agent>
163
174
  Internal MCP edge exposing Trellis providers and selected
164
175
  upstream capabilities; existing mcp-gateway configs remain valid
@@ -470,6 +481,11 @@ async function main(argv) {
470
481
  process.exitCode = (await runMcpRemove(name, { json, dryRun })).exitCode;
471
482
  return;
472
483
  }
484
+ if (subcommand === "renew") {
485
+ const { exitCode } = await runMcpRenew({ json, dryRun });
486
+ process.exitCode = exitCode;
487
+ return;
488
+ }
473
489
  if (subcommand === "auth") {
474
490
  const [serverName] = mcpRest;
475
491
  if (!serverName || serverName.startsWith("--")) {
@@ -481,7 +497,7 @@ async function main(argv) {
481
497
  process.exitCode = exitCode;
482
498
  return;
483
499
  }
484
- console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|scope <name>|remove <name>|auth <name>\n`);
500
+ console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|scope <name>|remove <name>|auth <name>|renew\n`);
485
501
  process.exitCode = 1;
486
502
  return;
487
503
  }
@@ -5,7 +5,7 @@
5
5
  * throughout docs/research.md's investigation, now formalized.
6
6
  */
7
7
  import { loadCanonicalSource } from "../core/canonical.js";
8
- import type { AgentId, AgentSnapshot } from "../core/types.js";
8
+ import type { AgentId, AgentSnapshot, CanonicalSource } from "../core/types.js";
9
9
  /**
10
10
  * Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
11
11
  * real `known_host_injected` field (P0 has no canonical source at all —
@@ -14,7 +14,7 @@ import type { AgentId, AgentSnapshot } from "../core/types.js";
14
14
  * machine.
15
15
  */
16
16
  export declare const DEFAULT_KNOWN_HOST_INJECTED: readonly string[];
17
- export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "mcp-unreachable";
17
+ export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable";
18
18
  export interface Finding {
19
19
  kind: FindingKind;
20
20
  /** Omitted for a finding that names more than one agent (drift). */
@@ -48,9 +48,19 @@ export interface RunDoctorOptions {
48
48
  * every other Trellis entry point uses. */
49
49
  homeDir?: string;
50
50
  }
51
+ export interface OAuthHealthEntry {
52
+ server: string;
53
+ status: "valid" | "expired" | "absent" | "unreadable";
54
+ expiresAt?: number;
55
+ }
51
56
  export interface DoctorReport {
52
57
  snapshots: AgentSnapshot[];
53
58
  findings: Finding[];
59
+ /** Credential health for canonical OAuth-classified servers. Absent
60
+ * when no canonical source exists (doctor stays standalone, P0) or no
61
+ * server is OAuth-classified. Read-only by construction
62
+ * (trellis-mcp-oauth-renew design.md D4). */
63
+ oauth?: OAuthHealthEntry[];
54
64
  }
55
65
  /** Exported so tests can assert on this resolution directly, without
56
66
  * routing through real per-agent probes `runDoctor` also runs. */
@@ -67,6 +77,16 @@ export declare function runDoctor(opts?: RunDoctorOptions): Promise<{
67
77
  * `undefined`) is unaffected.
68
78
  */
69
79
  export declare function collectDoctorReport(homeDir?: string, knownHostInjected?: readonly string[], probeMcp?: boolean): Promise<DoctorReport>;
80
+ /**
81
+ * Read-only OAuth credential health (trellis-mcp-oauth-renew design.md
82
+ * D4): classified from the stored token file alone — no token endpoint,
83
+ * no writes. Scope is canonical Trellis-owned OAuth servers only, the
84
+ * same rule `trellis mcp renew` iterates (design.md D5 +
85
+ * trellis-desktop-oauth-ownership: an agent-owned server's authorization
86
+ * lives in the Agent's own client, so Trellis's empty token store for it
87
+ * is expected, never a finding), via the same classifier (design.md D2).
88
+ */
89
+ export declare function collectOAuthHealth(homeDir: string, canonical: Pick<CanonicalSource, "mcp">): OAuthHealthEntry[];
70
90
  /**
71
91
  * Within one agent, across all of its skill roots: any realpath reached by
72
92
  * more than one entry, where at least one of those entries is NOT a
@@ -13,7 +13,9 @@ import * as kimiCodeProbe from "../probes/kimi-code.js";
13
13
  import * as zcodeProbe from "../probes/zcode.js";
14
14
  import { loadCanonicalSource } from "../core/canonical.js";
15
15
  import { GATEWAY_ENTRY_NAME, RUNTIME_ENTRY_NAME, resolveMcpPlan } from "../adapters/mcpPlan.js";
16
- import { ALL_AGENTS } from "../core/types.js";
16
+ import { ALL_AGENTS, isOAuthAuth, oauthOwner } from "../core/types.js";
17
+ import { classifyStoredToken, remediationFor } from "../lib/oauth/renew.js";
18
+ import { readToken } from "../lib/oauth/store.js";
17
19
  /**
18
20
  * Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
19
21
  * real `known_host_injected` field (P0 has no canonical source at all —
@@ -36,6 +38,10 @@ const EXIT_NONZERO_KINDS = new Set([
36
38
  "case-mismatch",
37
39
  "parse-diagnostic",
38
40
  "runtime-drift",
41
+ // A stored credential that will 401 on next use is exactly what doctor
42
+ // exists to catch *before* the session does (trellis-mcp-oauth-renew
43
+ // design.md D4).
44
+ "oauth-expired",
39
45
  ]);
40
46
  /** Exported so tests can assert on this resolution directly, without
41
47
  * routing through real per-agent probes `runDoctor` also runs. */
@@ -103,8 +109,42 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
103
109
  // Doctor remains useful as a standalone cross-agent scanner when
104
110
  // canonical has not been initialized yet.
105
111
  }
106
- findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots));
107
- return { snapshots, findings };
112
+ const oauth = canonical ? collectOAuthHealth(homeDir, canonical) : [];
113
+ findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots), ...oauth.flatMap((entry) => (entry.status === "valid" ? [] : [oauthFinding(entry)])));
114
+ return { snapshots, findings, ...(oauth.length > 0 ? { oauth } : {}) };
115
+ }
116
+ /**
117
+ * Read-only OAuth credential health (trellis-mcp-oauth-renew design.md
118
+ * D4): classified from the stored token file alone — no token endpoint,
119
+ * no writes. Scope is canonical Trellis-owned OAuth servers only, the
120
+ * same rule `trellis mcp renew` iterates (design.md D5 +
121
+ * trellis-desktop-oauth-ownership: an agent-owned server's authorization
122
+ * lives in the Agent's own client, so Trellis's empty token store for it
123
+ * is expected, never a finding), via the same classifier (design.md D2).
124
+ */
125
+ export function collectOAuthHealth(homeDir, canonical) {
126
+ return Object.entries(canonical.mcp.servers)
127
+ .filter(([, def]) => isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis")
128
+ .map(([name]) => name)
129
+ .sort()
130
+ .map((name) => {
131
+ const status = classifyStoredToken(homeDir, name);
132
+ const expiresAt = readToken(homeDir, name)?.expiresAt;
133
+ const collapsed = status === "valid" ? "valid"
134
+ : status === "no-token" ? "absent"
135
+ : status === "unreadable" ? "unreadable"
136
+ : "expired";
137
+ return { server: name, status: collapsed, ...(expiresAt !== undefined ? { expiresAt } : {}) };
138
+ });
139
+ }
140
+ function oauthFinding(entry) {
141
+ const reason = entry.status === "absent" ? "has never been authorized"
142
+ : entry.status === "unreadable" ? "has an unreadable credential file"
143
+ : "has an expired stored token";
144
+ return {
145
+ kind: "oauth-expired",
146
+ message: `OAuth server "${entry.server}" ${reason} — ${remediationFor(entry.status === "expired" ? "expired-renewable" : "needs-auth", entry.server)}`,
147
+ };
108
148
  }
109
149
  /**
110
150
  * Within one agent, across all of its skill roots: any realpath reached by
@@ -308,6 +348,14 @@ export function printReport(report) {
308
348
  console.log(` - ${finding.message}`);
309
349
  }
310
350
  }
351
+ if (report.oauth && report.oauth.length > 0) {
352
+ console.log("\nOAuth credentials:");
353
+ for (const entry of report.oauth) {
354
+ const expiry = entry.expiresAt !== undefined ? ` (expires ${new Date(entry.expiresAt).toISOString()})` : "";
355
+ const icon = entry.status === "valid" ? "✅" : "❌";
356
+ console.log(`${icon} ${entry.server} — ${entry.status}${expiry}`);
357
+ }
358
+ }
311
359
  if (crossAgent.length > 0) {
312
360
  console.log("\nCross-agent:");
313
361
  for (const finding of crossAgent) {
@@ -111,6 +111,12 @@ export function printReport(report, dryRun) {
111
111
  for (const item of [...created, ...removed, ...conflicts]) {
112
112
  console.log(` - [${item.action}] ${item.description}`);
113
113
  }
114
+ // pi does not watch mcp.json (verified 2026-10-11): without this hint
115
+ // a changed config silently only takes effect on the next pi launch
116
+ // (trellis-pi-native-mcp-migration design.md D5).
117
+ if (agent === "pi" && (created.length > 0 || removed.length > 0)) {
118
+ console.log(" pi does not watch mcp.json — restart pi or run /reload to pick up MCP changes");
119
+ }
114
120
  }
115
121
  }
116
122
  function serversYamlPath(homeDir) {
@@ -46,6 +46,7 @@ export declare function parseMcpGatewayArgs(argv: readonly string[]): {
46
46
  */
47
47
  export declare function resolveGatewayUpstreams(agentId: AgentId, canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): {
48
48
  upstreams: UpstreamSpec[];
49
+ oauthServers: string[];
49
50
  conflicts: string[];
50
51
  };
51
52
  export declare function runMcpGateway(opts: RunMcpGatewayOptions): Promise<{
@@ -27,7 +27,7 @@ import { TaskProvider } from "../lib/taskProvider.js";
27
27
  import { AgentBridgeProvider } from "../lib/agentBridgeProvider.js";
28
28
  import { RuntimeMemoryProvider } from "../lib/memoryProvider.js";
29
29
  import { SkillProvider } from "../lib/skillProvider.js";
30
- import { ALL_AGENTS } from "../core/types.js";
30
+ import { ALL_AGENTS, isOAuthAuth } from "../core/types.js";
31
31
  const CLIENT_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
32
32
  const SERVER_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
33
33
  const RUNTIME_CLIENT_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
@@ -60,11 +60,18 @@ export function resolveGatewayUpstreams(agentId, canonical) {
60
60
  const route = canonical.mcp.routes?.[agentId];
61
61
  const hubActive = route ? route.mode === "hub" : Boolean(canonical.mcp.hub) && !isGatewayAgent(agentId, canonical.mcp, canonical.managedAgents);
62
62
  if (hubActive) {
63
- return { upstreams: [], conflicts: [] };
63
+ // Hub mode writes no direct entries at all (mcpPlan's hub branch), so
64
+ // there are no OAuth servers for the status tool to report either.
65
+ return { upstreams: [], oauthServers: [], conflicts: [] };
64
66
  }
65
67
  const directRoutes = canonical.mcp.routes && route
66
68
  ? { ...canonical.mcp.routes, [agentId]: { mode: "direct", ...(route.servers ? { servers: route.servers } : {}) } }
67
69
  : canonical.mcp.routes;
70
+ // Trellis-owned OAuth servers stay in the plan as ordinary upstreams
71
+ // (auth stripped here so the direct-mode plan keeps them; the original
72
+ // defs are restored below so withOAuthHeader can attach their tokens at
73
+ // connect). Agent-owned ones are excluded entirely: sync writes those as
74
+ // direct native entries beside the gateway (trellis-desktop-oauth-ownership).
68
75
  const ordinaryServers = Object.fromEntries(Object.entries(canonical.mcp.servers)
69
76
  .filter(([, def]) => !isAgentOwnedOAuth(def))
70
77
  .map(([name, def]) => { const { auth: _auth, ...ordinary } = def; return [name, ordinary]; }));
@@ -81,8 +88,20 @@ export function resolveGatewayUpstreams(agentId, canonical) {
81
88
  // every server, and every secret resolved into it, to the one agent the
82
89
  // user just said to stop involving.
83
90
  const { desired, conflicts } = resolveMcpPlan(agentId, direct, canonical.managedAgents, canonical.secretsPolicy);
91
+ // Agent-owned OAuth servers bypass the gateway as direct entries, so no
92
+ // connection state exists for them here — but Trellis owns the token
93
+ // store, so `trellis.mcp.status` reports their credential health for
94
+ // exactly this set. It is resolved through a second direct-mode plan
95
+ // over just those servers, so scope, `enabled: false` and route
96
+ // restrictions match what sync writes (the identical planDirectServers
97
+ // checks; trellis-mcp-status-credential-health design.md D4).
98
+ const agentOwnedServers = Object.fromEntries(Object.entries(canonical.mcp.servers).filter(([, def]) => isAgentOwnedOAuth(def)));
99
+ const oauthServers = Object.keys(agentOwnedServers).length === 0
100
+ ? []
101
+ : resolveMcpPlan(agentId, { ...direct, servers: agentOwnedServers }, canonical.managedAgents, canonical.secretsPolicy).desired.map(({ name }) => name);
84
102
  return {
85
103
  upstreams: desired.map(({ name }) => ({ name, def: canonical.mcp.servers[name] })),
104
+ oauthServers,
86
105
  conflicts: conflicts.map((conflict) => conflict.message),
87
106
  };
88
107
  }
@@ -109,7 +128,7 @@ async function runMcpEdge(opts, clientInfo, serverInfo) {
109
128
  console.error(`trellis-mcp-gateway: ${err instanceof Error ? err.message : String(err)}`);
110
129
  return { exitCode: 1 };
111
130
  }
112
- const { upstreams, conflicts } = resolveGatewayUpstreams(opts.agentId, canonical);
131
+ const { upstreams, oauthServers, conflicts } = resolveGatewayUpstreams(opts.agentId, canonical);
113
132
  for (const message of conflicts) {
114
133
  console.error(`trellis-mcp-gateway: ${message}`);
115
134
  }
@@ -124,7 +143,16 @@ async function runMcpEdge(opts, clientInfo, serverInfo) {
124
143
  // process has no terminal to run one in (design.md D9).
125
144
  homeDir,
126
145
  });
127
- const registry = new BuiltinRegistry([new SkillProvider(), new RuntimeMemoryProvider(), new RuntimeControlProvider(backend), new McpStatusProvider(backend), new TaskProvider(), new AgentBridgeProvider(), new UpstreamProvider(backend)]);
146
+ // The status tool's oauth section covers both ownership kinds
147
+ // (trellis-desktop-oauth-ownership): trellis-owned servers arrive as
148
+ // upstreams (auth intact) — their Trellis-store credential health is
149
+ // what the gateway itself will rely on at connect; agent-owned servers
150
+ // bypass the gateway as direct entries and are marked agent-managed.
151
+ const oauthReport = [
152
+ ...upstreams.filter((u) => isOAuthAuth(u.def.auth)).map((u) => ({ name: u.name, owner: "trellis" })),
153
+ ...oauthServers.map((name) => ({ name, owner: "agent" })),
154
+ ];
155
+ const registry = new BuiltinRegistry([new SkillProvider(), new RuntimeMemoryProvider(), new RuntimeControlProvider(backend), new McpStatusProvider(backend, { homeDir, oauthServers: oauthReport }), new TaskProvider(), new AgentBridgeProvider(), new UpstreamProvider(backend)]);
128
156
  const server = serveStdio(() => createRuntimeServer(serverInfo, { agentId: opts.agentId, homeDir }, registry));
129
157
  await waitForShutdown(registry, server);
130
158
  return { exitCode: 0 };