agent-trellis 0.17.0 → 0.17.1
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 +2 -1
- package/dist/commands/mcpAuth.d.ts +2 -0
- package/dist/commands/mcpAuth.js +25 -6
- package/dist/lib/oauth/refresh.d.ts +2 -0
- package/dist/lib/oauth/refresh.js +17 -2
- package/dist/lib/oauth/renew.js +7 -1
- package/dist/pi-bridge/bundle.js +14 -2
- package/docs/mcp-oauth-renewal.md +10 -0
- package/docs/roadmap.md +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -172,7 +172,8 @@ Commands:
|
|
|
172
172
|
Authorize a remote (http/sse) MCP server that uses OAuth —
|
|
173
173
|
opens a browser once, stores the result 0600 under
|
|
174
174
|
~/.trellis/mcp/oauth/. A still-valid token is a no-op; an
|
|
175
|
-
expired one is refreshed without a browser.
|
|
175
|
+
expired one is refreshed without a browser. If the refresh
|
|
176
|
+
grant is rejected, this command starts a new login. The gateway
|
|
176
177
|
refreshes silently on its own and never prompts.
|
|
177
178
|
--force re-authorize even if the stored token is valid
|
|
178
179
|
--json machine-readable output, no report text
|
|
@@ -19,6 +19,8 @@ export interface RunMcpAuthOptions {
|
|
|
19
19
|
signal?: AbortSignal;
|
|
20
20
|
onProgress?: (phase: "discovering" | "registering" | "waiting" | "exchanging" | "saving") => void;
|
|
21
21
|
timeoutMs?: number;
|
|
22
|
+
/** An expired grant was rejected; the explicit auth command will log in again. */
|
|
23
|
+
onReauthorization?: () => void;
|
|
22
24
|
/** Used by the desktop; native grants belong to each Agent. */
|
|
23
25
|
requireHosted?: boolean;
|
|
24
26
|
}
|
package/dist/commands/mcpAuth.js
CHANGED
|
@@ -12,7 +12,8 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
13
13
|
import { discoverAuthorizationServer, parseResourceMetadataUrl } from "../lib/oauth/discovery.js";
|
|
14
14
|
import { authorize, abortable } from "../lib/oauth/flow.js";
|
|
15
|
-
import { ensureFreshToken } from "../lib/oauth/refresh.js";
|
|
15
|
+
import { ensureFreshToken, RefreshError } from "../lib/oauth/refresh.js";
|
|
16
|
+
import { classifyRefreshFailure } from "../lib/oauth/renew.js";
|
|
16
17
|
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
17
18
|
import { isExpired, readToken, tokenPath, writeToken } from "../lib/oauth/store.js";
|
|
18
19
|
import { withServerLock } from "../lib/oauth/lock.js";
|
|
@@ -21,7 +22,14 @@ export async function runMcpAuth(opts) {
|
|
|
21
22
|
const homeDir = opts.homeDir ?? homedir();
|
|
22
23
|
let result;
|
|
23
24
|
try {
|
|
24
|
-
result = await authorizeMcpServer(
|
|
25
|
+
result = await authorizeMcpServer({
|
|
26
|
+
...opts,
|
|
27
|
+
onReauthorization: () => {
|
|
28
|
+
if (!opts.json)
|
|
29
|
+
console.error(`OAuth grant for "${opts.serverName}" can no longer be refreshed; starting a new login.`);
|
|
30
|
+
opts.onReauthorization?.();
|
|
31
|
+
},
|
|
32
|
+
});
|
|
25
33
|
}
|
|
26
34
|
catch (err) {
|
|
27
35
|
console.error(err instanceof Error ? err.message : String(err));
|
|
@@ -72,10 +80,21 @@ export async function authorizeMcpServer(opts) {
|
|
|
72
80
|
return { server: opts.serverName, status: "already-valid", tokenFile: file, ...(existing.expiresAt ? { expiresAt: existing.expiresAt } : {}) };
|
|
73
81
|
}
|
|
74
82
|
if (existing?.refreshToken && !opts.requireHosted) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
try {
|
|
84
|
+
// Expired but renewable — no browser needed.
|
|
85
|
+
const refreshed = await ensureFreshToken(homeDir, opts.serverName);
|
|
86
|
+
if (refreshed) {
|
|
87
|
+
return { server: opts.serverName, status: "refreshed", tokenFile: file, ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (!(err instanceof RefreshError) || classifyRefreshFailure(err) !== "needs-auth")
|
|
92
|
+
throw err;
|
|
93
|
+
opts.signal?.throwIfAborted();
|
|
94
|
+
// This is an explicit authorization request, so a dead grant can
|
|
95
|
+
// continue into login. Keep the old file until the new grant is
|
|
96
|
+
// saved; cancellation must not erase the prior credential.
|
|
97
|
+
opts.onReauthorization?.();
|
|
79
98
|
}
|
|
80
99
|
}
|
|
81
100
|
}
|
|
@@ -19,6 +19,8 @@ export interface RefreshOptions {
|
|
|
19
19
|
lockTimeoutMs?: number;
|
|
20
20
|
}
|
|
21
21
|
export declare class RefreshError extends Error {
|
|
22
|
+
readonly oauthError?: string | undefined;
|
|
23
|
+
constructor(message: string, oauthError?: string | undefined);
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* Returns a token that is valid now, refreshing first if it isn't.
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
import { isExpired, readToken, writeToken } from "./store.js";
|
|
12
12
|
import { withServerLock } from "./lock.js";
|
|
13
13
|
export class RefreshError extends Error {
|
|
14
|
+
oauthError;
|
|
15
|
+
constructor(message, oauthError) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.oauthError = oauthError;
|
|
18
|
+
}
|
|
14
19
|
}
|
|
15
20
|
/**
|
|
16
21
|
* Returns a token that is valid now, refreshing first if it isn't.
|
|
@@ -67,7 +72,17 @@ export async function performRefresh(serverName, token, opts = {}) {
|
|
|
67
72
|
body: body.toString(),
|
|
68
73
|
});
|
|
69
74
|
if (!response.ok) {
|
|
70
|
-
|
|
75
|
+
const detail = await safeText(response);
|
|
76
|
+
let oauthError;
|
|
77
|
+
try {
|
|
78
|
+
const payload = JSON.parse(detail);
|
|
79
|
+
if (typeof payload?.error === "string")
|
|
80
|
+
oauthError = payload.error;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Providers may return plain text or an empty body.
|
|
84
|
+
}
|
|
85
|
+
throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${detail.slice(0, 200)}`, oauthError);
|
|
71
86
|
}
|
|
72
87
|
const payload = (await response.json());
|
|
73
88
|
if (!payload.access_token) {
|
|
@@ -87,7 +102,7 @@ export async function performRefresh(serverName, token, opts = {}) {
|
|
|
87
102
|
}
|
|
88
103
|
async function safeText(response) {
|
|
89
104
|
try {
|
|
90
|
-
return
|
|
105
|
+
return await response.text();
|
|
91
106
|
}
|
|
92
107
|
catch {
|
|
93
108
|
return "<unreadable body>";
|
package/dist/lib/oauth/renew.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { existsSync } from "node:fs";
|
|
19
19
|
import { isExpired, readToken, tokenPath } from "./store.js";
|
|
20
|
+
import { RefreshError } from "./refresh.js";
|
|
20
21
|
/** Statuses that mean "a human step is required" — the non-zero-exit set
|
|
21
22
|
* (design.md D6). */
|
|
22
23
|
export const FAILURE_STATUSES = new Set([
|
|
@@ -77,8 +78,13 @@ export function hasRefreshToken(token) {
|
|
|
77
78
|
* varies by provider, so message sniffing stays deliberately narrow.
|
|
78
79
|
*/
|
|
79
80
|
export function classifyRefreshFailure(err) {
|
|
81
|
+
// A structured OAuth error takes precedence over the HTTP fallback:
|
|
82
|
+
// invalid_request also uses 400, but another login cannot fix it.
|
|
83
|
+
if (err instanceof RefreshError && err.oauthError !== undefined) {
|
|
84
|
+
return err.oauthError === "invalid_grant" ? "needs-auth" : "refresh-failed";
|
|
85
|
+
}
|
|
80
86
|
const message = err instanceof Error ? err.message : String(err);
|
|
81
|
-
if (/there is no refresh token/.test(message))
|
|
87
|
+
if (/there is no refresh token|no recorded token endpoint/.test(message))
|
|
82
88
|
return "needs-auth";
|
|
83
89
|
if (/invalid_grant|HTTP 400|HTTP 401/.test(message))
|
|
84
90
|
return "needs-auth";
|
package/dist/pi-bridge/bundle.js
CHANGED
|
@@ -46200,6 +46200,11 @@ async function withServerLock(homeDir, serverName, fn, opts = {}) {
|
|
|
46200
46200
|
|
|
46201
46201
|
// src/lib/oauth/refresh.ts
|
|
46202
46202
|
var RefreshError = class extends Error {
|
|
46203
|
+
constructor(message, oauthError) {
|
|
46204
|
+
super(message);
|
|
46205
|
+
this.oauthError = oauthError;
|
|
46206
|
+
}
|
|
46207
|
+
oauthError;
|
|
46203
46208
|
};
|
|
46204
46209
|
async function ensureFreshToken(homeDir, serverName, opts = {}) {
|
|
46205
46210
|
const now = opts.now ?? Date.now;
|
|
@@ -46242,7 +46247,14 @@ async function performRefresh(serverName, token, opts = {}) {
|
|
|
46242
46247
|
body: body.toString()
|
|
46243
46248
|
});
|
|
46244
46249
|
if (!response.ok) {
|
|
46245
|
-
|
|
46250
|
+
const detail = await safeText(response);
|
|
46251
|
+
let oauthError;
|
|
46252
|
+
try {
|
|
46253
|
+
const payload2 = JSON.parse(detail);
|
|
46254
|
+
if (typeof payload2?.error === "string") oauthError = payload2.error;
|
|
46255
|
+
} catch {
|
|
46256
|
+
}
|
|
46257
|
+
throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${detail.slice(0, 200)}`, oauthError);
|
|
46246
46258
|
}
|
|
46247
46259
|
const payload = await response.json();
|
|
46248
46260
|
if (!payload.access_token) {
|
|
@@ -46262,7 +46274,7 @@ async function performRefresh(serverName, token, opts = {}) {
|
|
|
46262
46274
|
}
|
|
46263
46275
|
async function safeText(response) {
|
|
46264
46276
|
try {
|
|
46265
|
-
return
|
|
46277
|
+
return await response.text();
|
|
46266
46278
|
} catch {
|
|
46267
46279
|
return "<unreadable body>";
|
|
46268
46280
|
}
|
|
@@ -6,6 +6,16 @@ on a schedule, it refreshes whatever is expiring and reports — with a
|
|
|
6
6
|
non-zero exit code — anything that needs a human, before an Agent session
|
|
7
7
|
discovers it as a 401.
|
|
8
8
|
|
|
9
|
+
When a refresh token has expired, been revoked, or been rotated out, the
|
|
10
|
+
provider can reject renewal with OAuth `invalid_grant`. Run
|
|
11
|
+
`trellis mcp auth <name>`: it attempts a silent refresh first, then starts
|
|
12
|
+
a new browser login if the grant is no longer usable. No `--force` is
|
|
13
|
+
required. Network failures, rate limits, and temporary provider failures
|
|
14
|
+
remain errors to retry; they do not trigger another login. The prior
|
|
15
|
+
credential is replaced only after the new authorization succeeds, so
|
|
16
|
+
cancelling login preserves the existing file. Background renewal and the
|
|
17
|
+
gateway report the failure and never open a browser.
|
|
18
|
+
|
|
9
19
|
```
|
|
10
20
|
trellis mcp renew # refresh what's expiring, report health
|
|
11
21
|
trellis mcp renew --dry-run # report only; no token-endpoint call
|
package/docs/roadmap.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Roadmap
|
|
2
2
|
|
|
3
|
-
*Current release: v0.
|
|
3
|
+
*Current release: v0.17.1 (2026-10-21). This file tracks phase-level scope;
|
|
4
4
|
per-release detail lives in [`releases/`](releases/).*
|
|
5
5
|
|
|
6
6
|
Each phase must ship with a `trellis doctor` check that verifies its own
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-trellis",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
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",
|