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.
- package/dist/adapters/pi.d.ts +46 -7
- package/dist/adapters/pi.js +151 -8
- package/dist/adapters/piMcp.d.ts +35 -0
- package/dist/adapters/piMcp.js +58 -0
- package/dist/cli.js +17 -1
- package/dist/commands/doctor.d.ts +22 -2
- package/dist/commands/doctor.js +51 -3
- package/dist/commands/mcp.js +6 -0
- package/dist/commands/mcpGateway.d.ts +1 -0
- package/dist/commands/mcpGateway.js +32 -4
- package/dist/commands/mcpRenew.d.ts +52 -0
- package/dist/commands/mcpRenew.js +104 -0
- package/dist/lib/gatewayBackend.d.ts +5 -0
- package/dist/lib/gatewayBackend.js +2 -1
- package/dist/lib/mcpStatusProvider.d.ts +35 -1
- package/dist/lib/mcpStatusProvider.js +27 -4
- package/dist/lib/oauth/callbackPage.d.ts +11 -0
- package/dist/lib/oauth/callbackPage.js +89 -0
- package/dist/lib/oauth/flow.js +12 -9
- package/dist/lib/oauth/renew.d.ts +75 -0
- package/dist/lib/oauth/renew.js +86 -0
- package/dist/probes/pi.d.ts +7 -4
- package/dist/probes/pi.js +16 -11
- package/docs/getting-started.md +31 -0
- package/docs/mcp-authorization-ownership.md +22 -3
- package/docs/mcp-oauth-renewal.md +89 -0
- package/docs/research.md +7 -4
- package/package.json +1 -1
|
@@ -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(
|
|
22
|
-
structuredContent:
|
|
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
|
+
}
|
package/dist/lib/oauth/flow.js
CHANGED
|
@@ -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
|
-
|
|
116
|
-
res.
|
|
117
|
-
|
|
118
|
-
: "
|
|
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
|
+
}
|
package/dist/probes/pi.d.ts
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
* and `dist/core/resource-loader.js` (`loadContextFileFromDir(resolvedAgentDir)`),
|
|
6
6
|
* not by assumption. See design.md D5 (openspec/changes/trellis-doctor-p0).
|
|
7
7
|
*
|
|
8
|
-
* pi has
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* pi >= 1.1.0 has a native MCP client (`~/.pi/agent/mcp.json`), which the
|
|
9
|
+
* adapter manages (trellis-pi-native-mcp-migration); the probe still
|
|
10
|
+
* reports `mcpServers: []` because server health is the runtime's job,
|
|
11
|
+
* not the probe's. `probePiVersion` is split out so the adapter's
|
|
12
|
+
* native-vs-bridge version gate shares exactly this exec and tests can
|
|
13
|
+
* inject a seam.
|
|
12
14
|
*/
|
|
13
15
|
import type { AgentSnapshot } from "../core/types.js";
|
|
16
|
+
export declare function probePiVersion(): string | undefined;
|
|
14
17
|
export declare function probe(homeDir?: string): Promise<AgentSnapshot>;
|