agent-trellis 0.17.1 → 0.19.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 +0 -0
- package/dist/lib/gatewayBackend.d.ts +121 -6
- package/dist/lib/gatewayBackend.js +378 -22
- package/dist/lib/mcpConnect.d.ts +4 -4
- package/dist/lib/mcpConnect.js +17 -7
- package/dist/lib/mcpToolRegistry.d.ts +10 -0
- package/dist/lib/mcpToolRegistry.js +19 -0
- package/dist/lib/oauth/refresh.d.ts +15 -0
- package/dist/lib/oauth/refresh.js +30 -0
- package/dist/lib/upstreamResources.d.ts +5 -0
- package/dist/lib/upstreamResources.js +10 -0
- package/dist/pi-bridge/bundle.js +35 -10
- package/docs/roadmap.md +20 -1
- package/docs/runtime-infra.md +55 -0
- package/docs/self-evolution.md +209 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
File without changes
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { McpServerDef, SecretsPolicy } from "../core/types.js";
|
|
19
19
|
import { type McpClientInfo } from "./mcpConnect.js";
|
|
20
|
+
import type { FetchLike } from "./oauth/discovery.js";
|
|
20
21
|
import { type AggregatedTool } from "./mcpToolRegistry.js";
|
|
21
|
-
import type { Resource, ReadResourceResult } from "@modelcontextprotocol/client";
|
|
22
|
+
import type { AuthProvider, Resource, ReadResourceResult } from "@modelcontextprotocol/client";
|
|
22
23
|
import type { SkillEntry } from "./skillManifest.js";
|
|
23
24
|
export interface GatewayBackend {
|
|
24
25
|
/** Every tool available through this backend, under its aggregated name. */
|
|
@@ -40,7 +41,11 @@ export interface UpstreamSpec {
|
|
|
40
41
|
name: string;
|
|
41
42
|
def: McpServerDef;
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
/** `reconnecting` is the transient state between a detected session
|
|
45
|
+
* invalidation and the recovery outcome (spec: an upstream whose MCP
|
|
46
|
+
* session is invalidated mid-session is recovered alone, with a status
|
|
47
|
+
* that never claims ready in the meantime). */
|
|
48
|
+
export type UpstreamStatusKind = "ready" | "auth-required" | "unavailable" | "timeout" | "degraded" | "reconnecting";
|
|
44
49
|
export interface UpstreamStatus {
|
|
45
50
|
name: string;
|
|
46
51
|
transport: "stdio" | "http" | "sse";
|
|
@@ -74,6 +79,10 @@ export interface LocalBackendOptions {
|
|
|
74
79
|
* no terminal attached (design.md D9).
|
|
75
80
|
*/
|
|
76
81
|
homeDir?: string;
|
|
82
|
+
/** How long a server whose mid-session recovery failed refuses automatic
|
|
83
|
+
* re-attempts, so a down upstream cannot trigger a reconnect storm. Test
|
|
84
|
+
* seam; defaults to `SESSION_RECOVERY_COOLDOWN_MS`. */
|
|
85
|
+
sessionRecoveryCooldownMs?: number;
|
|
77
86
|
}
|
|
78
87
|
/**
|
|
79
88
|
* v1's backend: connects each upstream itself and aggregates the result.
|
|
@@ -86,9 +95,26 @@ export interface LocalBackendOptions {
|
|
|
86
95
|
*/
|
|
87
96
|
export declare class LocalBackend implements GatewayBackend {
|
|
88
97
|
private readonly warn;
|
|
98
|
+
private readonly sessionCooldownMs;
|
|
89
99
|
private readonly registry;
|
|
100
|
+
/** server name → the client currently serving that upstream. Keyed by
|
|
101
|
+
* name (not a Set of clients) because recovery swaps the client under a
|
|
102
|
+
* stable name and callTool must ask "is this still the client I called?"
|
|
103
|
+
* by comparing against the current registration. */
|
|
90
104
|
private readonly connected;
|
|
91
105
|
private readonly statuses;
|
|
106
|
+
/** Saved at connect time so a mid-session recovery can rebuild the
|
|
107
|
+
* upstream through `addUpstream` itself — the recovery path IS the
|
|
108
|
+
* startup path, and inherits its OAuth resolution and special cases
|
|
109
|
+
* (mcp-router) wholesale instead of growing a parallel one. */
|
|
110
|
+
private readonly upstreamConfigs;
|
|
111
|
+
/** server name → in-flight session recovery; one recovery per server at
|
|
112
|
+
* a time, every racing caller awaits the same promise. */
|
|
113
|
+
private readonly recoveries;
|
|
114
|
+
/** server name → cooldown expiry; a recently failed recovery is not
|
|
115
|
+
* re-attempted until it lapses, so a down upstream sees one reconnect
|
|
116
|
+
* attempt per window, not a storm. */
|
|
117
|
+
private readonly sessionCooldowns;
|
|
92
118
|
private closed;
|
|
93
119
|
private readonly resources;
|
|
94
120
|
private constructor();
|
|
@@ -102,8 +128,68 @@ export declare class LocalBackend implements GatewayBackend {
|
|
|
102
128
|
getSkill(uri: string): Promise<SkillEntry | undefined>;
|
|
103
129
|
listStatus(): readonly UpstreamStatus[];
|
|
104
130
|
close(): Promise<void>;
|
|
105
|
-
private
|
|
131
|
+
private closeClient;
|
|
132
|
+
/** A dead grant surfaces mid-session: this upstream's calls now fail,
|
|
133
|
+
* but its tools stay registered and every other upstream keeps serving.
|
|
134
|
+
* The credential resolves per request, so the human's re-authorization
|
|
135
|
+
* takes effect on the next call — no session restart required. */
|
|
136
|
+
private markCredentialFailure;
|
|
137
|
+
private restoreIfAuthRequired;
|
|
138
|
+
/** The pre-call hard failure for a server inside its recovery cooldown. */
|
|
139
|
+
private sessionCooldownBlock;
|
|
140
|
+
private enterSessionCooldown;
|
|
141
|
+
/** Join the server's in-flight recovery, or start exactly one. */
|
|
142
|
+
private ensureSessionRecovered;
|
|
143
|
+
/** Drop and rebuild one upstream through the exact startup path
|
|
144
|
+
* (`addUpstream`), keeping the old client connected until the fresh
|
|
145
|
+
* registration serves — so a failed recovery still surfaces the old
|
|
146
|
+
* session errors that re-trigger recovery after the cooldown, and the
|
|
147
|
+
* replaced connection is never closed before its successor exists. */
|
|
148
|
+
private reconnectUpstream;
|
|
149
|
+
/** The retried request failed on the fresh session too: an honest
|
|
150
|
+
* terminal state plus a cooldown — never a stale ready (D4). */
|
|
151
|
+
private markRetrySessionFailure;
|
|
152
|
+
/** Upstream-originated failures name their server (spec: call errors
|
|
153
|
+
* name the failing upstream server). */
|
|
154
|
+
private upstreamCallError;
|
|
106
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* How long a server whose session recovery failed refuses automatic
|
|
158
|
+
* re-attempts. Fixed by design: a reconnect storm against a down upstream
|
|
159
|
+
* buys nothing; the honest failure state plus this window is the v1
|
|
160
|
+
* answer, to be revisited with real-world data.
|
|
161
|
+
*/
|
|
162
|
+
export declare const SESSION_RECOVERY_COOLDOWN_MS = 30000;
|
|
163
|
+
/**
|
|
164
|
+
* Whether an upstream error means "your MCP session no longer exists".
|
|
165
|
+
*
|
|
166
|
+
* The official TypeScript server SDK rejects an unknown session with HTTP
|
|
167
|
+
* 404 carrying the JSON-RPC error "Session not found", and pairs 401/403
|
|
168
|
+
* with -32000 for authentication rejections — so 401/403 gate the match
|
|
169
|
+
* out first, and an auth rejection can never enter recovery. The body
|
|
170
|
+
* reaches us inside the client SDK's `Error POSTing to endpoint: …`
|
|
171
|
+
* wrapper, so a word match covers custom upstream wordings (the real-world
|
|
172
|
+
* one says "MCP session no longer exists"). Real-machine verification
|
|
173
|
+
* (2026-10-23) showed custom upstreams also answer with other non-auth
|
|
174
|
+
* statuses — a 400 carrying that same -32000 body took the plain
|
|
175
|
+
* `SdkHttpError` path, not the enveloped `ProtocolError` one — hence the
|
|
176
|
+
* status gate is "not an auth rejection" rather than "exactly 404". A
|
|
177
|
+
* stray non-auth response that merely mentions "session" costs one
|
|
178
|
+
* idempotent-safe reconnect+retry, an acceptable price for covering every
|
|
179
|
+
* upstream wording. The `ProtocolError` branch covers the HTTP-200
|
|
180
|
+
* JSON-RPC error case, where the client SDK parses the envelope and
|
|
181
|
+
* rejects with the upstream's error code instead.
|
|
182
|
+
*/
|
|
183
|
+
export declare function isSessionInvalidation(err: unknown): boolean;
|
|
184
|
+
/**
|
|
185
|
+
* Whether the gateway manages the OAuth credential for this definition:
|
|
186
|
+
* Trellis-owned OAuth, and no explicit `Authorization` header — someone
|
|
187
|
+
* who wrote one in canonical meant it, and silently overriding it with a
|
|
188
|
+
* stored token would be the kind of invisible precedence that is
|
|
189
|
+
* impossible to debug. Both `withOAuthHeader` and the per-request
|
|
190
|
+
* `AuthProvider` attach on exactly this predicate.
|
|
191
|
+
*/
|
|
192
|
+
export declare function oauthAttachApplies(def: McpServerDef): boolean;
|
|
107
193
|
/**
|
|
108
194
|
* Attaches a stored OAuth token, refreshing it first if it has expired.
|
|
109
195
|
*
|
|
@@ -112,8 +198,37 @@ export declare class LocalBackend implements GatewayBackend {
|
|
|
112
198
|
* OAuth at all, or one whose credential lives in `headers` already, and
|
|
113
199
|
* neither should be disturbed.
|
|
114
200
|
*
|
|
115
|
-
* An explicit `Authorization` header in canonical wins.
|
|
116
|
-
* one meant it, and silently overriding it with a stored token would be
|
|
117
|
-
* the kind of invisible precedence that is impossible to debug.
|
|
201
|
+
* An explicit `Authorization` header in canonical wins.
|
|
118
202
|
*/
|
|
119
203
|
export declare function withOAuthHeader(name: string, def: McpServerDef, homeDir: string): Promise<McpServerDef>;
|
|
204
|
+
/**
|
|
205
|
+
* The early-refresh window on the gateway's per-request path. Minute-scale
|
|
206
|
+
* on purpose: a request that passes the freshness check must not carry a
|
|
207
|
+
* token that dies mid-flight. `store.ts`'s 60s default is untouched —
|
|
208
|
+
* `mcp auth`/`mcp renew` don't have this problem.
|
|
209
|
+
*/
|
|
210
|
+
export declare const GATEWAY_REFRESH_SKEW_MS = 300000;
|
|
211
|
+
export interface TrellisAuthProviderOptions {
|
|
212
|
+
homeDir: string;
|
|
213
|
+
serverName: string;
|
|
214
|
+
/** Test seam; defaults to the gateway window. */
|
|
215
|
+
skewMs?: number;
|
|
216
|
+
fetchImpl?: FetchLike;
|
|
217
|
+
now?: () => number;
|
|
218
|
+
/** Fired when pre-request resolution produced a credential while this
|
|
219
|
+
* server sat in auth-required — the backend restores its status. */
|
|
220
|
+
onCredentialRecovered?: () => void;
|
|
221
|
+
/** Fired when the 401-forced refresh failed — the backend marks this
|
|
222
|
+
* server auth-required with the OAuth remediation. */
|
|
223
|
+
onCredentialFailure?: (err: unknown) => void;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The SDK `AuthProvider` for one Trellis-owned OAuth upstream: `token()`
|
|
227
|
+
* runs as each outbound request's headers are assembled, so credentials
|
|
228
|
+
* resolve per request instead of once at connect time.
|
|
229
|
+
*
|
|
230
|
+
* Every read goes back to the token file with no memory cache — that is
|
|
231
|
+
* what makes a refresh by `trellis mcp auth` in another terminal take
|
|
232
|
+
* effect on the very next request of a running gateway (D6).
|
|
233
|
+
*/
|
|
234
|
+
export declare function trellisAuthProvider(opts: TrellisAuthProviderOptions): AuthProvider;
|
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
import { isOAuthAuth, oauthOwner } from "../core/types.js";
|
|
19
19
|
import { connectServer } from "./mcpConnect.js";
|
|
20
20
|
import { withTimeout } from "./mcpConnect.js";
|
|
21
|
-
import { ensureFreshToken } from "./oauth/refresh.js";
|
|
21
|
+
import { ensureFreshToken, forceRefresh, RefreshError } from "./oauth/refresh.js";
|
|
22
22
|
import { readToken } from "./oauth/store.js";
|
|
23
23
|
import { McpToolRegistry } from "./mcpToolRegistry.js";
|
|
24
|
+
import { ProtocolError, SdkHttpError } from "@modelcontextprotocol/client";
|
|
24
25
|
import { UpstreamResources } from "./upstreamResources.js";
|
|
25
26
|
/**
|
|
26
27
|
* v1's backend: connects each upstream itself and aggregates the result.
|
|
@@ -33,24 +34,47 @@ import { UpstreamResources } from "./upstreamResources.js";
|
|
|
33
34
|
*/
|
|
34
35
|
export class LocalBackend {
|
|
35
36
|
warn;
|
|
37
|
+
sessionCooldownMs;
|
|
36
38
|
registry = new McpToolRegistry();
|
|
37
|
-
|
|
39
|
+
/** server name → the client currently serving that upstream. Keyed by
|
|
40
|
+
* name (not a Set of clients) because recovery swaps the client under a
|
|
41
|
+
* stable name and callTool must ask "is this still the client I called?"
|
|
42
|
+
* by comparing against the current registration. */
|
|
43
|
+
connected = new Map();
|
|
38
44
|
statuses = new Map();
|
|
45
|
+
/** Saved at connect time so a mid-session recovery can rebuild the
|
|
46
|
+
* upstream through `addUpstream` itself — the recovery path IS the
|
|
47
|
+
* startup path, and inherits its OAuth resolution and special cases
|
|
48
|
+
* (mcp-router) wholesale instead of growing a parallel one. */
|
|
49
|
+
upstreamConfigs = new Map();
|
|
50
|
+
/** server name → in-flight session recovery; one recovery per server at
|
|
51
|
+
* a time, every racing caller awaits the same promise. */
|
|
52
|
+
recoveries = new Map();
|
|
53
|
+
/** server name → cooldown expiry; a recently failed recovery is not
|
|
54
|
+
* re-attempted until it lapses, so a down upstream sees one reconnect
|
|
55
|
+
* attempt per window, not a storm. */
|
|
56
|
+
sessionCooldowns = new Map();
|
|
39
57
|
closed = false;
|
|
40
58
|
resources;
|
|
41
|
-
constructor(warn, timeoutMs) {
|
|
59
|
+
constructor(warn, timeoutMs, sessionCooldownMs) {
|
|
42
60
|
this.warn = warn;
|
|
61
|
+
this.sessionCooldownMs = sessionCooldownMs;
|
|
43
62
|
this.resources = new UpstreamResources(timeoutMs, warn);
|
|
44
63
|
}
|
|
45
64
|
static async connect(upstreams, options) {
|
|
46
65
|
const warn = options.onWarning ?? ((message) => console.error(message));
|
|
47
|
-
const backend = new LocalBackend(warn, options.connectTimeoutMs);
|
|
66
|
+
const backend = new LocalBackend(warn, options.connectTimeoutMs, options.sessionRecoveryCooldownMs ?? SESSION_RECOVERY_COOLDOWN_MS);
|
|
48
67
|
await Promise.all(upstreams.map((upstream) => backend.addUpstream(upstream, options)));
|
|
49
68
|
return backend;
|
|
50
69
|
}
|
|
51
|
-
async addUpstream({ name, def }, options) {
|
|
70
|
+
async addUpstream({ name, def }, options, replaceOf) {
|
|
71
|
+
this.upstreamConfigs.set(name, { spec: { name, def }, options });
|
|
52
72
|
const transport = def.transport === "http" || def.transport === "sse" ? def.transport : "stdio";
|
|
53
|
-
|
|
73
|
+
// A recovery set `reconnecting` before calling us; a first connect
|
|
74
|
+
// reports "connecting". The recovery's status stays until the outcome.
|
|
75
|
+
if (replaceOf === undefined) {
|
|
76
|
+
this.statuses.set(name, { name, transport, status: "unavailable", detail: "connecting" });
|
|
77
|
+
}
|
|
54
78
|
let effectiveDef = def;
|
|
55
79
|
if (options.homeDir && def.url) {
|
|
56
80
|
try {
|
|
@@ -65,16 +89,42 @@ export class LocalBackend {
|
|
|
65
89
|
return;
|
|
66
90
|
}
|
|
67
91
|
}
|
|
92
|
+
// Per-request credential resolution: the SDK calls token() as each
|
|
93
|
+
// request's headers are assembled, so a token refreshed here — by this
|
|
94
|
+
// process, or written to disk by `trellis mcp auth` in another
|
|
95
|
+
// terminal — affects the very next request, no restart. The static
|
|
96
|
+
// header from withOAuthHeader stays as the connect-time fallback.
|
|
97
|
+
// (A mid-session recovery rebuilds this provider, so its `lastIssued`
|
|
98
|
+
// starts empty again: the first post-recovery 401 force-refreshes with
|
|
99
|
+
// no previous-token guard — at most one extra rotation, and safe,
|
|
100
|
+
// because forceRefresh re-reads the token file under its lock.)
|
|
101
|
+
const authProvider = options.homeDir && (transport === "http" || transport === "sse") && oauthAttachApplies(def)
|
|
102
|
+
? trellisAuthProvider({
|
|
103
|
+
homeDir: options.homeDir,
|
|
104
|
+
serverName: name,
|
|
105
|
+
onCredentialRecovered: () => this.restoreIfAuthRequired(name),
|
|
106
|
+
onCredentialFailure: (err) => this.markCredentialFailure(name, transport, err),
|
|
107
|
+
})
|
|
108
|
+
: undefined;
|
|
68
109
|
let client;
|
|
69
110
|
try {
|
|
70
|
-
client = await connectServer(effectiveDef, options.secretsPolicy, options.connectTimeoutMs, options.clientInfo);
|
|
111
|
+
client = await connectServer(effectiveDef, options.secretsPolicy, options.connectTimeoutMs, options.clientInfo, authProvider);
|
|
71
112
|
}
|
|
72
113
|
catch (err) {
|
|
73
114
|
this.warn(`trellis-mcp-gateway: failed to connect to MCP server "${name}": ${errorText(err)}`);
|
|
115
|
+
// A failed recovery keeps the replaced client: its dead session is
|
|
116
|
+
// what keeps surfacing the error that re-triggers recovery once the
|
|
117
|
+
// cooldown expires — close-after-success, never orphaned mid-failure.
|
|
118
|
+
if (replaceOf !== undefined)
|
|
119
|
+
this.connected.set(name, replaceOf);
|
|
74
120
|
this.statuses.set(name, failureStatus(name, def, err));
|
|
75
121
|
return;
|
|
76
122
|
}
|
|
77
|
-
this.connected.
|
|
123
|
+
this.connected.set(name, client);
|
|
124
|
+
// Routes (and any skill registrations) a previous incarnation of this
|
|
125
|
+
// source left behind would route reads at the replaced client — forget
|
|
126
|
+
// the source entirely; the fresh registration re-adds its routes below.
|
|
127
|
+
this.resources.removeSource(name);
|
|
78
128
|
let tools;
|
|
79
129
|
try {
|
|
80
130
|
const result = client.getServerCapabilities()?.tools
|
|
@@ -84,11 +134,19 @@ export class LocalBackend {
|
|
|
84
134
|
}
|
|
85
135
|
catch (err) {
|
|
86
136
|
this.warn(`trellis-mcp-gateway: failed to list tools for MCP server "${name}": ${errorText(err)}`);
|
|
87
|
-
|
|
137
|
+
this.connected.delete(name);
|
|
138
|
+
if (replaceOf !== undefined)
|
|
139
|
+
this.connected.set(name, replaceOf);
|
|
140
|
+
await this.closeClient(client);
|
|
88
141
|
this.statuses.set(name, failureStatus(name, def, err));
|
|
89
142
|
return;
|
|
90
143
|
}
|
|
91
|
-
|
|
144
|
+
// A recovery swaps this server's whole registration: its previous
|
|
145
|
+
// candidates are dropped first, so a changed upstream tool set is
|
|
146
|
+
// reflected exactly rather than accumulated.
|
|
147
|
+
const { skipped } = replaceOf !== undefined
|
|
148
|
+
? this.registry.replace(name, client, tools)
|
|
149
|
+
: this.registry.add(name, client, tools);
|
|
92
150
|
this.resources.add(name, client);
|
|
93
151
|
for (const collided of skipped) {
|
|
94
152
|
this.warn(`trellis-mcp-gateway: tool "${collided}" from server "${name}" collides with an already-registered tool and was skipped`);
|
|
@@ -109,12 +167,63 @@ export class LocalBackend {
|
|
|
109
167
|
else {
|
|
110
168
|
this.statuses.set(name, { name, transport, status: "ready", toolCount: tools.length, detail: `connected; ${tools.length} tool(s) available` });
|
|
111
169
|
}
|
|
170
|
+
// The fresh registration serves; only now is the replaced client
|
|
171
|
+
// retired. Callers mid-flight on it finish against it — their stale
|
|
172
|
+
// session errors are guarded in callTool and never start a second
|
|
173
|
+
// recovery — and a failed recovery left the old client connected.
|
|
174
|
+
if (replaceOf !== undefined)
|
|
175
|
+
await this.closeClient(replaceOf);
|
|
112
176
|
}
|
|
113
177
|
async listTools() {
|
|
114
178
|
return this.registry.listTools();
|
|
115
179
|
}
|
|
116
180
|
async callTool(name, args) {
|
|
117
|
-
|
|
181
|
+
let target = this.registry.resolve(name);
|
|
182
|
+
if (!target)
|
|
183
|
+
throw new Error(`unknown tool "${name}"`);
|
|
184
|
+
// A server inside its recovery cooldown fails hard and immediately:
|
|
185
|
+
// its automatic recovery is known to be failing, and a reconnect
|
|
186
|
+
// storm against a down upstream buys nothing (D3).
|
|
187
|
+
const blocked = this.sessionCooldownBlock(target.serverName);
|
|
188
|
+
if (blocked)
|
|
189
|
+
throw blocked;
|
|
190
|
+
// A recovery already in flight — started by a concurrent caller.
|
|
191
|
+
// Wait it out and aim at whatever registration it lands.
|
|
192
|
+
const pending = this.recoveries.get(target.serverName);
|
|
193
|
+
if (pending) {
|
|
194
|
+
await pending;
|
|
195
|
+
target = this.registry.resolve(name);
|
|
196
|
+
if (!target)
|
|
197
|
+
throw new Error(`unknown tool "${name}"`);
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return await target.upstream.callTool({ name: target.toolName, arguments: args });
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
const { serverName } = target;
|
|
204
|
+
if (this.closed || !isSessionInvalidation(err))
|
|
205
|
+
throw this.upstreamCallError(serverName, err);
|
|
206
|
+
// A completed recovery already replaced the client this error came
|
|
207
|
+
// from (a racing caller's reconnect won): the stale error must not
|
|
208
|
+
// start a second recovery — just aim at the fresh registration.
|
|
209
|
+
const current = this.connected.get(serverName);
|
|
210
|
+
if (current === undefined || current === target.upstream) {
|
|
211
|
+
await this.ensureSessionRecovered(serverName, err);
|
|
212
|
+
}
|
|
213
|
+
const retried = this.registry.resolve(name);
|
|
214
|
+
if (!retried)
|
|
215
|
+
throw new Error(`unknown tool "${name}"`);
|
|
216
|
+
try {
|
|
217
|
+
return await retried.upstream.callTool({ name: retried.toolName, arguments: args });
|
|
218
|
+
}
|
|
219
|
+
catch (retryErr) {
|
|
220
|
+
// The fresh session failed the same way: an honest terminal state
|
|
221
|
+
// plus a cooldown — never a stale ready (D4).
|
|
222
|
+
if (isSessionInvalidation(retryErr))
|
|
223
|
+
this.markRetrySessionFailure(retried.serverName, retryErr);
|
|
224
|
+
throw this.upstreamCallError(retried.serverName, retryErr);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
118
227
|
}
|
|
119
228
|
listResources() { return this.resources.listResources(); }
|
|
120
229
|
readResource(uri) { return this.resources.readResource(uri); }
|
|
@@ -127,12 +236,11 @@ export class LocalBackend {
|
|
|
127
236
|
if (this.closed)
|
|
128
237
|
return;
|
|
129
238
|
this.closed = true;
|
|
130
|
-
const pending = [...this.connected];
|
|
239
|
+
const pending = [...this.connected.values()];
|
|
131
240
|
this.connected.clear();
|
|
132
|
-
await Promise.all(pending.map((client) => this.
|
|
241
|
+
await Promise.all(pending.map((client) => this.closeClient(client)));
|
|
133
242
|
}
|
|
134
|
-
async
|
|
135
|
-
this.connected.delete(client);
|
|
243
|
+
async closeClient(client) {
|
|
136
244
|
try {
|
|
137
245
|
await client.close();
|
|
138
246
|
}
|
|
@@ -141,6 +249,185 @@ export class LocalBackend {
|
|
|
141
249
|
this.warn(`trellis-mcp-gateway: failed to close MCP client: ${errorText(err)}`);
|
|
142
250
|
}
|
|
143
251
|
}
|
|
252
|
+
/** A dead grant surfaces mid-session: this upstream's calls now fail,
|
|
253
|
+
* but its tools stay registered and every other upstream keeps serving.
|
|
254
|
+
* The credential resolves per request, so the human's re-authorization
|
|
255
|
+
* takes effect on the next call — no session restart required. */
|
|
256
|
+
markCredentialFailure(name, transport, err) {
|
|
257
|
+
const previous = this.statuses.get(name);
|
|
258
|
+
this.statuses.set(name, {
|
|
259
|
+
name,
|
|
260
|
+
transport,
|
|
261
|
+
status: "auth-required",
|
|
262
|
+
toolCount: previous?.toolCount,
|
|
263
|
+
detail: errorText(err).slice(0, 300),
|
|
264
|
+
remediationKind: "oauth",
|
|
265
|
+
requiresHuman: true,
|
|
266
|
+
requiresSessionRestart: false,
|
|
267
|
+
remediation: `run \`trellis mcp auth ${name}\` in a human terminal — the running gateway picks the new credential up on its next request`,
|
|
268
|
+
});
|
|
269
|
+
this.warn(`trellis-mcp-gateway: MCP server "${name}" needs re-authorization: ${errorText(err)}`);
|
|
270
|
+
}
|
|
271
|
+
restoreIfAuthRequired(name) {
|
|
272
|
+
const current = this.statuses.get(name);
|
|
273
|
+
if (!current || current.status !== "auth-required")
|
|
274
|
+
return;
|
|
275
|
+
this.statuses.set(name, {
|
|
276
|
+
...current,
|
|
277
|
+
status: "ready",
|
|
278
|
+
detail: `credential recovered; ${current.toolCount ?? 0} tool(s) available`,
|
|
279
|
+
remediation: undefined,
|
|
280
|
+
remediationKind: undefined,
|
|
281
|
+
requiresHuman: undefined,
|
|
282
|
+
requiresSessionRestart: undefined,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Mid-session recovery (spec: an upstream whose MCP session is invalidated
|
|
287
|
+
// is recovered alone, in flight — restart is the remediation of last
|
|
288
|
+
// resort, not the default)
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
/** The pre-call hard failure for a server inside its recovery cooldown. */
|
|
291
|
+
sessionCooldownBlock(serverName) {
|
|
292
|
+
const until = this.sessionCooldowns.get(serverName);
|
|
293
|
+
if (until === undefined || Date.now() >= until)
|
|
294
|
+
return undefined;
|
|
295
|
+
return new Error(sessionRecoveryHint(serverName, "a recovery attempt failed recently and its cooldown has not expired"));
|
|
296
|
+
}
|
|
297
|
+
enterSessionCooldown(serverName) {
|
|
298
|
+
this.sessionCooldowns.set(serverName, Date.now() + this.sessionCooldownMs);
|
|
299
|
+
}
|
|
300
|
+
/** Join the server's in-flight recovery, or start exactly one. */
|
|
301
|
+
ensureSessionRecovered(serverName, trigger) {
|
|
302
|
+
const blocked = this.sessionCooldownBlock(serverName);
|
|
303
|
+
if (blocked)
|
|
304
|
+
return Promise.reject(blocked);
|
|
305
|
+
const existing = this.recoveries.get(serverName);
|
|
306
|
+
if (existing)
|
|
307
|
+
return existing;
|
|
308
|
+
const pending = this.reconnectUpstream(serverName, trigger);
|
|
309
|
+
this.recoveries.set(serverName, pending);
|
|
310
|
+
const cleanup = () => {
|
|
311
|
+
if (this.recoveries.get(serverName) === pending)
|
|
312
|
+
this.recoveries.delete(serverName);
|
|
313
|
+
};
|
|
314
|
+
pending.then(cleanup, cleanup);
|
|
315
|
+
return pending;
|
|
316
|
+
}
|
|
317
|
+
/** Drop and rebuild one upstream through the exact startup path
|
|
318
|
+
* (`addUpstream`), keeping the old client connected until the fresh
|
|
319
|
+
* registration serves — so a failed recovery still surfaces the old
|
|
320
|
+
* session errors that re-trigger recovery after the cooldown, and the
|
|
321
|
+
* replaced connection is never closed before its successor exists. */
|
|
322
|
+
async reconnectUpstream(serverName, trigger) {
|
|
323
|
+
const config = this.upstreamConfigs.get(serverName);
|
|
324
|
+
if (!config)
|
|
325
|
+
throw new Error(sessionRecoveryHint(serverName, "no saved connection config exists for it"));
|
|
326
|
+
// Preserve the tool count through reconnecting and failure: Agents
|
|
327
|
+
// reasoning about capability see the same number on both sides of a
|
|
328
|
+
// recovery, whatever its outcome (a success overwrites it with the
|
|
329
|
+
// fresh count inside addUpstream).
|
|
330
|
+
const toolCount = this.statuses.get(serverName)?.toolCount;
|
|
331
|
+
this.statuses.set(serverName, {
|
|
332
|
+
name: serverName,
|
|
333
|
+
transport: transportOf(config.spec.def),
|
|
334
|
+
status: "reconnecting",
|
|
335
|
+
toolCount,
|
|
336
|
+
detail: `MCP session invalidated; reconnecting (${errorText(trigger).slice(0, 160)})`,
|
|
337
|
+
});
|
|
338
|
+
try {
|
|
339
|
+
await this.addUpstream(config.spec, config.options, this.connected.get(serverName));
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
// addUpstream isolates its own failures into statuses; reaching here
|
|
343
|
+
// is a bug guard, not the expected failure path.
|
|
344
|
+
this.statuses.set(serverName, { ...failureStatus(serverName, config.spec.def, err), toolCount, remediation: sessionRestartRemediation(serverName) });
|
|
345
|
+
this.enterSessionCooldown(serverName);
|
|
346
|
+
throw new Error(sessionRecoveryHint(serverName, errorText(err)));
|
|
347
|
+
}
|
|
348
|
+
const status = this.statuses.get(serverName);
|
|
349
|
+
if (status?.status === "ready" || status?.status === "degraded") {
|
|
350
|
+
this.sessionCooldowns.delete(serverName);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// A failed recovery already landed in its classified failure status;
|
|
354
|
+
// sharpen its remediation to the restart hint and open the cooldown.
|
|
355
|
+
this.statuses.set(serverName, { ...status, toolCount, remediation: sessionRestartRemediation(serverName) });
|
|
356
|
+
this.enterSessionCooldown(serverName);
|
|
357
|
+
throw new Error(sessionRecoveryHint(serverName, status?.detail ?? "the reconnect failed"));
|
|
358
|
+
}
|
|
359
|
+
/** The retried request failed on the fresh session too: an honest
|
|
360
|
+
* terminal state plus a cooldown — never a stale ready (D4). */
|
|
361
|
+
markRetrySessionFailure(serverName, err) {
|
|
362
|
+
const config = this.upstreamConfigs.get(serverName);
|
|
363
|
+
const previous = this.statuses.get(serverName);
|
|
364
|
+
const base = config
|
|
365
|
+
? failureStatus(serverName, config.spec.def, err)
|
|
366
|
+
: {
|
|
367
|
+
name: serverName,
|
|
368
|
+
transport: "http",
|
|
369
|
+
status: "unavailable",
|
|
370
|
+
detail: errorText(err).slice(0, 300),
|
|
371
|
+
requiresHuman: true,
|
|
372
|
+
requiresSessionRestart: true,
|
|
373
|
+
};
|
|
374
|
+
this.statuses.set(serverName, {
|
|
375
|
+
...base,
|
|
376
|
+
toolCount: previous?.toolCount,
|
|
377
|
+
remediation: sessionRestartRemediation(serverName),
|
|
378
|
+
});
|
|
379
|
+
this.enterSessionCooldown(serverName);
|
|
380
|
+
}
|
|
381
|
+
/** Upstream-originated failures name their server (spec: call errors
|
|
382
|
+
* name the failing upstream server). */
|
|
383
|
+
upstreamCallError(serverName, err) {
|
|
384
|
+
return new Error(`upstream "${serverName}" tool call failed: ${errorText(err)}`, err instanceof Error ? { cause: err } : undefined);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* How long a server whose session recovery failed refuses automatic
|
|
389
|
+
* re-attempts. Fixed by design: a reconnect storm against a down upstream
|
|
390
|
+
* buys nothing; the honest failure state plus this window is the v1
|
|
391
|
+
* answer, to be revisited with real-world data.
|
|
392
|
+
*/
|
|
393
|
+
export const SESSION_RECOVERY_COOLDOWN_MS = 30_000;
|
|
394
|
+
function transportOf(def) {
|
|
395
|
+
if (def.transport === "sse")
|
|
396
|
+
return "sse";
|
|
397
|
+
return def.url ? "http" : "stdio";
|
|
398
|
+
}
|
|
399
|
+
function sessionRestartRemediation(serverName) {
|
|
400
|
+
return `restart the Agent session to re-establish the MCP session for "${serverName}"`;
|
|
401
|
+
}
|
|
402
|
+
function sessionRecoveryHint(serverName, reason) {
|
|
403
|
+
return `upstream "${serverName}" MCP session invalidated and automatic recovery failed (${reason}) — ${sessionRestartRemediation(serverName)}`;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Whether an upstream error means "your MCP session no longer exists".
|
|
407
|
+
*
|
|
408
|
+
* The official TypeScript server SDK rejects an unknown session with HTTP
|
|
409
|
+
* 404 carrying the JSON-RPC error "Session not found", and pairs 401/403
|
|
410
|
+
* with -32000 for authentication rejections — so 401/403 gate the match
|
|
411
|
+
* out first, and an auth rejection can never enter recovery. The body
|
|
412
|
+
* reaches us inside the client SDK's `Error POSTing to endpoint: …`
|
|
413
|
+
* wrapper, so a word match covers custom upstream wordings (the real-world
|
|
414
|
+
* one says "MCP session no longer exists"). Real-machine verification
|
|
415
|
+
* (2026-10-23) showed custom upstreams also answer with other non-auth
|
|
416
|
+
* statuses — a 400 carrying that same -32000 body took the plain
|
|
417
|
+
* `SdkHttpError` path, not the enveloped `ProtocolError` one — hence the
|
|
418
|
+
* status gate is "not an auth rejection" rather than "exactly 404". A
|
|
419
|
+
* stray non-auth response that merely mentions "session" costs one
|
|
420
|
+
* idempotent-safe reconnect+retry, an acceptable price for covering every
|
|
421
|
+
* upstream wording. The `ProtocolError` branch covers the HTTP-200
|
|
422
|
+
* JSON-RPC error case, where the client SDK parses the envelope and
|
|
423
|
+
* rejects with the upstream's error code instead.
|
|
424
|
+
*/
|
|
425
|
+
export function isSessionInvalidation(err) {
|
|
426
|
+
if (err instanceof SdkHttpError)
|
|
427
|
+
return err.status !== 401 && err.status !== 403 && /session/i.test(err.message);
|
|
428
|
+
if (err instanceof ProtocolError)
|
|
429
|
+
return err.code === -32000 && /session/i.test(err.message);
|
|
430
|
+
return false;
|
|
144
431
|
}
|
|
145
432
|
function failureStatus(name, def, error) {
|
|
146
433
|
const detail = errorText(error).slice(0, 300);
|
|
@@ -189,6 +476,18 @@ function failureStatus(name, def, error) {
|
|
|
189
476
|
: `check that \`${def.command ?? "the MCP command"}\` is installed and responsive`,
|
|
190
477
|
};
|
|
191
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* Whether the gateway manages the OAuth credential for this definition:
|
|
481
|
+
* Trellis-owned OAuth, and no explicit `Authorization` header — someone
|
|
482
|
+
* who wrote one in canonical meant it, and silently overriding it with a
|
|
483
|
+
* stored token would be the kind of invisible precedence that is
|
|
484
|
+
* impossible to debug. Both `withOAuthHeader` and the per-request
|
|
485
|
+
* `AuthProvider` attach on exactly this predicate.
|
|
486
|
+
*/
|
|
487
|
+
export function oauthAttachApplies(def) {
|
|
488
|
+
return isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis"
|
|
489
|
+
&& !Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
|
|
490
|
+
}
|
|
192
491
|
/**
|
|
193
492
|
* Attaches a stored OAuth token, refreshing it first if it has expired.
|
|
194
493
|
*
|
|
@@ -197,15 +496,10 @@ function failureStatus(name, def, error) {
|
|
|
197
496
|
* OAuth at all, or one whose credential lives in `headers` already, and
|
|
198
497
|
* neither should be disturbed.
|
|
199
498
|
*
|
|
200
|
-
* An explicit `Authorization` header in canonical wins.
|
|
201
|
-
* one meant it, and silently overriding it with a stored token would be
|
|
202
|
-
* the kind of invisible precedence that is impossible to debug.
|
|
499
|
+
* An explicit `Authorization` header in canonical wins.
|
|
203
500
|
*/
|
|
204
501
|
export async function withOAuthHeader(name, def, homeDir) {
|
|
205
|
-
if (!
|
|
206
|
-
return def;
|
|
207
|
-
const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
|
|
208
|
-
if (hasExplicitAuth)
|
|
502
|
+
if (!oauthAttachApplies(def))
|
|
209
503
|
return def;
|
|
210
504
|
const stored = readToken(homeDir, name);
|
|
211
505
|
const resource = new URL(def.url);
|
|
@@ -217,6 +511,68 @@ export async function withOAuthHeader(name, def, homeDir) {
|
|
|
217
511
|
return def;
|
|
218
512
|
return { ...def, headers: { ...(def.headers ?? {}), Authorization: `Bearer ${token.accessToken}` } };
|
|
219
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* The early-refresh window on the gateway's per-request path. Minute-scale
|
|
516
|
+
* on purpose: a request that passes the freshness check must not carry a
|
|
517
|
+
* token that dies mid-flight. `store.ts`'s 60s default is untouched —
|
|
518
|
+
* `mcp auth`/`mcp renew` don't have this problem.
|
|
519
|
+
*/
|
|
520
|
+
export const GATEWAY_REFRESH_SKEW_MS = 300_000;
|
|
521
|
+
/**
|
|
522
|
+
* The SDK `AuthProvider` for one Trellis-owned OAuth upstream: `token()`
|
|
523
|
+
* runs as each outbound request's headers are assembled, so credentials
|
|
524
|
+
* resolve per request instead of once at connect time.
|
|
525
|
+
*
|
|
526
|
+
* Every read goes back to the token file with no memory cache — that is
|
|
527
|
+
* what makes a refresh by `trellis mcp auth` in another terminal take
|
|
528
|
+
* effect on the very next request of a running gateway (D6).
|
|
529
|
+
*/
|
|
530
|
+
export function trellisAuthProvider(opts) {
|
|
531
|
+
let lastIssued;
|
|
532
|
+
return {
|
|
533
|
+
token: async () => {
|
|
534
|
+
try {
|
|
535
|
+
const token = await ensureFreshToken(opts.homeDir, opts.serverName, {
|
|
536
|
+
skewMs: opts.skewMs ?? GATEWAY_REFRESH_SKEW_MS,
|
|
537
|
+
fetchImpl: opts.fetchImpl,
|
|
538
|
+
now: opts.now,
|
|
539
|
+
});
|
|
540
|
+
if (!token)
|
|
541
|
+
return undefined;
|
|
542
|
+
lastIssued = token.accessToken;
|
|
543
|
+
opts.onCredentialRecovered?.();
|
|
544
|
+
return token.accessToken;
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
// The pre-request credential check must never abort the request
|
|
548
|
+
// pipeline (D3): send whatever is stored — possibly stale — and
|
|
549
|
+
// let the server's 401 path decide the outcome.
|
|
550
|
+
return readToken(opts.homeDir, opts.serverName)?.accessToken;
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
onUnauthorized: async () => {
|
|
554
|
+
// The server just rejected a token that looked fine locally; the
|
|
555
|
+
// expiry check is exactly what must be bypassed. The transport
|
|
556
|
+
// retries the request once after this resolves — and when this
|
|
557
|
+
// throws instead, the SDK propagates the error to this one call
|
|
558
|
+
// as-is (pinned by test), so the message names the way out.
|
|
559
|
+
try {
|
|
560
|
+
await forceRefresh(opts.homeDir, opts.serverName, {
|
|
561
|
+
previousAccessToken: lastIssued,
|
|
562
|
+
fetchImpl: opts.fetchImpl,
|
|
563
|
+
now: opts.now,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
opts.onCredentialFailure?.(err);
|
|
568
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
569
|
+
throw new RefreshError(base.includes("trellis mcp auth")
|
|
570
|
+
? base
|
|
571
|
+
: `${base} — run \`trellis mcp auth ${opts.serverName}\` in a human terminal; the running gateway picks the new credential up on its next request`, err instanceof RefreshError ? err.oauthError : undefined);
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
}
|
|
220
576
|
function errorText(err) {
|
|
221
577
|
return err instanceof Error ? err.message : String(err);
|
|
222
578
|
}
|
package/dist/lib/mcpConnect.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* which servers are in scope, or knows what a tool is; callers isolate
|
|
13
13
|
* failures in whatever way suits them.
|
|
14
14
|
*/
|
|
15
|
-
import { Client } from "@modelcontextprotocol/client";
|
|
15
|
+
import { Client, type AuthProvider } from "@modelcontextprotocol/client";
|
|
16
16
|
import type { McpServerDef, SecretsPolicy } from "../core/types.js";
|
|
17
17
|
/** What an MCP upstream is told this client is. Each consumer passes its
|
|
18
18
|
* own so an upstream's logs name the thing that actually connected. */
|
|
@@ -46,11 +46,11 @@ export declare function connectWithCleanup<T, TTransport extends {
|
|
|
46
46
|
}, transport: TTransport, timeoutMs: number, message: string): Promise<T>;
|
|
47
47
|
export declare function connectStdio(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
|
|
48
48
|
export declare function resolveHeaders(def: McpServerDef, secretsPolicy: SecretsPolicy): Record<string, string> | undefined;
|
|
49
|
-
export declare function connectHttp(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
|
|
50
|
-
export declare function connectSse(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
|
|
49
|
+
export declare function connectHttp(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo, authProvider?: AuthProvider): Promise<Client>;
|
|
50
|
+
export declare function connectSse(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo, authProvider?: AuthProvider): Promise<Client>;
|
|
51
51
|
/**
|
|
52
52
|
* Transport dispatch, matching the original bridge's own ordering: `http`
|
|
53
53
|
* and `sse` are explicit, everything else (including an absent
|
|
54
54
|
* `transport`) is stdio — a default the canonical schema relies on.
|
|
55
55
|
*/
|
|
56
|
-
export declare function connectServer(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
|
|
56
|
+
export declare function connectServer(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo, authProvider?: AuthProvider): Promise<Client>;
|
package/dist/lib/mcpConnect.js
CHANGED
|
@@ -102,17 +102,27 @@ export function resolveHeaders(def, secretsPolicy) {
|
|
|
102
102
|
}
|
|
103
103
|
return result;
|
|
104
104
|
}
|
|
105
|
-
export async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
105
|
+
export async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
|
|
106
106
|
const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: "auto", probe: { timeoutMs } } });
|
|
107
107
|
const headers = resolveHeaders(def, secretsPolicy);
|
|
108
|
-
|
|
108
|
+
// authProvider is the SDK's per-request credential seam: `token()` runs
|
|
109
|
+
// as each request's headers are assembled and its result overrides
|
|
110
|
+
// `requestInit`'s Authorization — the alternative to burning a Bearer
|
|
111
|
+
// into the static headers once at connect time.
|
|
112
|
+
const opts = {
|
|
113
|
+
...(headers ? { requestInit: { headers } } : {}),
|
|
114
|
+
...(authProvider ? { authProvider } : {}),
|
|
115
|
+
};
|
|
109
116
|
return connectWithCleanup(client, new StreamableHTTPClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
110
117
|
}
|
|
111
|
-
export async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
118
|
+
export async function connectSse(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
|
|
112
119
|
// HTTP+SSE is a legacy transport; it cannot carry modern discovery.
|
|
113
120
|
const client = new Client(clientInfo, { capabilities: {} });
|
|
114
121
|
const headers = resolveHeaders(def, secretsPolicy);
|
|
115
|
-
const opts =
|
|
122
|
+
const opts = {
|
|
123
|
+
...(headers ? { requestInit: { headers } } : {}),
|
|
124
|
+
...(authProvider ? { authProvider } : {}),
|
|
125
|
+
};
|
|
116
126
|
return connectWithCleanup(client, new SSEClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
117
127
|
}
|
|
118
128
|
/**
|
|
@@ -120,10 +130,10 @@ export async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
|
120
130
|
* and `sse` are explicit, everything else (including an absent
|
|
121
131
|
* `transport`) is stdio — a default the canonical schema relies on.
|
|
122
132
|
*/
|
|
123
|
-
export function connectServer(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
133
|
+
export function connectServer(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
|
|
124
134
|
if (def.transport === "http")
|
|
125
|
-
return connectHttp(def, secretsPolicy, timeoutMs, clientInfo);
|
|
135
|
+
return connectHttp(def, secretsPolicy, timeoutMs, clientInfo, authProvider);
|
|
126
136
|
if (def.transport === "sse")
|
|
127
|
-
return connectSse(def, secretsPolicy, timeoutMs, clientInfo);
|
|
137
|
+
return connectSse(def, secretsPolicy, timeoutMs, clientInfo, authProvider);
|
|
128
138
|
return connectStdio(def, secretsPolicy, timeoutMs, clientInfo);
|
|
129
139
|
}
|
|
@@ -82,6 +82,16 @@ export declare class McpToolRegistry {
|
|
|
82
82
|
* aggregated, with no special-casing here.
|
|
83
83
|
*/
|
|
84
84
|
add(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
|
|
85
|
+
/**
|
|
86
|
+
* Swap one server's entire registration — the mid-session recovery path.
|
|
87
|
+
* Every candidate the server previously contributed is dropped before the
|
|
88
|
+
* fresh tool set is added, so a changed upstream tool set is reflected
|
|
89
|
+
* exactly rather than accumulated. Exposed names are re-allocated across
|
|
90
|
+
* the whole registry, so a name this server frees may be re-assigned
|
|
91
|
+
* deterministically to another server's colliding tool — accepted drift,
|
|
92
|
+
* pinned by the spec scenario.
|
|
93
|
+
*/
|
|
94
|
+
replace(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
|
|
85
95
|
private exposedNameFor;
|
|
86
96
|
/** Every registered tool, under its prefixed name, in registration
|
|
87
97
|
* order. Each tool's own `description`/`inputSchema` and any additional
|
|
@@ -127,6 +127,25 @@ export class McpToolRegistry {
|
|
|
127
127
|
const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
|
|
128
128
|
return { added, skipped: [] };
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Swap one server's entire registration — the mid-session recovery path.
|
|
132
|
+
* Every candidate the server previously contributed is dropped before the
|
|
133
|
+
* fresh tool set is added, so a changed upstream tool set is reflected
|
|
134
|
+
* exactly rather than accumulated. Exposed names are re-allocated across
|
|
135
|
+
* the whole registry, so a name this server frees may be re-assigned
|
|
136
|
+
* deterministically to another server's colliding tool — accepted drift,
|
|
137
|
+
* pinned by the spec scenario.
|
|
138
|
+
*/
|
|
139
|
+
replace(serverName, upstream, tools) {
|
|
140
|
+
const kept = this.candidates.filter((candidate) => candidate.serverName !== serverName);
|
|
141
|
+
this.candidates.length = 0;
|
|
142
|
+
this.candidates.push(...kept);
|
|
143
|
+
for (const tool of tools)
|
|
144
|
+
this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
|
|
145
|
+
this.rebuild();
|
|
146
|
+
const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
|
|
147
|
+
return { added, skipped: [] };
|
|
148
|
+
}
|
|
130
149
|
exposedNameFor(serverName, toolName) {
|
|
131
150
|
for (const [name, entry] of this.entries) {
|
|
132
151
|
if (entry.serverName === serverName && entry.toolName === toolName)
|
|
@@ -33,4 +33,19 @@ export declare class RefreshError extends Error {
|
|
|
33
33
|
* would hide a revoked grant.
|
|
34
34
|
*/
|
|
35
35
|
export declare function ensureFreshToken(homeDir: string, serverName: string, opts?: RefreshOptions): Promise<StoredToken | undefined>;
|
|
36
|
+
export interface ForceRefreshOptions extends RefreshOptions {
|
|
37
|
+
/** The access token the caller last saw rejected (e.g. by a 401). When
|
|
38
|
+
* the stored credential already holds a different access token, another
|
|
39
|
+
* process refreshed in between and that result is returned as-is. */
|
|
40
|
+
previousAccessToken?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Refreshes regardless of expiry — the 401 recovery path.
|
|
44
|
+
*
|
|
45
|
+
* The server just rejected a token that still looks unexpired locally
|
|
46
|
+
* (revoked early, clock skew, a stale read), so `ensureFreshToken`'s
|
|
47
|
+
* freshness check is exactly the thing that must be bypassed. Everything
|
|
48
|
+
* else matches it: per-server lock, in-lock re-read, rotation persisted.
|
|
49
|
+
*/
|
|
50
|
+
export declare function forceRefresh(homeDir: string, serverName: string, opts?: ForceRefreshOptions): Promise<StoredToken>;
|
|
36
51
|
export declare function performRefresh(serverName: string, token: StoredToken, opts?: RefreshOptions): Promise<StoredToken>;
|
|
@@ -53,6 +53,36 @@ export async function ensureFreshToken(homeDir, serverName, opts = {}) {
|
|
|
53
53
|
return refreshed;
|
|
54
54
|
}, { timeoutMs: opts.lockTimeoutMs, now });
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Refreshes regardless of expiry — the 401 recovery path.
|
|
58
|
+
*
|
|
59
|
+
* The server just rejected a token that still looks unexpired locally
|
|
60
|
+
* (revoked early, clock skew, a stale read), so `ensureFreshToken`'s
|
|
61
|
+
* freshness check is exactly the thing that must be bypassed. Everything
|
|
62
|
+
* else matches it: per-server lock, in-lock re-read, rotation persisted.
|
|
63
|
+
*/
|
|
64
|
+
export async function forceRefresh(homeDir, serverName, opts = {}) {
|
|
65
|
+
const existing = readToken(homeDir, serverName);
|
|
66
|
+
if (!existing) {
|
|
67
|
+
throw new RefreshError(`OAuth token for "${serverName}" is gone — run \`trellis mcp auth ${serverName}\``);
|
|
68
|
+
}
|
|
69
|
+
return withServerLock(homeDir, serverName, async () => {
|
|
70
|
+
// Re-read inside the lock. With the expiry check gone, "someone else
|
|
71
|
+
// got here first" can only be detected by identity: if the stored
|
|
72
|
+
// access token is no longer the one we saw rejected, a concurrent
|
|
73
|
+
// refresh already fixed things and rotating again would kill its
|
|
74
|
+
// result.
|
|
75
|
+
const current = readToken(homeDir, serverName) ?? existing;
|
|
76
|
+
if (opts.previousAccessToken !== undefined && current.accessToken !== opts.previousAccessToken)
|
|
77
|
+
return current;
|
|
78
|
+
if (!current.refreshToken) {
|
|
79
|
+
throw new RefreshError(`OAuth token for "${serverName}" has no refresh token — run \`trellis mcp auth ${serverName}\``);
|
|
80
|
+
}
|
|
81
|
+
const refreshed = await performRefresh(serverName, current, opts);
|
|
82
|
+
writeToken(homeDir, serverName, refreshed);
|
|
83
|
+
return refreshed;
|
|
84
|
+
}, { timeoutMs: opts.lockTimeoutMs, now: opts.now });
|
|
85
|
+
}
|
|
56
86
|
export async function performRefresh(serverName, token, opts = {}) {
|
|
57
87
|
const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000) }));
|
|
58
88
|
const now = opts.now ?? Date.now;
|
|
@@ -13,6 +13,11 @@ export declare class UpstreamResources {
|
|
|
13
13
|
private readonly files;
|
|
14
14
|
constructor(timeoutMs: number, warn: (message: string) => void);
|
|
15
15
|
add(source: string, client: Client): void;
|
|
16
|
+
/** Forget one source entirely: its client and every file route it
|
|
17
|
+
* registered. Called before a source is re-added mid-session — stale
|
|
18
|
+
* routes would otherwise route reads at the closed previous client
|
|
19
|
+
* (`listResources`/`listSkills` re-register the fresh client's routes). */
|
|
20
|
+
removeSource(source: string): void;
|
|
16
21
|
private skillsEnabled;
|
|
17
22
|
private isolated;
|
|
18
23
|
listResources(): Promise<Resource[]>;
|
|
@@ -54,6 +54,16 @@ export class UpstreamResources {
|
|
|
54
54
|
this.warn = warn;
|
|
55
55
|
}
|
|
56
56
|
add(source, client) { this.clients.set(source, client); }
|
|
57
|
+
/** Forget one source entirely: its client and every file route it
|
|
58
|
+
* registered. Called before a source is re-added mid-session — stale
|
|
59
|
+
* routes would otherwise route reads at the closed previous client
|
|
60
|
+
* (`listResources`/`listSkills` re-register the fresh client's routes). */
|
|
61
|
+
removeSource(source) {
|
|
62
|
+
this.clients.delete(source);
|
|
63
|
+
for (const [uri, route] of this.files)
|
|
64
|
+
if (route.source === source)
|
|
65
|
+
this.files.delete(uri);
|
|
66
|
+
}
|
|
57
67
|
skillsEnabled(client) {
|
|
58
68
|
return Boolean(client.getServerCapabilities()?.resources && client.getDiscoverResult()?.capabilities.extensions?.[EXTENSION]);
|
|
59
69
|
}
|
package/dist/pi-bridge/bundle.js
CHANGED
|
@@ -45929,10 +45929,13 @@ function resolveHeaders(def, secretsPolicy) {
|
|
|
45929
45929
|
}
|
|
45930
45930
|
return result;
|
|
45931
45931
|
}
|
|
45932
|
-
async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
45932
|
+
async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
|
|
45933
45933
|
const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: "auto", probe: { timeoutMs } } });
|
|
45934
45934
|
const headers = resolveHeaders(def, secretsPolicy);
|
|
45935
|
-
const opts =
|
|
45935
|
+
const opts = {
|
|
45936
|
+
...headers ? { requestInit: { headers } } : {},
|
|
45937
|
+
...authProvider ? { authProvider } : {}
|
|
45938
|
+
};
|
|
45936
45939
|
return connectWithCleanup(
|
|
45937
45940
|
client,
|
|
45938
45941
|
new StreamableHTTPClientTransport(new URL(def.url), opts),
|
|
@@ -45940,10 +45943,13 @@ async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
|
45940
45943
|
`connect timed out after ${timeoutMs}ms`
|
|
45941
45944
|
);
|
|
45942
45945
|
}
|
|
45943
|
-
async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
45946
|
+
async function connectSse(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
|
|
45944
45947
|
const client = new Client(clientInfo, { capabilities: {} });
|
|
45945
45948
|
const headers = resolveHeaders(def, secretsPolicy);
|
|
45946
|
-
const opts =
|
|
45949
|
+
const opts = {
|
|
45950
|
+
...headers ? { requestInit: { headers } } : {},
|
|
45951
|
+
...authProvider ? { authProvider } : {}
|
|
45952
|
+
};
|
|
45947
45953
|
return connectWithCleanup(
|
|
45948
45954
|
client,
|
|
45949
45955
|
new SSEClientTransport(new URL(def.url), opts),
|
|
@@ -45951,9 +45957,9 @@ async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
|
45951
45957
|
`connect timed out after ${timeoutMs}ms`
|
|
45952
45958
|
);
|
|
45953
45959
|
}
|
|
45954
|
-
function connectServer(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
45955
|
-
if (def.transport === "http") return connectHttp(def, secretsPolicy, timeoutMs, clientInfo);
|
|
45956
|
-
if (def.transport === "sse") return connectSse(def, secretsPolicy, timeoutMs, clientInfo);
|
|
45960
|
+
function connectServer(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
|
|
45961
|
+
if (def.transport === "http") return connectHttp(def, secretsPolicy, timeoutMs, clientInfo, authProvider);
|
|
45962
|
+
if (def.transport === "sse") return connectSse(def, secretsPolicy, timeoutMs, clientInfo, authProvider);
|
|
45957
45963
|
return connectStdio(def, secretsPolicy, timeoutMs, clientInfo);
|
|
45958
45964
|
}
|
|
45959
45965
|
|
|
@@ -46041,6 +46047,24 @@ var McpToolRegistry = class {
|
|
|
46041
46047
|
const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== void 0);
|
|
46042
46048
|
return { added, skipped: [] };
|
|
46043
46049
|
}
|
|
46050
|
+
/**
|
|
46051
|
+
* Swap one server's entire registration — the mid-session recovery path.
|
|
46052
|
+
* Every candidate the server previously contributed is dropped before the
|
|
46053
|
+
* fresh tool set is added, so a changed upstream tool set is reflected
|
|
46054
|
+
* exactly rather than accumulated. Exposed names are re-allocated across
|
|
46055
|
+
* the whole registry, so a name this server frees may be re-assigned
|
|
46056
|
+
* deterministically to another server's colliding tool — accepted drift,
|
|
46057
|
+
* pinned by the spec scenario.
|
|
46058
|
+
*/
|
|
46059
|
+
replace(serverName, upstream, tools) {
|
|
46060
|
+
const kept = this.candidates.filter((candidate) => candidate.serverName !== serverName);
|
|
46061
|
+
this.candidates.length = 0;
|
|
46062
|
+
this.candidates.push(...kept);
|
|
46063
|
+
for (const tool of tools) this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
|
|
46064
|
+
this.rebuild();
|
|
46065
|
+
const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== void 0);
|
|
46066
|
+
return { added, skipped: [] };
|
|
46067
|
+
}
|
|
46044
46068
|
exposedNameFor(serverName, toolName) {
|
|
46045
46069
|
for (const [name, entry] of this.entries) {
|
|
46046
46070
|
if (entry.serverName === serverName && entry.toolName === toolName) return name;
|
|
@@ -46293,10 +46317,11 @@ var listSchema = external_exports.looseObject({ skills: external_exports.array(e
|
|
|
46293
46317
|
var getSchema = external_exports.looseObject({ skill: external_exports.unknown() });
|
|
46294
46318
|
|
|
46295
46319
|
// src/lib/gatewayBackend.ts
|
|
46320
|
+
function oauthAttachApplies(def) {
|
|
46321
|
+
return isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis" && !Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
|
|
46322
|
+
}
|
|
46296
46323
|
async function withOAuthHeader(name, def, homeDir) {
|
|
46297
|
-
if (!
|
|
46298
|
-
const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
|
|
46299
|
-
if (hasExplicitAuth) return def;
|
|
46324
|
+
if (!oauthAttachApplies(def)) return def;
|
|
46300
46325
|
const stored = readToken(homeDir, name);
|
|
46301
46326
|
const resource = new URL(def.url);
|
|
46302
46327
|
resource.hash = "";
|
package/docs/roadmap.md
CHANGED
|
@@ -971,7 +971,12 @@ health, honest credential status in `trellis.mcp.status`, browser callback
|
|
|
971
971
|
page). v0.14.0 — pi native MCP delivery (pi ≥ 1.1.0 `mcp.json` writes,
|
|
972
972
|
bridge retirement in the same sync; supersedes P4's default path).
|
|
973
973
|
v0.14.1 — canonical `auth.client_name` for providers that gate OAuth dynamic
|
|
974
|
-
client registration by client name (Figma).
|
|
974
|
+
client registration by client name (Figma). v0.15.0 — workspace scope: a
|
|
975
|
+
second canonical layer in the repo itself (`<repo>/.agents/`), merged
|
|
976
|
+
workspace-wins-by-ID, delivered layer-preserving to all six agents.
|
|
977
|
+
v0.15.1 — `--flag=value` accepted everywhere; `mcp remove --workspace`.
|
|
978
|
+
v0.16.0 — doctor validates workspace `.codex/config.toml` TOML syntax.
|
|
979
|
+
v0.17.0 — doctor reports diverging `trellis` entrypoints on PATH.
|
|
975
980
|
|
|
976
981
|
**Cross-machine / cloud sharing — confirmed future direction, not yet
|
|
977
982
|
scoped as a phase.** Surveyed 2026-09-15 (`docs/research.md`'s "Cross-
|
|
@@ -988,6 +993,20 @@ coordination, evaluated only after the simpler pieces are proven). See
|
|
|
988
993
|
and `daytonaio/daytona` were both evaluated and not adopted (preview-only
|
|
989
994
|
API instability, and unmaintained-as-of-June-2026, respectively).
|
|
990
995
|
|
|
996
|
+
**Self-evolution — direction captured 2026-10-20, not yet scoped as a
|
|
997
|
+
phase.** Trellis is two decoupled subsystems: hosting (delivery and
|
|
998
|
+
consistency of canonical content) and self-evolution (content that improves
|
|
999
|
+
with use, running ambiently on the runtime substrate, opt-in and graded
|
|
1000
|
+
off → observe → propose → auto-A). Dependency runs one way, evolution →
|
|
1001
|
+
hosting, through a narrow port; uninstalling evolution leaves hosting
|
|
1002
|
+
byte-identical. Methodology ported from google-research/harness2 (no usable
|
|
1003
|
+
SDK exists); first step is a set of generic hosting extension points, not
|
|
1004
|
+
evolution code. See [`self-evolution.md`](self-evolution.md) for the full
|
|
1005
|
+
idea, what is verified versus assumed, and the open decisions; the
|
|
1006
|
+
verifiable contract is the consolidated draft at
|
|
1007
|
+
`openspec/changes/trellis-evolve/` (one change, two capabilities:
|
|
1008
|
+
`host-extension-points` and `self-evolution`; unimplemented).
|
|
1009
|
+
|
|
991
1010
|
No dates. This is scoped by verification milestones, not calendar time.
|
|
992
1011
|
Execution order for P11–P16: P11 → P12 → P13 → P14 → P15 → P16 — CRUD
|
|
993
1012
|
(P12) lands before the larger features (P14, P15, P16) so each can be
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Runtime 基建调研议程
|
|
2
|
+
|
|
3
|
+
> **状态:调研议程,尚未实施。** 记录于 2026-10-23。四个方向是"我们的 runtime"离不开的
|
|
4
|
+
> 基建层;逐项调研,调研结果各自沉淀后再决定实施。自我进化(第 4 项)已有完整草案,
|
|
5
|
+
> 见 `docs/self-evolution.md` 与 `openspec/changes/trellis-evolve/`。
|
|
6
|
+
|
|
7
|
+
## 建议顺序:1 → 2 → 3,第 4 项按既有 tasks 推进
|
|
8
|
+
|
|
9
|
+
先吃透模型接入层(网关),再往网络层(代理、多机)走;进化已是实施轨道,不是调研轨道。
|
|
10
|
+
|
|
11
|
+
## 1. 大模型网关(Magpie)——子仓库化 + 核心实现研究
|
|
12
|
+
|
|
13
|
+
已知:
|
|
14
|
+
- Magpie 已在 Air 两层实测运行(macOS 与 OrbStack worker 各自 `127.0.0.1:3425/v1`);
|
|
15
|
+
worker 层 Codex 流式/工具往返/Responses 通过。用法见 `$local-model-gateway`、
|
|
16
|
+
`$mac-air-usage`。
|
|
17
|
+
|
|
18
|
+
调研问题:
|
|
19
|
+
- Magpie 源码在本机的位置(**未定位,第一步**);
|
|
20
|
+
- 子仓库形态:git submodule / subtree / 研究副本(先例:`~/www/morphix-research/sources/`);
|
|
21
|
+
- 核心实现:provider 路由与适配、流式转发、工具调用透传、错误与重试语义。
|
|
22
|
+
|
|
23
|
+
## 2. 代理——稳定访问层
|
|
24
|
+
|
|
25
|
+
调研问题:
|
|
26
|
+
- Magpie 到各上游 provider 的代理路由(per-provider 配置、failover 策略);
|
|
27
|
+
- npm/git 等开发工具链的代理;
|
|
28
|
+
- 与网关层的边界:重试/切换由代理层还是网关层负责;
|
|
29
|
+
- 与 Nore 的职责区分(设备互联 vs 外网代理,别混为一谈)。
|
|
30
|
+
|
|
31
|
+
已有数据点:2026-10-22 npm publish 上传连接 ECONNRESET,IPv4-first + 单 socket 解决;
|
|
32
|
+
GET/ping 正常、仅上传路径异常。
|
|
33
|
+
|
|
34
|
+
## 3. 多机互通——相互委派任务的网络底座
|
|
35
|
+
|
|
36
|
+
已知锚点:
|
|
37
|
+
- Pro↔Air 私网由 Nore 设备互联提供(`ssh morphix-air-private` → `100.127.171.120`);
|
|
38
|
+
- 本机有 `~/www/nore/` 与 `~/www/nore-next/` 两个仓库,调研以 Nore 源码为主对象之一;
|
|
39
|
+
- 现网痛点:SSH 中继偶发超时(Air doctor 详情两次读取均超时)。
|
|
40
|
+
|
|
41
|
+
委派已有雏形(分散,未统一):Trellis `tasks_handoff`(MCP)、Air 侧 Dagu
|
|
42
|
+
(`morphix-air-tasks`,当前已禁用)、agent-collab mailbox。
|
|
43
|
+
|
|
44
|
+
调研问题:网络可靠性(重连/唤醒)、服务发现、委派语义与安全边界。
|
|
45
|
+
|
|
46
|
+
## 4. 自我进化——已是实施轨道
|
|
47
|
+
|
|
48
|
+
不在本文重复。接手入口:`docs/self-evolution.md` 的"接手指南";下一步是其
|
|
49
|
+
tasks 第 1 组的三个只读前置验证(元数据信号采样、provider 读取路径、扩展加载机制),
|
|
50
|
+
之后才是托管扩展点(P1)。
|
|
51
|
+
|
|
52
|
+
## 对照样本(见记忆 research-subjects-backlog)
|
|
53
|
+
|
|
54
|
+
Raven(harness of harnesses)、Octop(腾讯云多 Agent,coordinator 调度与 workspace
|
|
55
|
+
分层)、asyar(轻量启动器,仅交互效率参照)。
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# 自我进化:Trellis 的第二个子系统(想法沉淀)
|
|
2
|
+
|
|
3
|
+
> **状态:整合草案,尚未实现。** 记录于 2026-10-20,来自一轮对 google-research/harness2 的调研与讨论;同日已整合为 `openspec/changes/trellis-evolve/`(一份草案、两个解耦的 capability)。
|
|
4
|
+
> 本文是**想法与取舍**:是什么、为什么、边界在哪、已核实什么、还没定什么。可验证的行为约束在 change 的 `specs/` 里,**二者冲突时以 `specs/` 为准**。
|
|
5
|
+
|
|
6
|
+
## 接手指南
|
|
7
|
+
|
|
8
|
+
**一句话现状**:方向、边界、评测方法与护栏已定并写成可校验的草案;没有一行实现代码;下一步是先做三项只读前置验证,再做托管扩展点。
|
|
9
|
+
|
|
10
|
+
**文档地图(各管什么,避免重复维护)**
|
|
11
|
+
|
|
12
|
+
| 文件 | 管什么 | 更新时机 |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `docs/self-evolution.md`(本文) | 想法、取舍、已核实/未核实、路线 | 每次核实出新事实或做出决定 |
|
|
15
|
+
| `openspec/changes/trellis-evolve/proposal.md` | 为什么、改什么、影响面 | 范围变化时 |
|
|
16
|
+
| `…/specs/host-extension-points/spec.md` | 托管侧通用扩展点的行为契约 | 接口决定后 |
|
|
17
|
+
| `…/specs/self-evolution/spec.md` | 进化子系统的行为契约 | 行为决定后 |
|
|
18
|
+
| `…/design.md` | 技术决策、已核实现状(带代码位置)、风险、开放问题 | 决策或核实后 |
|
|
19
|
+
| `…/tasks.md` | 实施顺序与逐项验证方式 | 实施中勾选 |
|
|
20
|
+
| `docs/roadmap.md` | 一段指针 | 阶段变化时 |
|
|
21
|
+
|
|
22
|
+
**建议阅读顺序**:本文"一句话"与"核心原则" → `design.md` 的 Context(已核实表)与 Open Questions → `tasks.md` 前三组 → 需要细节再读 `specs/`。
|
|
23
|
+
|
|
24
|
+
**接手后第一步(tasks 第 1 组,全部只读)**:① 真机采样元数据事件能产生什么信号;② 核实 skills/instructions provider 是实时读取还是启动时缓存;③ 定下扩展加载机制与状态目录路径。这三项会决定 `host-extension-points` 的具体接口,**不要在它们之前开始写实现**。
|
|
25
|
+
|
|
26
|
+
**需要接手人特别留意的三件事**
|
|
27
|
+
|
|
28
|
+
1. **解耦是硬约束**:依赖只能是进化 → 托管。`src/evolve/` 只允许 import `src/host/*`,有测试强制。任何让托管"知道"进化的改动都是方向性错误。
|
|
29
|
+
2. **带外批准目前不是完全可靠的**:有 shell 能力的 agent 理论上能执行管理面命令,TTY 检测只是启发式。`auto-A` 开启之前必须再评估(见 design 的 Risks 与 Open Questions)。
|
|
30
|
+
3. **覆盖范围有边界**:事件只来自经 gateway/runtime 投递的 agent,仅走原生渲染的 agent 没有工具调用事件。不要在文档或 doctor 输出里假装全覆盖。
|
|
31
|
+
|
|
32
|
+
**本草案的形成过程(便于判断哪些结论可信)**:最初按"用户运行 `trellis evolve` 命令触发"建模,被用户纠正为"环境式、跑在 runtime 上、与托管解耦、可选开启"后整体重写。命令式阶段遗留的"单备份会话写入""staging 复现层分裂"等需求已下沉为托管的公开接口。
|
|
33
|
+
|
|
34
|
+
## 一句话
|
|
35
|
+
|
|
36
|
+
Trellis 由两个**解耦**的部分组成:**托管**(让配置被正确投递并保持一致)和**自我进化**(让被托管的内容随使用变得更好)。进化是挂在 runtime 底座上的环境式能力,跑在其上的 agent 自己持续进化,可选安装、分级开启;它**不是**一条要用户去敲的命令。
|
|
37
|
+
|
|
38
|
+
## 为什么要做
|
|
39
|
+
|
|
40
|
+
托管已经覆盖 skills、MCP、指令、scope、memory,但托管内容的**改进完全靠人**。信号其实一直存在,缺的是把"信号 → 提案 → 验证 → 采纳 → 教训沉淀"闭环起来的机制。2026-10 的两个真实事故说明了这一点:
|
|
41
|
+
|
|
42
|
+
- workspace `.codex/config.toml` 重复键,让该项目的所有 Codex 会话静默失败约 19 小时,没有任何机制主动发现;
|
|
43
|
+
- PATH 上 0.12.0 旧入口遮蔽 0.16.0,把版本分叉误诊成 `servers.yaml` 配置错误。
|
|
44
|
+
|
|
45
|
+
两件事 doctor 都在事后补上了检查,但"事故 → 人发现 → 人修 → 人补检查"这条链全靠人。进化要缩短的就是这条链。
|
|
46
|
+
|
|
47
|
+
## 核心原则:两个子系统,一个窄接口
|
|
48
|
+
|
|
49
|
+
| | 托管 (Hosting) | 自我进化 (Evolution) |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| 职责 | canonical 内容被正确投递并保持一致 | 被托管的内容随使用变好 |
|
|
52
|
+
| 拥有 | canonical、adapters、gateway、backup、doctor、memory 管道 | 信号、提案、验证、风险门、lesson、运行档案 |
|
|
53
|
+
| 不知道 | 进化的存在 | 具体 agent 怎么渲染、怎么投递 |
|
|
54
|
+
| 缺席时 | 完全正常 | (本来就可选) |
|
|
55
|
+
|
|
56
|
+
- **依赖方向只有一条:进化 → 托管,绝不反向。** 卸载进化,托管行为逐字节不变。这比"功能开关"更强:它是**可选安装**。
|
|
57
|
+
- **进化自己声明它需要的 `HostPort`,托管去实现**,和 harness2 的 `Substrate` 协议同构(方法只依赖协议,不 import 具体框架)。好处:进化可以用 fake host 独立测试;托管的 `scope.yaml`(怎么渲染)与进化的"可编辑表面"声明(哪些能改、有什么静默失效坑)各管各的;架构上不堵死将来对接别的宿主。
|
|
58
|
+
- 早期进化放在同仓库独立目录(`src/evolve/` 只允许 import 托管的公开 `host` 子路径,用测试强制边界),接口稳定后再考虑拆独立包。
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
进化(只依赖这个接口) 托管(实现它)
|
|
62
|
+
HostPort {
|
|
63
|
+
readSurface(layer) ───────→ loadCanonicalSource(只读,已有)
|
|
64
|
+
stage(edits) ───────→ scratch HOME + 临时 workspace(测试夹具模式已有)
|
|
65
|
+
check(staged|live) ───────→ doctor + sync --check + secrets audit
|
|
66
|
+
apply(edits, session) ───────→ backup session 写入(已有,未公开)
|
|
67
|
+
rollback(session) ───────→ trellis rollback(已有,未公开)
|
|
68
|
+
subscribe(events) ───────→ 会话事件流(新增,通用)
|
|
69
|
+
remember(lesson) ───────→ memory 管道(已有)
|
|
70
|
+
delegate(task) ───────→ agent-bridge(进行中)
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 环境式运行:触发、观察、通知、生效
|
|
75
|
+
|
|
76
|
+
Trellis 的 gateway 是按会话拉起的 stdio 进程,规范明确 **"without a resident daemon"**,所以进化也**不引入常驻进程**,靠事件驱动:
|
|
77
|
+
|
|
78
|
+
1. **观察**:被动加主动并行。被动 = gateway 路由层记录元数据(工具/skill/记忆的调用、耗时、错误类别);主动 = agent 在用户纠正时自己调用 `trellis.evolve.note` 上报。主动信号最贵、质量最高(例如一次"配置改反方向"的纠正)。
|
|
79
|
+
2. **触发**:会话结束(provider 的 `close()` 钩子)时判断信号量/严重度/冷却期,满足则 detached 拉起**一次性**后台作业,单实例锁。
|
|
80
|
+
3. **提案 → 验证 → 过门**:见下节,即原五环的 Propose / Probe / Compose。
|
|
81
|
+
4. **通知**:pending 提案经 provider 暴露给 agent(`pending` 工具或 resource),agent 在会话里告知用户;doctor 与 GUI 也可查看。
|
|
82
|
+
5. **生效**:runtime 模式下 skills / instructions 由 provider 从 canonical 实时提供,采纳后**下个会话自然读到新版本,无需 sync**;只用原生渲染的 agent,采纳后触发 sync。
|
|
83
|
+
6. **分级开启**:`off`(默认)→ `observe` → `propose` → `auto-A`(仅 Class A 自动采纳)。粒度为全局默认 + per-agent + per-workspace,沿用 `onboard --memory on` 的显式选择方式,写进 canonical,doctor 可见,不静默开启。
|
|
84
|
+
|
|
85
|
+
CLI 退居**管理面**:`status | pending | approve | reject | enable | disable | rollback`,不再是触发器。
|
|
86
|
+
|
|
87
|
+
## 进化循环(五环)
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
Observe → Propose → Probe → Compose → Apply+Reflect
|
|
91
|
+
│
|
|
92
|
+
lesson 回流为下次 Propose 的上下文 ┘
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- **Propose**:经 agent-bridge `runDelegatedCall` 委派 editor,产出结构化 `proposal.yaml`。editor 是**不可信输出**:机械校验器复核 kind/target 白名单与证据要求,错误写成可执行散文喂回重试一次,再错丢弃。**没有 evidence 链接的提案直接丢弃。**
|
|
96
|
+
- **Probe**:提案先写入 staging 候选(scratch HOME,按需加临时 workspace root),不动真配置。
|
|
97
|
+
- **Compose**:风险门,见下。采纳铁律:任何验收器结果不得差于 baseline,宁可不进化。
|
|
98
|
+
- **Apply+Reflect**:写入走一个备份会话,单点 `trellis rollback` 整体回滚;被采纳的编辑蒸馏成 lesson `{cue, lesson, evidence, confidence?}` 进 `memories/`,作为下次提案上下文(harness2 的 improvement harness)。
|
|
99
|
+
|
|
100
|
+
## 评测:怎么对比修改前后(最关键的一环)
|
|
101
|
+
|
|
102
|
+
harness2 的评分是**两段式且互相隔离**:选择(匿名对比、无 ground truth)与评分(checklist,选择承诺之后才跑)物理分离,选择器永远看不到评分材料。我们按"有无现成 oracle"分层:
|
|
103
|
+
|
|
104
|
+
- **L1 配置类 — 确定性差集,零 LLM**:baseline 与 staging 候选各跑 doctor / `sync --check` / secrets audit,可采纳 ⟺ 无新 finding ∧ 目标 finding 已消除 ∧ 其余不变。配置类的检查本身就是 ground truth,比 LLM 选择器更强也更便宜。两个事故都属这一类。
|
|
105
|
+
- **L2 内容类 — 沙盒执行 + 匿名 A/B**:同一道探针任务在 base 与 candidate 下各跑一次,交付物冻结后,以种子匿名 id 呈现给独立选择器(不给 verdict、不含评分字样、不标明哪臂是新)。选择器失败 / 任一臂未交付 / 选中 base ⟹ 一律不采纳。**一处刻意反转**:harness2 把 base 排除出候选池("构造上无法弃权",因为它必须进化);我们把 base **留在池里**,因为我们不能退化。
|
|
106
|
+
- **L3 成绩单**:一小组带显式 checklist 的探针,定期对已采纳修改跑 `n_passed/n_criteria` 看趋势。评分失败记 `None`,不计入分母,**永不记 0 分**;结果只给人看,不给选择器看。
|
|
107
|
+
- **缺口**:内容类(L2)缺**探针任务集**,这是真正的前置资产,比代码更难。来源优先级:真实事故复现题 > 用户纠正转化题 > 按 skill 领域合成题。因此路线先做 L1。
|
|
108
|
+
|
|
109
|
+
执行隔离:本机用"穷人沙盒"——`mkdtemp` workspace + staging 渲染配置 + 重定向的 agent 配置 HOME + 一次性会话,**沙盒保证每次执行干净可复现,对比本身发生在冻结的产物上,不在沙盒里**。容器(本机有 OrbStack)后置,等探针要跑不可信代码或需并行 K 候选时再上。
|
|
110
|
+
|
|
111
|
+
## 风险分级与护栏
|
|
112
|
+
|
|
113
|
+
分级由**代码按 kind + target 计算**,不信 editor 自报的 `risk_class`:
|
|
114
|
+
|
|
115
|
+
- **Class A**:可逆、零爆炸半径、证据确凿(如补一个 `${VAR}` 名、修复 doctor 已报告的 drift)——仅 `auto-A` 级别且 probe 通过时自动采纳,事后通知。
|
|
116
|
+
- **Class B**:skill/指令内容编辑、MCP 路由变更——永远人批。
|
|
117
|
+
- **Class C**:secrets、auth、受管 agent 集合、用户手写文件、`evolve` 自身配置——校验阶段拒绝,**连提案都不生成**。
|
|
118
|
+
- 无法分类 → 按 Class B。
|
|
119
|
+
|
|
120
|
+
环境式运行比命令式危险得多,必须有的护栏:
|
|
121
|
+
|
|
122
|
+
1. **被进化者不能批准自己的进化**:Class B 批准**只走带外通道**(CLI/GUI),agent 只能展示和提醒。否则传入 `confirm=true` 就能自批,批准形同虚设。
|
|
123
|
+
2. **层级隔离**:信号、提案、verdict、lesson 全部携带 `global | workspace(<root>)` 归属;跨层写入被拒;**单个项目的证据不自动升格为全局规则**(需另一上下文佐证或显式批准);workspace 编辑继承可提交约束(`${VAR}` 引用,绝不写凭据字面值——那个文件 git 跟踪在别人的仓里)。
|
|
124
|
+
3. **隐私**:观察只存元数据,不存 payload,并过 secrets 擦除。
|
|
125
|
+
4. **防自激**:evolver 自己的调用与编辑不进信号源。
|
|
126
|
+
5. **成本与频率**:委派 editor 有预算上限、冷却期、每日提案上限。
|
|
127
|
+
6. **会话中途漂移**:gateway 启动时 pin 住 canonical 版本摘要,采纳只在会话边界生效。
|
|
128
|
+
7. **并发**:信号只追加存储,evolver 作业单实例锁。
|
|
129
|
+
8. **失败收口**:probe / verdict / 选择过程的任何失败一律记为"不采纳",测量失败记 unknown,不当作通过。
|
|
130
|
+
9. **不 bypass 目标 agent 的审批与沙盒**:harness2 在宿主机上用 `--dangerously-bypass-approvals-and-sandbox`,这是研究代码的取舍,**我们明确不采纳**。
|
|
131
|
+
|
|
132
|
+
## 托管侧需要新增的通用扩展点
|
|
133
|
+
|
|
134
|
+
目前 SDK 只导出 `loadCanonicalSource`、`ALL_AGENTS`、`resolveScope` 与类型(只读),`trellis-sdk` spec 还禁止导出 adapters / commands 内部。进化要的能力托管侧都没有公开接口,所以接缝要新建,且必须是**通用扩展点,不带进化字样**:
|
|
135
|
+
|
|
136
|
+
1. **事件流**:会话开始/结束、工具调用已路由(仅元数据)。指标、审计同样可用。
|
|
137
|
+
2. **公开 `host` 子路径**:写入(带备份会话)、回滚、检查。不放开 `src/commands/*` 内部实现,而是新增明确的导出面。
|
|
138
|
+
3. **provider 外部注册约定**:`TrellisProvider` 已是开放接口(tools / resources / prompts / skill 条目 / `close()`),进化作为外部包注册自己的 provider,托管核心无需认识它。
|
|
139
|
+
|
|
140
|
+
"单备份会话写入""probe staging 复现层分裂"这类本属托管的能力,已在整合草案里下沉为 `host` 门面(备份会话内应用/回滚、staging、结构化检查);进化只调用,不拥有。
|
|
141
|
+
|
|
142
|
+
## 从 harness2 学到什么(已对照源码核实)
|
|
143
|
+
|
|
144
|
+
来源:`github.com/google-research/harness2`,本机副本 `~/www/morphix-research/sources/harness2`。
|
|
145
|
+
|
|
146
|
+
| 机制 | 出处 | 我们怎么用 |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| Propose–Probe–Compose,无 ground truth 的行为对比 | `core/proposer.py` `selection.py` | 循环骨架;L2 对比 |
|
|
149
|
+
| 两段式测量(选择与评分隔离) | `core/selection.py` `judge.py` | L2 选择器看不到评分材料 |
|
|
150
|
+
| 失败语义收口(judge 失败 = None;选择器失败 = 回退 base;preflight 带修复命令) | `judge.py` `selection.py` | 全局失败收口原则 |
|
|
151
|
+
| 指纹 + 身份守卫的复用 | `core/runmark.py` | probe 缓存键 = 被测切片 sha1 |
|
|
152
|
+
| 差异即数据(`SurfaceSpec`,frozen dataclass) | `core/tree.py` | "可编辑表面 + 静默失效模式"声明为数据 |
|
|
153
|
+
| 三轴正交 + 一格子一实例 | `core/spec.py` `benchmarks/workbuddy.py` | `HostPort` 与 probe adapter 的形态 |
|
|
154
|
+
| 穷人沙盒 | `substrate/codex.py:149-185` | probe workspace 边界 |
|
|
155
|
+
| 校验错误即 prompt(原样喂回作重试反馈) | `validate_tree` | editor 重试 |
|
|
156
|
+
|
|
157
|
+
**没有可直接用的 SDK**:无 PyPI 包、`harness2/__init__.py` 为空(无公开 API)、7 个 console script、仅 Linux、Python 3.12、绑 Vertex AI 与 Docker、与三个 benchmark 耦合。**移植方法论,不移植代码。**
|
|
158
|
+
|
|
159
|
+
## 已核实 / 未核实
|
|
160
|
+
|
|
161
|
+
| 已核实(读过代码或 spec;位置见 design.md Context) | 未核实 / 假设 |
|
|
162
|
+
|---|---|
|
|
163
|
+
| gateway 无常驻进程,client 断开即释放上游 | 观察元数据是否足以支撑有价值的信号(需真机采样,tasks 1.1) |
|
|
164
|
+
| **所有工具调用(内建与上游)汇入同一咽喉点** `BuiltinRegistry.callTool`,由 `tools/call` handler 调用——事件可在此一处挂载 | skills / instructions provider 是实时读取还是启动时缓存(tasks 1.2),决定"会话快照"是否需要 |
|
|
165
|
+
| `RuntimeContext` 只有 `{agentId, homeDir}`,**无会话标识**——须由事件层生成 | 扩展加载机制与状态目录路径(tasks 1.3) |
|
|
166
|
+
| 关闭路径只处理 stdin `end`/`close` 与 SIGTERM/SIGINT/SIGHUP;**SIGKILL/崩溃不触发 `close()`**——因此必须有"下次启动补偿检查" | 全局记忆未启用时,lesson 回流的实际效果 |
|
|
167
|
+
| `openBackupSession` 与 `collectDoctorReport` 存在,但**都未经 SDK 公开**;SDK 根入口只导出 `loadCanonicalSource`/`ALL_AGENTS`/`resolveScope`,且 spec 禁止导出 adapters/commands 内部 | 带外批准在 agent 有 shell 能力时的强度(TTY 检测只是启发式) |
|
|
168
|
+
| `TrellisProvider` 含 `close()` 与 skill 条目接口 | |
|
|
169
|
+
| harness2 各机制的行为(见上表) | |
|
|
170
|
+
|
|
171
|
+
## 已定倾向 / 待决
|
|
172
|
+
|
|
173
|
+
- **倾向(未最终确认)**:Class B 批准带外为主;后台作业由会话结束拉起而不靠外部调度器;进化先同仓库独立目录;先做 L1。
|
|
174
|
+
- **待决**:用户纠正的采集形态(手工 `note` vs 从会话收割);探针任务集放 canonical 还是 per-workspace;`auto-A` 之前需要多少次有人监督的运行才算"证明不误报";运行档案的轮转策略。
|
|
175
|
+
|
|
176
|
+
## 前置依赖
|
|
177
|
+
|
|
178
|
+
- agent-bridge 还有 5 个未完成项(委托存储、doctor 委托 section、Claude Code blocked、多跳、文档);editor 委派依赖 `runDelegatedCall`,不可用时 L1 可降级为 rule-based 提案。
|
|
179
|
+
- 全局记忆需启用(`trellis onboard --memory on`)才能让 lesson 回流有真实载体;本机目前未启用。
|
|
180
|
+
- 探针任务集(L2 的前置资产)。
|
|
181
|
+
|
|
182
|
+
## 分阶段路线
|
|
183
|
+
|
|
184
|
+
| 阶段 | 内容 | 状态 |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| P0 | doctor oracle、backup/rollback、memory 管道、agent-bridge 委派原语 | 已就位 |
|
|
187
|
+
| P1 | **托管扩展点**(事件流、`host` 子路径、provider 外部注册) | 先做;进化侧一切依赖它 |
|
|
188
|
+
| P2 | 进化 Observe + Propose,仅配置类、人审批 | |
|
|
189
|
+
| P3 | Probe:L1 verdict 差集;`auto-A` | |
|
|
190
|
+
| P4 | 内容类:L2 匿名 A/B,依赖探针任务集;lesson 回流提质 | |
|
|
191
|
+
| P5 | 递归预算(K 候选 parallel / K 步 sequential)、容器化 probe | |
|
|
192
|
+
|
|
193
|
+
## 明确不做
|
|
194
|
+
|
|
195
|
+
- 进化 Trellis 自身代码、secrets、auth、受管 agent 集合、用户手写文件。
|
|
196
|
+
- 让托管层依赖进化(依赖方向永远是进化 → 托管)。
|
|
197
|
+
- 绕过目标 agent 的审批 / 沙盒机制。
|
|
198
|
+
- 在没有现成 oracle 的类别上抢跑自动采纳(内容类必须等探针任务集)。
|
|
199
|
+
|
|
200
|
+
## 下一步
|
|
201
|
+
|
|
202
|
+
已完成:整合为 `openspec/changes/trellis-evolve/`(一份草案、两个 capability:`host-extension-points` 与 `self-evolution`),并已 `openspec validate --strict`;gateway 咽喉点与 `close()` 覆盖范围已核实(见上表)。
|
|
203
|
+
|
|
204
|
+
待做(按 `tasks.md` 顺序):
|
|
205
|
+
|
|
206
|
+
1. **前置验证(第 1 组,只读)**:真机采样元数据信号质量;核实 skills/instructions provider 读取路径;定下扩展加载机制与状态目录路径。
|
|
207
|
+
2. **托管扩展点(第 2 组)**:事件总线、`host` 子路径门面、外部 provider 注册、"托管不依赖消费者"构建测试。纯增量、默认零行为变化,可单独发版。
|
|
208
|
+
3. **进化 `off`/`observe` → `propose` → `auto-A`**(第 3–8 组),`auto-A` 之前须再评估带外批准的强度。
|
|
209
|
+
4. L2 执行、探针任务集、递归预算为后续独立变更。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-trellis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|