agent-trellis 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -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. The gateway
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
  }
@@ -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(opts);
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
- // Expired but renewable — no browser needed.
76
- const refreshed = await ensureFreshToken(homeDir, opts.serverName);
77
- if (refreshed) {
78
- return { server: opts.serverName, status: "refreshed", tokenFile: file, ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}) };
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
  }
@@ -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. */
@@ -103,7 +104,22 @@ export declare class LocalBackend implements GatewayBackend {
103
104
  listStatus(): readonly UpstreamStatus[];
104
105
  close(): Promise<void>;
105
106
  private closeUpstream;
107
+ /** A dead grant surfaces mid-session: this upstream's calls now fail,
108
+ * but its tools stay registered and every other upstream keeps serving.
109
+ * The credential resolves per request, so the human's re-authorization
110
+ * takes effect on the next call — no session restart required. */
111
+ private markCredentialFailure;
112
+ private restoreIfAuthRequired;
106
113
  }
114
+ /**
115
+ * Whether the gateway manages the OAuth credential for this definition:
116
+ * Trellis-owned OAuth, and no explicit `Authorization` header — someone
117
+ * who wrote one in canonical meant it, and silently overriding it with a
118
+ * stored token would be the kind of invisible precedence that is
119
+ * impossible to debug. Both `withOAuthHeader` and the per-request
120
+ * `AuthProvider` attach on exactly this predicate.
121
+ */
122
+ export declare function oauthAttachApplies(def: McpServerDef): boolean;
107
123
  /**
108
124
  * Attaches a stored OAuth token, refreshing it first if it has expired.
109
125
  *
@@ -112,8 +128,37 @@ export declare class LocalBackend implements GatewayBackend {
112
128
  * OAuth at all, or one whose credential lives in `headers` already, and
113
129
  * neither should be disturbed.
114
130
  *
115
- * An explicit `Authorization` header in canonical wins. Someone who wrote
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.
131
+ * An explicit `Authorization` header in canonical wins.
118
132
  */
119
133
  export declare function withOAuthHeader(name: string, def: McpServerDef, homeDir: string): Promise<McpServerDef>;
134
+ /**
135
+ * The early-refresh window on the gateway's per-request path. Minute-scale
136
+ * on purpose: a request that passes the freshness check must not carry a
137
+ * token that dies mid-flight. `store.ts`'s 60s default is untouched —
138
+ * `mcp auth`/`mcp renew` don't have this problem.
139
+ */
140
+ export declare const GATEWAY_REFRESH_SKEW_MS = 300000;
141
+ export interface TrellisAuthProviderOptions {
142
+ homeDir: string;
143
+ serverName: string;
144
+ /** Test seam; defaults to the gateway window. */
145
+ skewMs?: number;
146
+ fetchImpl?: FetchLike;
147
+ now?: () => number;
148
+ /** Fired when pre-request resolution produced a credential while this
149
+ * server sat in auth-required — the backend restores its status. */
150
+ onCredentialRecovered?: () => void;
151
+ /** Fired when the 401-forced refresh failed — the backend marks this
152
+ * server auth-required with the OAuth remediation. */
153
+ onCredentialFailure?: (err: unknown) => void;
154
+ }
155
+ /**
156
+ * The SDK `AuthProvider` for one Trellis-owned OAuth upstream: `token()`
157
+ * runs as each outbound request's headers are assembled, so credentials
158
+ * resolve per request instead of once at connect time.
159
+ *
160
+ * Every read goes back to the token file with no memory cache — that is
161
+ * what makes a refresh by `trellis mcp auth` in another terminal take
162
+ * effect on the very next request of a running gateway (D6).
163
+ */
164
+ export declare function trellisAuthProvider(opts: TrellisAuthProviderOptions): AuthProvider;
@@ -18,7 +18,7 @@
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
24
  import { UpstreamResources } from "./upstreamResources.js";
@@ -65,9 +65,22 @@ export class LocalBackend {
65
65
  return;
66
66
  }
67
67
  }
