agent-trellis 0.12.0 → 0.13.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.
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) {
@@ -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 };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `trellis mcp renew` — scheduled, non-interactive renewal of stored OAuth
3
+ * tokens (trellis-mcp-oauth-renew).
4
+ *
5
+ * Deliberately an orchestrator and nothing more: the refresh itself is
6
+ * `ensureFreshToken`, the same function the gateway calls before every
7
+ * upstream request, with the same lock, skew, and rotation semantics
8
+ * (design.md D1). This command's entire job is to run that path *before*
9
+ * an Agent session discovers a dead credential as a 401, and to say
10
+ * plainly which servers need a human.
11
+ *
12
+ * Never opens a browser, never performs an initial grant — that remains
13
+ * `trellis mcp auth` (mcp-gateway-hosting design.md D9). Scope is
14
+ * canonical Trellis-owned OAuth servers only: classification is explicit,
15
+ * never inferred from token files or URLs (design.md D5), and since
16
+ * trellis-desktop-oauth-ownership an explicit `owner: trellis` is what
17
+ * makes a credential Trellis's responsibility at all — agent-owned
18
+ * servers are authorized by the Agent's own client, which neither reads
19
+ * Trellis's token store nor can be renewed from here, so reporting them
20
+ * as `no-token` would cry wolf on every run.
21
+ */
22
+ import { type OAuthRenewStatus } from "../lib/oauth/renew.js";
23
+ import type { FetchLike } from "../lib/oauth/discovery.js";
24
+ export interface RunMcpRenewOptions {
25
+ json?: boolean;
26
+ dryRun?: boolean;
27
+ /** Defaults to the real `~`; overridable for tests — the seam every
28
+ * other Trellis entry point exposes. */
29
+ homeDir?: string;
30
+ /** Test seam for the token-endpoint call — an interval-scheduled run
31
+ * must use the real fetch, so this is not a supported user flag. */
32
+ fetchImpl?: FetchLike;
33
+ }
34
+ export interface McpRenewEntry {
35
+ server: string;
36
+ status: OAuthRenewStatus;
37
+ expiresAt?: number;
38
+ remediation?: string;
39
+ }
40
+ export interface McpRenewResult {
41
+ entries: McpRenewEntry[];
42
+ /** True when every classified server is healthy without a human step
43
+ * (design.md D6). The vacuous case — zero OAuth servers — is ok. */
44
+ ok: boolean;
45
+ }
46
+ /** Exported so tests and `doctor` reuse the exact iteration/scope rule
47
+ * without capturing stdout or re-deriving it (design.md D2's one-rule
48
+ * property). Read-only when `dryRun`: no network, no writes. */
49
+ export declare function collectRenewEntries(opts: RunMcpRenewOptions): Promise<McpRenewResult>;
50
+ export declare function runMcpRenew(opts: RunMcpRenewOptions): Promise<{
51
+ exitCode: number;
52
+ }>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `trellis mcp renew` — scheduled, non-interactive renewal of stored OAuth
3
+ * tokens (trellis-mcp-oauth-renew).
4
+ *
5
+ * Deliberately an orchestrator and nothing more: the refresh itself is
6
+ * `ensureFreshToken`, the same function the gateway calls before every
7
+ * upstream request, with the same lock, skew, and rotation semantics
8
+ * (design.md D1). This command's entire job is to run that path *before*
9
+ * an Agent session discovers a dead credential as a 401, and to say
10
+ * plainly which servers need a human.
11
+ *
12
+ * Never opens a browser, never performs an initial grant — that remains
13
+ * `trellis mcp auth` (mcp-gateway-hosting design.md D9). Scope is
14
+ * canonical Trellis-owned OAuth servers only: classification is explicit,
15
+ * never inferred from token files or URLs (design.md D5), and since
16
+ * trellis-desktop-oauth-ownership an explicit `owner: trellis` is what
17
+ * makes a credential Trellis's responsibility at all — agent-owned
18
+ * servers are authorized by the Agent's own client, which neither reads
19
+ * Trellis's token store nor can be renewed from here, so reporting them
20
+ * as `no-token` would cry wolf on every run.
21
+ */
22
+ import { homedir } from "node:os";
23
+ import { loadCanonicalSource } from "../core/canonical.js";
24
+ import { isOAuthAuth, oauthOwner } from "../core/types.js";
25
+ import { ensureFreshToken } from "../lib/oauth/refresh.js";
26
+ import { classifyRefreshFailure, classifyStoredToken, FAILURE_STATUSES, remediationFor, } from "../lib/oauth/renew.js";
27
+ import { readToken } from "../lib/oauth/store.js";
28
+ /** Exported so tests and `doctor` reuse the exact iteration/scope rule
29
+ * without capturing stdout or re-deriving it (design.md D2's one-rule
30
+ * property). Read-only when `dryRun`: no network, no writes. */
31
+ export async function collectRenewEntries(opts) {
32
+ const homeDir = opts.homeDir ?? homedir();
33
+ const canonical = loadCanonicalSource(homeDir);
34
+ const names = Object.entries(canonical.mcp.servers)
35
+ .filter(([, def]) => isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis")
36
+ .map(([name]) => name)
37
+ .sort();
38
+ const entries = [];
39
+ for (const name of names) {
40
+ entries.push(await renewOne(homeDir, name, opts));
41
+ }
42
+ return { entries, ok: entries.every((entry) => !FAILURE_STATUSES.has(entry.status)) };
43
+ }
44
+ async function renewOne(homeDir, name, opts) {
45
+ // The pre-refresh classification decides what `ensureFreshToken`'s
46
+ // return means: it only contacts the endpoint when the stored token was
47
+ // absent-but-not-required (returns undefined) or expiring (refreshes),
48
+ // so "expired-renewable before, token after" ⇒ a refresh just happened.
49
+ const before = classifyStoredToken(homeDir, name);
50
+ if (opts.dryRun)
51
+ return withDetails(homeDir, name, before);
52
+ try {
53
+ const token = await ensureFreshToken(homeDir, name, { ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });
54
+ if (!token)
55
+ return withDetails(homeDir, name, "no-token");
56
+ return withDetails(homeDir, name, before === "expired-renewable" ? "refreshed" : "valid");
57
+ }
58
+ catch (err) {
59
+ return withDetails(homeDir, name, classifyRefreshFailure(err));
60
+ }
61
+ }
62
+ function withDetails(homeDir, name, status) {
63
+ const expiresAt = readToken(homeDir, name)?.expiresAt;
64
+ return {
65
+ server: name,
66
+ status,
67
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
68
+ ...(FAILURE_STATUSES.has(status) || status === "expired-renewable" ? { remediation: remediationFor(status, name) } : {}),
69
+ };
70
+ }
71
+ const STATUS_ICON = {
72
+ valid: "✅",
73
+ refreshed: "🔄",
74
+ "expired-renewable": "⏳",
75
+ "no-token": "❌",
76
+ "refresh-failed": "❌",
77
+ "needs-auth": "❌",
78
+ unreadable: "⚠️",
79
+ };
80
+ export async function runMcpRenew(opts) {
81
+ let result;
82
+ try {
83
+ result = await collectRenewEntries(opts);
84
+ }
85
+ catch (err) {
86
+ console.error(err instanceof Error ? err.message : String(err));
87
+ return { exitCode: 1 };
88
+ }
89
+ if (opts.json) {
90
+ console.log(JSON.stringify(result, null, 2));
91
+ }
92
+ else if (result.entries.length === 0) {
93
+ console.log("No OAuth-classified MCP servers in canonical config — nothing to renew.");
94
+ }
95
+ else {
96
+ for (const entry of result.entries) {
97
+ const expiry = entry.expiresAt !== undefined ? ` (expires ${new Date(entry.expiresAt).toISOString()})` : "";
98
+ console.log(`${STATUS_ICON[entry.status]} ${entry.server} — ${entry.status}${expiry}`);
99
+ if (entry.remediation)
100
+ console.log(` → ${entry.remediation}`);
101
+ }
102
+ }
103
+ return { exitCode: result.ok ? 0 : 1 };
104
+ }
@@ -45,6 +45,11 @@ export interface UpstreamStatus {
45
45
  name: string;
46
46
  transport: "stdio" | "http" | "sse";
47
47
  status: UpstreamStatusKind;
48
+ /** How many tools this upstream contributed at connect time — the
49
+ * structured form of the count `detail` already carries in prose, so
50
+ * Agents reasoning about capability never parse a sentence
51
+ * (trellis-mcp-status-credential-health design.md D5). */
52
+ toolCount?: number;
48
53
  detail?: string;
49
54
  remediation?: string;
50
55
  remediationKind?: "oauth" | "environment" | "install" | "endpoint" | "router";
@@ -98,6 +98,7 @@ export class LocalBackend {
98
98
  name,
99
99
  transport,
100
100
  status: "degraded",
101
+ toolCount: 0,
101
102
  detail: "router connected but exposed no downstream tools",
102
103
  remediationKind: "router",
103
104
  requiresHuman: true,
@@ -106,7 +107,7 @@ export class LocalBackend {
106
107
  });
107
108
  }
108
109
  else {
109
- this.statuses.set(name, { name, transport, status: "ready", detail: `connected; ${tools.length} tool(s) available` });
110
+ this.statuses.set(name, { name, transport, status: "ready", toolCount: tools.length, detail: `connected; ${tools.length} tool(s) available` });
110
111
  }
111
112
  }