68
+ // Per-request credential resolution: the SDK calls token() as each
69
+ // request's headers are assembled, so a token refreshed here — by this
70
+ // process, or written to disk by `trellis mcp auth` in another
71
+ // terminal — affects the very next request, no restart. The static
72
+ // header from withOAuthHeader stays as the connect-time fallback.
73
+ const authProvider = options.homeDir && (transport === "http" || transport === "sse") && oauthAttachApplies(def)
74
+ ? trellisAuthProvider({
75
+ homeDir: options.homeDir,
76
+ serverName: name,
77
+ onCredentialRecovered: () => this.restoreIfAuthRequired(name),
78
+ onCredentialFailure: (err) => this.markCredentialFailure(name, transport, err),
79
+ })
80
+ : undefined;
68
81
  let client;
69
82
  try {
70
- client = await connectServer(effectiveDef, options.secretsPolicy, options.connectTimeoutMs, options.clientInfo);
83
+ client = await connectServer(effectiveDef, options.secretsPolicy, options.connectTimeoutMs, options.clientInfo, authProvider);
71
84
  }
72
85
  catch (err) {
73
86
  this.warn(`trellis-mcp-gateway: failed to connect to MCP server "${name}": ${errorText(err)}`);
@@ -141,6 +154,39 @@ export class LocalBackend {
141
154
  this.warn(`trellis-mcp-gateway: failed to close MCP client: ${errorText(err)}`);
142
155
  }
143
156
  }
157
+ /** A dead grant surfaces mid-session: this upstream's calls now fail,
158
+ * but its tools stay registered and every other upstream keeps serving.
159
+ * The credential resolves per request, so the human's re-authorization
160
+ * takes effect on the next call — no session restart required. */
161
+ markCredentialFailure(name, transport, err) {
162
+ const previous = this.statuses.get(name);
163
+ this.statuses.set(name, {
164
+ name,
165
+ transport,
166
+ status: "auth-required",
167
+ toolCount: previous?.toolCount,
168
+ detail: errorText(err).slice(0, 300),
169
+ remediationKind: "oauth",
170
+ requiresHuman: true,
171
+ requiresSessionRestart: false,
172
+ remediation: `run \`trellis mcp auth ${name}\` in a human terminal — the running gateway picks the new credential up on its next request`,
173
+ });
174
+ this.warn(`trellis-mcp-gateway: MCP server "${name}" needs re-authorization: ${errorText(err)}`);
175
+ }
176
+ restoreIfAuthRequired(name) {
177
+ const current = this.statuses.get(name);
178
+ if (!current || current.status !== "auth-required")
179
+ return;
180
+ this.statuses.set(name, {
181
+ ...current,
182
+ status: "ready",
183
+ detail: `credential recovered; ${current.toolCount ?? 0} tool(s) available`,
184
+ remediation: undefined,
185
+ remediationKind: undefined,
186
+ requiresHuman: undefined,
187
+ requiresSessionRestart: undefined,
188
+ });
189
+ }
144
190
  }
145
191
  function failureStatus(name, def, error) {
146
192
  const detail = errorText(error).slice(0, 300);
@@ -189,6 +235,18 @@ function failureStatus(name, def, error) {
189
235
  : `check that \`${def.command ?? "the MCP command"}\` is installed and responsive`,
190
236
  };
191
237
  }
238
+ /**
239
+ * Whether the gateway manages the OAuth credential for this definition:
240
+ * Trellis-owned OAuth, and no explicit `Authorization` header — someone
241
+ * who wrote one in canonical meant it, and silently overriding it with a
242
+ * stored token would be the kind of invisible precedence that is
243
+ * impossible to debug. Both `withOAuthHeader` and the per-request
244
+ * `AuthProvider` attach on exactly this predicate.
245
+ */
246
+ export function oauthAttachApplies(def) {
247
+ return isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis"
248
+ && !Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
249
+ }
192
250
  /**
193
251
  * Attaches a stored OAuth token, refreshing it first if it has expired.
194
252
  *
@@ -197,15 +255,10 @@ function failureStatus(name, def, error) {
197
255
  * OAuth at all, or one whose credential lives in `headers` already, and
198
256
  * neither should be disturbed.
199
257
  *
200
- * An explicit `Authorization` header in canonical wins. Someone who wrote
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.
258
+ * An explicit `Authorization` header in canonical wins.
203
259
  */