112
113
  async listTools() {
@@ -1,10 +1,44 @@
1
1
  import type { CallToolResult, Tool } from "@modelcontextprotocol/server";
2
2
  import type { GatewayBackend } from "./gatewayBackend.js";
3
3
  import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
4
+ import { type OAuthRenewStatus } from "./oauth/renew.js";
5
+ export interface McpStatusProviderOptions {
6
+ /** Where the OAuth token store lives. Omitted, the `oauth` section of
7
+ * the report is empty — the provider stays usable by callers with no
8
+ * home directory, the same contract `LocalBackend` exposes. */
9
+ homeDir?: string;
10
+ /** OAuth-classified servers in scope for this agent, with their
11
+ * ownership (trellis-desktop-oauth-ownership). Trellis-owned servers
12
+ * connect through the gateway with a Trellis-store token, so their
13
+ * stored-credential health is meaningful and reported. Agent-owned
14
+ * servers are direct native entries authorized by the Agent's own
15
+ * client — reported as `agent-managed` precisely because inferring
16
+ * their state from Trellis's store would be a lie (that change's
17
+ * "unknown native authorization is displayed as unknown"). */
18
+ oauthServers?: readonly OAuthServerRef[];
19
+ }
20
+ export interface OAuthServerRef {
21
+ name: string;
22
+ owner: "agent" | "trellis";
23
+ }
24
+ /** One OAuth-classified server's credential health. `credential` — not
25
+ * `status` — because the gateway cannot observe a direct-routed server's
26
+ * connection state; claiming "ready" would be a lie
27
+ * (trellis-mcp-status-credential-health design.md D1). */
28
+ export interface OAuthCredentialReport {
29
+ name: string;
30
+ owner: "agent" | "trellis";
31
+ credential: OAuthRenewStatus | "agent-managed";
32
+ /** ISO 8601, when the stored token carries an expiry at all. */
33
+ expiresAt?: string;
34
+ /** Present only when there is an action for Trellis to take. */
35
+ remediation?: string;
36
+ }
4
37
  export declare class McpStatusProvider implements TrellisProvider {
5
38
  private readonly backend;
39
+ private readonly options;
6
40
  readonly id = "mcp";
7
- constructor(backend: GatewayBackend);
41
+ constructor(backend: GatewayBackend, options?: McpStatusProviderOptions);
8
42
  listTools(): Tool[];
9
43
  callTool(name: string, _args: unknown, _context: RuntimeContext): Promise<CallToolResult>;
10
44
  }
@@ -1,14 +1,17 @@
1
+ import { describeStoredToken, remediationFor } from "./oauth/renew.js";
1
2
  const STATUS_TOOL = {
2
3
  name: "trellis.mcp.status",
3
- description: "Show MCP upstream availability and the safe next action for authentication or configuration failures.",
4
+ description: "Show MCP upstream availability, credential health for OAuth-classified direct-routed servers, and the safe next action for authentication or configuration failures.",
4
5
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
5
6
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
6
7
  };
7
8
  export class McpStatusProvider {
8
9
  backend;
10
+ options;
9
11
  id = "mcp";
10
- constructor(backend) {
12
+ constructor(backend, options = {}) {
11
13
  this.backend = backend;
14
+ this.options = options;
12
15
  }
13
16
  listTools() {
14
17
  return [STATUS_TOOL];
@@ -17,9 +20,29 @@ export class McpStatusProvider {
17
20
  if (name !== STATUS_TOOL.name)
18
21
  return { content: [{ type: "text", text: `unknown MCP status tool "${name}"` }], isError: true };
19
22
  const statuses = this.backend.listStatus?.() ?? [];
23
+ // Strictly read-only (design.md D3): classification reads the token
24
+ // files and nothing else — no token endpoint, no refresh, no writes.
25
+ // The readOnlyHint annotation on the tool depends on that staying true.
26
+ const oauth = this.options.homeDir
27
+ ? (this.options.oauthServers ?? []).map(({ name: serverName, owner }) => {
28
+ if (owner === "agent") {
29
+ return { name: serverName, owner, credential: "agent-managed" };
30
+ }
31
+ const { status, expiresAt } = describeStoredToken(this.options.homeDir, serverName);
32
+ const remediation = remediationFor(status, serverName);
33
+ return {
34
+ name: serverName,
35
+ owner,
36
+ credential: status,
37
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
38
+ ...(remediation !== "" ? { remediation } : {}),
39
+ };
40
+ })
41
+ : [];
42
+ const report = { servers: statuses, oauth };
20
43
  return {
21
- content: [{ type: "text", text: JSON.stringify({ servers: statuses }, null, 2) }],
22
- structuredContent: { servers: statuses },
44
+ content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
45
+ structuredContent: report,
23
46
  };
24
47
  }
25
48
  }
@@ -0,0 +1,11 @@
1
+ export interface CallbackPageOptions {
2
+ serverName: string;
3
+ hasCode: boolean;
4
+ error?: string;
5
+ acceptLanguage?: string;
6
+ }
7
+ /** Browser receipt only: the caller still validates and persists the grant. */
8
+ export declare function renderCallbackPage(options: CallbackPageOptions): {
9
+ html: string;
10
+ contentSecurityPolicy: string;
11
+ };
@@ -0,0 +1,89 @@
1
+ import { randomBytes } from "node:crypto";
2
+ const COPY = {
3
+ en: {
4
+ received: "Authorization response received",
5
+ receivedBody: "Return to Trellis or your terminal to check the final connection result.",
6
+ cancelled: "Authorization cancelled",
7
+ cancelledBody: "Return to Trellis and start again when you are ready.",
8
+ failed: "Authorization could not continue",
9
+ failedBody: "Return to Trellis or your terminal for details, then try again.",
10
+ incomplete: "Authorization response incomplete",
11
+ incompleteBody: "No authorization code was received. Start again from Trellis.",
12
+ close: "Close this tab",
13
+ manual: "If this tab stays open, close it manually and return to Trellis or your terminal.",
14
+ noScript: "You can close this tab manually and return to Trellis or your terminal.",
15
+ },
16
+ zh: {
17
+ received: "授权响应已接收",
18
+ receivedBody: "请返回 Trellis 或终端查看最终连接结果。",
19
+ cancelled: "授权已取消",
20
+ cancelledBody: "准备好后,请返回 Trellis 重新发起授权。",
21
+ failed: "授权暂未完成",
22
+ failedBody: "请返回 Trellis 或终端查看原因,然后重试。",
23
+ incomplete: "授权响应不完整",
24
+ incompleteBody: "未收到授权码,请从 Trellis 重新发起授权。",
25
+ close: "关闭此标签页",
26
+ manual: "如果页面没有关闭,请手动关闭此标签页,返回 Trellis 或终端。",
27
+ noScript: "你可以手动关闭此标签页,返回 Trellis 或终端。",
28
+ },
29
+ };
30
+ function escapeHtml(value) {
31
+ return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
32
+ }
33
+ // Inline the existing docs/assets/brand-v3/trellis-mark-v3-a.svg geometry so
34
+ // callback pages work offline and inside the packaged Sidecar.
35
+ const MARK = `<svg viewBox="0 0 96 96" aria-hidden="true" focusable="false"><g fill="none" stroke="currentColor" stroke-linecap="round"><rect x="16" y="18" width="64" height="18" rx="9" stroke-width="5.5"/><g stroke-width="2.6" clip-path="url(#a-beam)"><path d="M-64.5 32 L-55.5 23"/><path d="M-64.5 23 L-55.5 32"/><path d="M-46.5 32 L-37.5 23"/><path d="M-46.5 23 L-37.5 32"/><path d="M-28.5 32 L-19.5 23"/><path d="M-28.5 23 L-19.5 32"/><path d="M-10.5 32 L-1.5 23"/><path d="M-10.5 23 L-1.5 32"/><path d="M7.5 32 L16.5 23"/><path d="M7.5 23 L16.5 32"/><path d="M25.5 32 L34.5 23"/><path d="M25.5 23 L34.5 32"/><path d="M43.5 32 L52.5 23"/><path d="M43.5 23 L52.5 32"/><path d="M61.5 32 L70.5 23"/><path d="M61.5 23 L70.5 32"/><path d="M79.5 32 L88.5 23"/><path d="M79.5 23 L88.5 32"/><path d="M97.5 32 L106.5 23"/><path d="M97.5 23 L106.5 32"/><path d="M115.5 32 L124.5 23"/><path d="M115.5 23 L124.5 32"/></g><path d="M48 36 V78" stroke-width="8"/></g><defs><clipPath id="a-beam"><rect x="21" y="23" width="54" height="9" rx="4.5"/></clipPath></defs></svg>`;
36
+ /** Browser receipt only: the caller still validates and persists the grant. */
37
+ export function renderCallbackPage(options) {
38
+ const chinese = options.acceptLanguage?.split(",")[0]?.trim().toLowerCase().startsWith("zh") ?? false;
39
+ const copy = chinese ? COPY.zh : COPY.en;
40
+ const state = options.error === "access_denied" ? "cancelled" : options.error ? "failed" : options.hasCode ? "received" : "incomplete";
41
+ const title = copy[state];
42
+ const description = copy[`${state}Body`];
43
+ const nonce = randomBytes(16).toString("base64");
44
+ const contentSecurityPolicy = `default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`;
45
+ const html = `<!doctype html>
46
+ <html lang="${chinese ? "zh-CN" : "en"}">
47
+ <head>
48
+ <meta charset="utf-8">
49
+ <meta name="viewport" content="width=device-width, initial-scale=1">
50
+ <meta name="color-scheme" content="light">
51
+ <title>${escapeHtml(title)} · Trellis</title>
52
+ <style nonce="${nonce}">
53
+ :root{color-scheme:light;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Arial,sans-serif;color:#182230;background:#fff;font-synthesis:none;-webkit-font-smoothing:antialiased}
54
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;min-height:100dvh;display:grid;place-items:center;padding:32px 24px}
55
+ main{width:100%;max-width:440px;text-align:center}.brand{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px;margin-bottom:24px;font-size:14px;color:#667085}.brand svg{width:28px;height:28px;flex:none}.brand-name{font-weight:600;color:#344054}.separator{color:#98a2b3}.server{max-width:100%;overflow-wrap:anywhere}
56
+ h1{font-size:25px;font-weight:600;line-height:1.35;letter-spacing:-.5px;margin:0 0 12px;overflow-wrap:anywhere}.description{font-size:14px;line-height:1.7;color:#667085;margin:0 0 24px}
57
+ button{appearance:none;border:1px solid #2563eb;border-radius:8px;background:#2563eb;color:#fff;font:inherit;font-size:14px;font-weight:500;line-height:1.5;padding:10px 18px;min-height:44px;max-width:100%;cursor:pointer}button:hover{background:#1d4ed8;border-color:#1d4ed8}button:focus-visible{outline:3px solid #93b4ff;outline-offset:3px}.help{font-size:12px;line-height:1.65;color:#667085;margin:16px 0 0;overflow-wrap:anywhere}.help:focus{outline:none}
58
+ [hidden]{display:none!important}@media(max-width:480px){body{padding:24px}h1{font-size:23px}}
59
+ </style>
60
+ </head>
61
+ <body>
62
+ <main>
63
+ <div class="brand">${MARK}<span class="brand-name">Trellis</span><span class="separator" aria-hidden="true">·</span><span class="server">${escapeHtml(options.serverName)}</span></div>
64
+ <section aria-labelledby="page-title" data-state="${state}">
65
+ <h1 id="page-title">${title}</h1>
66
+ <p class="description">${description}</p>
67
+ <button id="close-tab" type="button" hidden>${copy.close}</button>
68
+ <p id="close-help" class="help" role="status" tabindex="-1" hidden>${copy.manual}</p>
69
+ <p id="manual-close" class="help">${copy.noScript}</p>
70
+ </section>
71
+ </main>
72
+ <script nonce="${nonce}">
73
+ try { history.replaceState(null, '', location.pathname); } catch (_) {}
74
+ const button = document.getElementById('close-tab');
75
+ button.hidden = false;
76
+ document.getElementById('manual-close').hidden = true;
77
+ button.addEventListener('click', function () {
78
+ try { window.close(); } catch (_) {}
79
+ setTimeout(function () {
80
+ const help = document.getElementById('close-help');
81
+ help.hidden = false;
82
+ help.focus({ preventScroll: true });
83
+ }, 150);
84
+ });
85
+ </script>
86
+ </body>
87
+ </html>`;
88
+ return { html, contentSecurityPolicy };
89
+ }
@@ -17,6 +17,7 @@ import { createServer } from "node:http";
17
17
  import { randomBytes } from "node:crypto";
18
18
  import { spawn } from "node:child_process";
19
19
  import { createPkcePair } from "./pkce.js";
20
+ import { renderCallbackPage } from "./callbackPage.js";
20
21
  export class AuthorizationError extends Error {
21
22
  }
22
23
  /**
@@ -82,7 +83,7 @@ const STATIC_CLIENT_REMEDIATION = "register a client with the provider and set a
82
83
  /** A one-shot loopback listener for the redirect. Bound to 127.0.0.1
83
84
  * explicitly, never 0.0.0.0: the authorization code arrives in this URL,
84
85
  * and a listener on all interfaces would accept it from the network. */
85
- function listenForCallback(port) {
86
+ function listenForCallback(port, serverName) {
86
87
  return new Promise((resolve, reject) => {
87
88
  let settle;
88
89
  const received = new Promise((r) => {
@@ -112,10 +113,15 @@ function listenForCallback(port) {
112
113
  if (description)
113
114
  result.errorDescription = description;
114
115
  }
115
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
116
- res.end(result.error
117
- ? `<html><body><h1>Authorization failed</h1><p>${escapeHtml(result.errorDescription ?? result.error)}</p></body></html>`
118
- : "<html><body><h1>Authorization response received</h1><p>You can close this tab and return to Trellis.</p></body></html>");
116
+ const page = renderCallbackPage({ serverName, hasCode: Boolean(result.code), error: result.error, acceptLanguage: req.headers["accept-language"] });
117
+ res.writeHead(200, {
118
+ "content-type": "text/html; charset=utf-8",
119
+ "cache-control": "no-store",
120
+ "referrer-policy": "no-referrer",
121
+ "x-content-type-options": "nosniff",
122
+ "content-security-policy": page.contentSecurityPolicy,
123
+ });
124
+ res.end(page.html);
119
125
  settle(result);
120
126
  });
121
127
  server.on("error", reject);
@@ -124,9 +130,6 @@ function listenForCallback(port) {
124
130
  });
125
131
  });
126
132
  }
127
- function escapeHtml(value) {
128
- return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
129
- }
130
133
  /**
131
134
  * Runs the full authorization-code-plus-PKCE exchange and returns a
132
135
  * token ready to persist. Does not write anything — storage is the
@@ -137,7 +140,7 @@ export async function authorize(serverName, metadata, opts = {}) {
137
140
  const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, { ...init, signal: opts.signal }));
138
141
  const now = opts.now ?? Date.now;
139
142
  const timeoutMs = opts.timeoutMs ?? 300_000;
140
- const { server, port, received } = await listenForCallback(opts.callbackPort ?? 0);
143
+ const { server, port, received } = await listenForCallback(opts.callbackPort ?? 0, serverName);
141
144
  const redirectUri = `http://127.0.0.1:${port}/callback`;
142
145
  try {
143
146
  let clientId = opts.clientId;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Per-server OAuth credential health classification, shared by
3
+ * `trellis mcp renew`, `trellis doctor`, and the `trellis.mcp.status`
4
+ * Runtime tool (trellis-mcp-oauth-renew design.md D2,
5
+ * trellis-mcp-status-credential-health design.md D2).
6
+ *
7
+ * One function, two consumers, on purpose: a gateway call, a renew run,
8
+ * and a doctor report all read the same token files, so the statuses they
9
+ * derive must come from a single computation — two classifiers could
10
+ * drift into disagreeing about whether a credential is healthy, which is
11
+ * exactly the kind of "reported ready but 401s in session" mismatch this
12
+ * change exists to eliminate.
13
+ *
14
+ * Everything here is derived from the stored token at call time. Nothing
15
+ * is persisted: the next caller recomputes from the same source of truth
16
+ * the gateway reads.
17
+ */
18
+ import { type StoredToken } from "./store.js";
19
+ export type OAuthRenewStatus =
20
+ /** Nothing stored; renewal can do nothing. */
21
+ "no-token"
22
+ /** Stored and not expired (within the same skew `ensureFreshToken` uses). */
23
+ | "valid"
24
+ /** Was expired, refresh succeeded. Only the refresh path produces this. */
25
+ | "refreshed"
26
+ /** Expired but a refresh token is present — a real renew run would
27
+ * attempt a refresh. Only the read-only path produces this. */
28
+ | "expired-renewable"
29
+ /** Refresh was attempted and failed (network error, or the grant was
30
+ * rejected — a possibly revoked credential). */
31
+ | "refresh-failed"
32
+ /** Expired with no refresh token: only a fresh authorization helps. */
33
+ | "needs-auth"
34
+ /** A file exists on disk but cannot be interpreted. */
35
+ | "unreadable";
36
+ /** Statuses that mean "a human step is required" — the non-zero-exit set
37
+ * (design.md D6). */
38
+ export declare const FAILURE_STATUSES: ReadonlySet<OAuthRenewStatus>;
39
+ export declare function remediationFor(status: OAuthRenewStatus, serverName: string): string;
40
+ /** Test seam for expiry checks, mirroring the `now` seams the rest of the
41
+ * oauth package already exposes. */
42
+ export interface ClassifyOptions {
43
+ now?: number;
44
+ }
45
+ /**
46
+ * The stored token's health plus its expiry (ISO 8601, when the token
47
+ * carries one — store.ts's contract makes `expiresAt` optional). The
48
+ * status tool needs the timestamp to answer "how long is this credential
49
+ * good for"; renew and doctor need only the status
50
+ * (trellis-mcp-status-credential-health design.md D2).
51
+ */
52
+ export interface OAuthTokenDescription {
53
+ status: OAuthRenewStatus;
54
+ expiresAt?: string;
55
+ }
56
+ /**
57
+ * Read-only classification: no network, no writes — doctor's and the
58
+ * status tool's path (design.md D4). A token with no `expiresAt` never
59
+ * expires (store.ts's own contract), so it classifies `valid`.
60
+ */
61
+ export declare function describeStoredToken(homeDir: string, serverName: string, opts?: ClassifyOptions): OAuthTokenDescription;
62
+ export declare function classifyStoredToken(homeDir: string, serverName: string, opts?: ClassifyOptions): OAuthRenewStatus;
63
+ /** Exported for tests that build statuses straight from token fixtures. */
64
+ export declare function hasRefreshToken(token: StoredToken | undefined): boolean;
65
+ /**
66
+ * Maps a refresh failure to a status. The RFC 6749 §5.2 errors that mean
67
+ * "this grant is dead" — `invalid_grant`, and Figma-style servers that
68
+ * answer a rejected refresh with a bare 400/401 and no parseable body —
69
+ * get `needs-auth`, because re-authorization is the only remaining step
70
+ * and the user should not be told to "try refreshing again". Everything
71
+ * else (timeouts, 5xx, DNS) is `refresh-failed`: transient, worth another
72
+ * scheduled attempt before bothering a human. Rejected-grant phrasing
73
+ * varies by provider, so message sniffing stays deliberately narrow.
74
+ */
75
+ export declare function classifyRefreshFailure(err: unknown): OAuthRenewStatus;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Per-server OAuth credential health classification, shared by
3
+ * `trellis mcp renew`, `trellis doctor`, and the `trellis.mcp.status`
4
+ * Runtime tool (trellis-mcp-oauth-renew design.md D2,
5
+ * trellis-mcp-status-credential-health design.md D2).
6
+ *
7
+ * One function, two consumers, on purpose: a gateway call, a renew run,
8
+ * and a doctor report all read the same token files, so the statuses they
9
+ * derive must come from a single computation — two classifiers could
10
+ * drift into disagreeing about whether a credential is healthy, which is
11
+ * exactly the kind of "reported ready but 401s in session" mismatch this
12
+ * change exists to eliminate.
13
+ *
14
+ * Everything here is derived from the stored token at call time. Nothing
15
+ * is persisted: the next caller recomputes from the same source of truth
16
+ * the gateway reads.
17
+ */
18
+ import { existsSync } from "node:fs";
19
+ import { isExpired, readToken, tokenPath } from "./store.js";
20
+ /** Statuses that mean "a human step is required" — the non-zero-exit set
21
+ * (design.md D6). */
22
+ export const FAILURE_STATUSES = new Set([
23
+ "no-token",
24
+ "refresh-failed",
25
+ "needs-auth",
26
+ "unreadable",
27
+ ]);
28
+ export function remediationFor(status, serverName) {
29
+ switch (status) {
30
+ case "no-token":
31
+ case "needs-auth":
32
+ case "unreadable":
33
+ return `run \`trellis mcp auth ${serverName}\``;
34
+ case "expired-renewable":
35
+ return `run \`trellis mcp renew\` to attempt a silent refresh now, or \`trellis mcp auth ${serverName}\` if that fails`;
36
+ case "refresh-failed":
37
+ return `run \`trellis mcp auth ${serverName}\` — the stored grant was rejected or unreachable (possibly revoked)`;
38
+ default:
39
+ return "";
40
+ }
41
+ }
42
+ /**
43
+ * Read-only classification: no network, no writes — doctor's and the
44
+ * status tool's path (design.md D4). A token with no `expiresAt` never
45
+ * expires (store.ts's own contract), so it classifies `valid`.
46
+ */
47
+ export function describeStoredToken(homeDir, serverName, opts = {}) {
48
+ if (!existsSync(tokenPath(homeDir, serverName)))
49
+ return { status: "no-token" };
50
+ const token = readToken(homeDir, serverName);
51
+ if (!token)
52
+ return { status: "unreadable" };
53
+ const description = {
54
+ status: "valid",
55
+ ...(token.expiresAt !== undefined ? { expiresAt: new Date(token.expiresAt).toISOString() } : {}),
56
+ };
57
+ if (!isExpired(token, undefined, opts.now))
58
+ return description;
59
+ description.status = token.refreshToken ? "expired-renewable" : "needs-auth";
60
+ return description;
61
+ }
62
+ export function classifyStoredToken(homeDir, serverName, opts = {}) {
63
+ return describeStoredToken(homeDir, serverName, opts).status;
64
+ }
65
+ /** Exported for tests that build statuses straight from token fixtures. */
66
+ export function hasRefreshToken(token) {
67
+ return token?.refreshToken !== undefined;
68
+ }
69
+ /**
70
+ * Maps a refresh failure to a status. The RFC 6749 §5.2 errors that mean
71
+ * "this grant is dead" — `invalid_grant`, and Figma-style servers that
72
+ * answer a rejected refresh with a bare 400/401 and no parseable body —
73
+ * get `needs-auth`, because re-authorization is the only remaining step
74
+ * and the user should not be told to "try refreshing again". Everything
75
+ * else (timeouts, 5xx, DNS) is `refresh-failed`: transient, worth another
76
+ * scheduled attempt before bothering a human. Rejected-grant phrasing
77
+ * varies by provider, so message sniffing stays deliberately narrow.
78
+ */
79
+ export function classifyRefreshFailure(err) {
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ if (/there is no refresh token/.test(message))
82
+ return "needs-auth";
83
+ if (/invalid_grant|HTTP 400|HTTP 401/.test(message))
84
+ return "needs-auth";
85
+ return "refresh-failed";
86
+ }
@@ -907,6 +907,15 @@ An Agent can call the read-only `trellis.mcp.status` Runtime tool to explain
907
907
  this partial state to the user. It reports unavailable or authorization-
908
908
  required servers and gives the next safe action, such as `trellis mcp auth
909
909
  figma`, without exposing tokens or opening a browser from the Agent process.
910
+ Each connected upstream also carries a structured `toolCount`, and an `oauth`
911
+ section reports credential health with explicit ownership: Trellis-owned
912
+ OAuth servers (which connect through the gateway on a Trellis-store token)
913
+ report `valid` / `no-token` / `expired-renewable` / `needs-auth` /
914
+ `unreadable` with `expiresAt` when known; agent-owned servers report
915
+ `agent-managed`, since their authorization lives in the Agent's own client
916
+ and is never inferred from Trellis's store. The classification is read-only
917
+ and shared with `trellis mcp renew` and `trellis doctor`, so the three can
918
+ never disagree.
910
919
 
911
920
  This command only applies to `http`/`sse` servers. stdio servers get
912
921
  their credentials from `env`/`env_aliases` as described above; running
@@ -49,11 +49,30 @@ so a cancelled grant cannot overwrite a concurrent refresh.
49
49
  The gateway can silently refresh its own hosted credentials; it never opens a
50
50
  browser. Agents with native connections continue using their own flows.
51
51
 
52
+ ## Browser callback feedback
53
+
54
+ The local callback page shows a compact Trellis/service label, one status message,
55
+ one short instruction and a close action, with Chinese or English copy based on
56
+ the browser's preferred language. Receiving an
57
+ authorization response is an intermediate step: validation, token exchange and
58
+ credential storage still determine the final result shown in Trellis or the
59
+ terminal. Cancellation, provider errors and incomplete responses have separate
60
+ messages and guidance for starting again.
61
+
62
+ The page uses no external assets and does not display authorization parameters
63
+ or raw provider descriptions. Responses disable caching and referrer disclosure;
64
+ when JavaScript is available, the page also removes the query and fragment from
65
+ the current browser history entry. The close action offers manual-close guidance
66
+ when a browser blocks it, and manual guidance remains visible without JavaScript.
67
+
52
68
  ## Verification
53
69
 
54
70
  Run core tests and typecheck, GUI sidecar tests/typecheck and GUI smoke tests.
55
71
  `npm run test:oauth-browser --workspace @trellis/gui` additionally uses installed
56
- Google Chrome headlessly with a local authorization-server fixture. It exercises
57
- real browser CORS, PKCE callback, progress, cancellation, retry and owner
58
- confirmation without contacting a real provider or using personal credentials.
72
+ Google Chrome headlessly with a local authorization-server fixture. Set
73
+ `TRELLIS_OAUTH_BROWSER_EXECUTABLE` to use a compatible Chromium executable instead.
74
+ It exercises real browser CORS, PKCE callback, progress, cancellation, retry,
75
+ owner confirmation and callback feedback without contacting a real provider or
76
+ using personal credentials. Set `TRELLIS_OAUTH_SCREENSHOTS` to an output directory
77
+ to save callback desktop/mobile previews during that run.
59
78
  Desktop packaging and installed-app acceptance happen after these checks.
@@ -0,0 +1,88 @@
1
+ # Scheduled OAuth renewal — `trellis mcp renew`
2
+
3
+ The gateway and the pi bridge already refresh OAuth tokens silently before
4
+ every upstream call. `trellis mcp renew` moves that renewal *earlier*: run
5
+ on a schedule, it refreshes whatever is expiring and reports — with a
6
+ non-zero exit code — anything that needs a human, before an Agent session
7
+ discovers it as a 401.
8
+
9
+ ```
10
+ trellis mcp renew # refresh what's expiring, report health
11
+ trellis mcp renew --dry-run # report only; no token-endpoint call
12
+ trellis mcp renew --json # machine-readable (no credential material)
13
+ ```
14
+
15
+ Scope is canonical OAuth servers with an explicit `owner: trellis` —
16
+ classification is explicit (mcp-auth-routing-policy), and ownership decides
17
+ whose problem the credential is (trellis-desktop-oauth-ownership): an
18
+ agent-owned server (the default, e.g. scalar `auth: oauth`) is authorized by
19
+ the Agent's own client, never reads Trellis's token store, and is neither
20
+ renewed nor reported here. The command never opens a browser and never
21
+ performs an initial grant; that remains `trellis mcp auth <name>`
22
+ (mcp-gateway-hosting design.md D9). Exit code: `0` when every Trellis-owned
23
+ server is valid or refreshed, `1` when any needs `trellis mcp auth`, has
24
+ never been authorized, or has an unreadable credential file.
25
+
26
+ `trellis doctor` reports the same per-server health read-only (no network,
27
+ no writes) and surfaces an `oauth-expired` finding — contributing to a
28
+ non-zero doctor exit — for credentials that are expired or absent.
29
+
30
+ ## launchd setup (macOS)
31
+
32
+ Trellis has no daemon; periodic renewal belongs to the platform
33
+ scheduler. Save this as `~/Library/LaunchAgents/com.trellis.mcp-renew.plist`,
34
+ adjusting the `trellis` path (`which trellis`):
35
+
36
+ ```xml
37
+ <?xml version="1.0" encoding="UTF-8"?>
38
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
39
+ <plist version="1.0">
40
+ <dict>
41
+ <key>Label</key>
42
+ <string>com.trellis.mcp-renew</string>
43
+ <key>ProgramArguments</key>
44
+ <array>
45
+ <string>/usr/local/bin/trellis</string>
46
+ <string>mcp</string>
47
+ <string>renew</string>
48
+ <string>--json</string>
49
+ </array>
50
+ <key>StartInterval</key>
51
+ <integer>21600</integer>
52
+ <key>ThrottleInterval</key>
53
+ <integer>600</integer>
54
+ <key>StandardOutPath</key>
55
+ <string>/tmp/trellis-mcp-renew.log</string>
56
+ <key>StandardErrorPath</key>
57
+ <string>/tmp/trellis-mcp-renew.log</string>
58
+ </dict>
59
+ </plist>
60
+ ```
61
+
62
+ Then:
63
+
64
+ ```
65
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.trellis.mcp-renew.plist
66
+ launchctl kickstart gui/$(id -u)/com.trellis.mcp-renew # run once now
67
+ launchctl bootout gui/$(id -u)/com.trellis.mcp-renew # uninstall
68
+ ```
69
+
70
+ Why these knobs:
71
+
72
+ - **`ThrottleInterval`** (design.md D3) caps how close together runs may
73
+ be. A machine waking from sleep with a launchd backlog runs once, not N
74
+ times — refresh-endpoint hammering is the failure mode that gets refresh
75
+ tokens revoked by rate-limiting providers, which is worse than the
76
+ disease this job exists to prevent.
77
+ - **`StartInterval` of 6h** is comfortably inside typical refresh-token
78
+ lifetimes (days to months) while keeping token-endpoint traffic near
79
+ zero: a valid token is a no-op *read*, so most runs make no network
80
+ calls at all.
81
+ - **Overlapping runs are safe**: refresh is serialized by the per-server
82
+ lock (`src/lib/oauth/lock.ts`). A second concurrent run waits, then
83
+ reads the token the first one just wrote.
84
+ - **`--json` to a log** gives you a parseable trail. A `1` exit means
85
+ "something needs `trellis mcp auth <name>`" — the JSON names which.
86
+
87
+ Check on demand: `trellis doctor` shows the same per-server credential
88
+ health.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",