204
260
  export async function withOAuthHeader(name, def, homeDir) {
205
- if (!isOAuthAuth(def.auth) || oauthOwner(def.auth) !== "trellis")
206
- return def;
207
- const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
208
- if (hasExplicitAuth)
261
+ if (!oauthAttachApplies(def))
209
262
  return def;
210
263
  const stored = readToken(homeDir, name);
211
264
  const resource = new URL(def.url);
@@ -217,6 +270,68 @@ export async function withOAuthHeader(name, def, homeDir) {
217
270
  return def;
218
271
  return { ...def, headers: { ...(def.headers ?? {}), Authorization: `Bearer ${token.accessToken}` } };
219
272
  }
273
+ /**
274
+ * The early-refresh window on the gateway's per-request path. Minute-scale
275
+ * on purpose: a request that passes the freshness check must not carry a
276
+ * token that dies mid-flight. `store.ts`'s 60s default is untouched —
277
+ * `mcp auth`/`mcp renew` don't have this problem.
278
+ */
279
+ export const GATEWAY_REFRESH_SKEW_MS = 300_000;
280
+ /**
281
+ * The SDK `AuthProvider` for one Trellis-owned OAuth upstream: `token()`
282
+ * runs as each outbound request's headers are assembled, so credentials
283
+ * resolve per request instead of once at connect time.
284
+ *
285
+ * Every read goes back to the token file with no memory cache — that is
286
+ * what makes a refresh by `trellis mcp auth` in another terminal take
287
+ * effect on the very next request of a running gateway (D6).
288
+ */
289
+ export function trellisAuthProvider(opts) {
290
+ let lastIssued;
291
+ return {
292
+ token: async () => {
293
+ try {
294
+ const token = await ensureFreshToken(opts.homeDir, opts.serverName, {
295
+ skewMs: opts.skewMs ?? GATEWAY_REFRESH_SKEW_MS,
296
+ fetchImpl: opts.fetchImpl,
297
+ now: opts.now,
298
+ });
299
+ if (!token)
300
+ return undefined;
301
+ lastIssued = token.accessToken;
302
+ opts.onCredentialRecovered?.();
303
+ return token.accessToken;
304
+ }
305
+ catch {
306
+ // The pre-request credential check must never abort the request
307
+ // pipeline (D3): send whatever is stored — possibly stale — and
308
+ // let the server's 401 path decide the outcome.
309
+ return readToken(opts.homeDir, opts.serverName)?.accessToken;
310
+ }
311
+ },
312
+ onUnauthorized: async () => {
313
+ // The server just rejected a token that looked fine locally; the
314
+ // expiry check is exactly what must be bypassed. The transport
315
+ // retries the request once after this resolves — and when this
316
+ // throws instead, the SDK propagates the error to this one call
317
+ // as-is (pinned by test), so the message names the way out.
318
+ try {
319
+ await forceRefresh(opts.homeDir, opts.serverName, {
320
+ previousAccessToken: lastIssued,
321
+ fetchImpl: opts.fetchImpl,
322
+ now: opts.now,
323
+ });
324
+ }
325
+ catch (err) {
326
+ opts.onCredentialFailure?.(err);
327
+ const base = err instanceof Error ? err.message : String(err);
328
+ throw new RefreshError(base.includes("trellis mcp auth")
329
+ ? base
330
+ : `${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);
331
+ }
332
+ },
333
+ };
334
+ }
220
335
  function errorText(err) {
221
336
  return err instanceof Error ? err.message : String(err);
222
337
  }
@@ -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>;
@@ -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
- const opts = headers ? { requestInit: { headers } } : undefined;
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 = headers ? { requestInit: { headers } } : undefined;
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
  }
@@ -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.
@@ -31,4 +33,19 @@ export declare class RefreshError extends Error {
31
33
  * would hide a revoked grant.
32
34
  */
33
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>;
34
51
  export declare function performRefresh(serverName: string, token: StoredToken, opts?: RefreshOptions): Promise<StoredToken>;
@@ -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.
@@ -48,6 +53,36 @@ export async function ensureFreshToken(homeDir, serverName, opts = {}) {
48
53
  return refreshed;
49
54
  }, { timeoutMs: opts.lockTimeoutMs, now });
50
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
+ }
51
86
  export async function performRefresh(serverName, token, opts = {}) {
52
87
  const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000) }));
53
88
  const now = opts.now ?? Date.now;
@@ -67,7 +102,17 @@ export async function performRefresh(serverName, token, opts = {}) {
67
102
  body: body.toString(),
68
103
  });
69
104
  if (!response.ok) {
70
- throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${await safeText(response)}`);
105
+ const detail = await safeText(response);
106
+ let oauthError;
107
+ try {
108
+ const payload = JSON.parse(detail);
109
+ if (typeof payload?.error === "string")
110
+ oauthError = payload.error;
111
+ }
112
+ catch {
113
+ // Providers may return plain text or an empty body.
114
+ }
115
+ throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${detail.slice(0, 200)}`, oauthError);
71
116
  }
72
117
  const payload = (await response.json());
73
118
  if (!payload.access_token) {
@@ -87,7 +132,7 @@ export async function performRefresh(serverName, token, opts = {}) {
87
132
  }
88
133
  async function safeText(response) {
89
134
  try {
90
- return (await response.text()).slice(0, 200);
135
+ return await response.text();
91
136
  }
92
137
  catch {
93
138
  return "<unreadable body>";
@@ -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";
@@ -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 = headers ? { requestInit: { headers } } : void 0;
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 = headers ? { requestInit: { headers } } : void 0;
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
 
@@ -46200,6 +46206,11 @@ async function withServerLock(homeDir, serverName, fn, opts = {}) {
46200
46206
 
46201
46207
  // src/lib/oauth/refresh.ts
46202
46208
  var RefreshError = class extends Error {
46209
+ constructor(message, oauthError) {
46210
+ super(message);
46211
+ this.oauthError = oauthError;
46212
+ }
46213
+ oauthError;
46203
46214
  };
46204
46215
  async function ensureFreshToken(homeDir, serverName, opts = {}) {
46205
46216
  const now = opts.now ?? Date.now;
@@ -46242,7 +46253,14 @@ async function performRefresh(serverName, token, opts = {}) {
46242
46253
  body: body.toString()
46243
46254
  });
46244
46255
  if (!response.ok) {
46245
- throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${await safeText(response)}`);
46256
+ const detail = await safeText(response);
46257
+ let oauthError;
46258
+ try {
46259
+ const payload2 = JSON.parse(detail);
46260
+ if (typeof payload2?.error === "string") oauthError = payload2.error;
46261
+ } catch {
46262
+ }
46263
+ throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${detail.slice(0, 200)}`, oauthError);
46246
46264
  }
46247
46265
  const payload = await response.json();
46248
46266
  if (!payload.access_token) {
@@ -46262,7 +46280,7 @@ async function performRefresh(serverName, token, opts = {}) {
46262
46280
  }
46263
46281
  async function safeText(response) {
46264
46282
  try {
46265
- return (await response.text()).slice(0, 200);
46283
+ return await response.text();
46266
46284
  } catch {
46267
46285
  return "<unreadable body>";
46268
46286
  }
@@ -46281,10 +46299,11 @@ var listSchema = external_exports.looseObject({ skills: external_exports.array(e
46281
46299
  var getSchema = external_exports.looseObject({ skill: external_exports.unknown() });
46282
46300
 
46283
46301
  // src/lib/gatewayBackend.ts
46302
+ function oauthAttachApplies(def) {
46303
+ return isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis" && !Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
46304
+ }
46284
46305
  async function withOAuthHeader(name, def, homeDir) {
46285
- if (!isOAuthAuth(def.auth) || oauthOwner(def.auth) !== "trellis") return def;
46286
- const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
46287
- if (hasExplicitAuth) return def;
46306
+ if (!oauthAttachApplies(def)) return def;
46288
46307
  const stored = readToken(homeDir, name);
46289
46308
  const resource = new URL(def.url);
46290
46309
  resource.hash = "";
@@ -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.14.1 (2026-10-11). This file tracks phase-level scope;
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
@@ -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,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.17.0",
3
+ "version": "0.18.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",