agent-trellis 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js 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
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { loadCanonicalSource } from "../core/canonical.js";
8
8
  import type { AgentId, AgentSnapshot, CanonicalSource } from "../core/types.js";
9
+ import { type VersionExecutor } from "../lib/cliEntrypoints.js";
9
10
  import { type WorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
10
11
  /**
11
12
  * Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
@@ -15,7 +16,7 @@ import { type WorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
15
16
  * machine.
16
17
  */
17
18
  export declare const DEFAULT_KNOWN_HOST_INJECTED: readonly string[];
18
- export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable" | "workspace-toml-invalid";
19
+ export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable" | "workspace-toml-invalid" | "cli-version-split";
19
20
  export interface Finding {
20
21
  kind: FindingKind;
21
22
  /** Omitted for a finding that names more than one agent (drift). */
@@ -52,6 +53,10 @@ export interface RunDoctorOptions {
52
53
  * seam. CLI discovery walks up from cwd via `findWorkspaceRoot`;
53
54
  * null = no workspace layer. */
54
55
  workspaceDir?: string | null;
56
+ /** PATH to scan for `trellis` entrypoints; defaults to the real one.
57
+ * Test seam, with `execVersion`. */
58
+ pathEnv?: string;
59
+ execVersion?: VersionExecutor;
55
60
  }
56
61
  export interface OAuthHealthEntry {
57
62
  server: string;
@@ -17,6 +17,7 @@ import { ALL_AGENTS, isOAuthAuth, oauthOwner } from "../core/types.js";
17
17
  import { classifyStoredToken, remediationFor } from "../lib/oauth/renew.js";
18
18
  import { readToken } from "../lib/oauth/store.js";
19
19
  import { findWorkspaceRoot } from "../lib/workspace.js";
20
+ import { detectCliVersionSplit, findCliEntrypoints } from "../lib/cliEntrypoints.js";
20
21
  import { collectWorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
21
22
  /**
22
23
  * Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
@@ -48,6 +49,9 @@ const EXIT_NONZERO_KINDS = new Set([
48
49
  // *agent*'s own startup regardless of ownership — catch it here before
49
50
  // the next session does (trellis-doctor-workspace-toml-validation).
50
51
  "workspace-toml-invalid",
52
+ // A stale `trellis` that wins on PATH silently changes behavior (e.g.
53
+ // rejecting config keys a newer copy accepts) — trellis-doctor-cli-version-split.
54
+ "cli-version-split",
51
55
  ]);
52
56
  /** Exported so tests can assert on this resolution directly, without
53
57
  * routing through real per-agent probes `runDoctor` also runs. */
@@ -66,6 +70,12 @@ export function resolveKnownHostInjected(opts) {
66
70
  export async function runDoctor(opts = {}) {
67
71
  const workspaceDir = opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd());
68
72
  const report = await collectDoctorReport(opts.homeDir, resolveKnownHostInjected(opts), opts.probeMcp, workspaceDir);
73
+ // CLI boundary only: spawning `trellis --version` belongs here, not in
74
+ // collectDoctorReport, so library callers and their tests never depend
75
+ // on the developer's real PATH.
76
+ const split = detectCliVersionSplit(findCliEntrypoints({ pathEnv: opts.pathEnv, execVersion: opts.execVersion }));
77
+ if (split)
78
+ report.findings.push({ kind: "cli-version-split", message: split });
69
79
  const exitCode = report.findings.some((f) => EXIT_NONZERO_KINDS.has(f.kind)) ? 1 : 0;
70
80
  if (opts.json) {
71
81
  console.log(JSON.stringify(report, null, 2));
@@ -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
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Locates every `trellis` executable on PATH and compares their versions
3
+ * (trellis-doctor-cli-version-split). Read-only: it never edits, removes
4
+ * or upgrades an entrypoint — a wrapper script may be deliberately
5
+ * maintained by the user, so Trellis only reports.
6
+ */
7
+ export interface CliEntrypoint {
8
+ path: string;
9
+ version: string;
10
+ }
11
+ export type VersionExecutor = (path: string) => string | undefined;
12
+ /** Runs `<path> --version` via argv (no shell). `undefined` on any
13
+ * failure or unrecognizable output, so a broken entry is skipped rather
14
+ * than reported as a split. */
15
+ export declare const defaultExecVersion: VersionExecutor;
16
+ export declare function findCliEntrypoints(opts?: {
17
+ pathEnv?: string;
18
+ execVersion?: VersionExecutor;
19
+ name?: string;
20
+ }): CliEntrypoint[];
21
+ /** A finding message when two or more entries disagree; `undefined`
22
+ * otherwise. The first entry is the one a bare `trellis` runs. */
23
+ export declare function detectCliVersionSplit(entries: readonly CliEntrypoint[]): string | undefined;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Locates every `trellis` executable on PATH and compares their versions
3
+ * (trellis-doctor-cli-version-split). Read-only: it never edits, removes
4
+ * or upgrades an entrypoint — a wrapper script may be deliberately
5
+ * maintained by the user, so Trellis only reports.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
9
+ import { delimiter, join } from "node:path";
10
+ const VERSION_RE = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/;
11
+ /** Runs `<path> --version` via argv (no shell). `undefined` on any
12
+ * failure or unrecognizable output, so a broken entry is skipped rather
13
+ * than reported as a split. */
14
+ export const defaultExecVersion = (path) => {
15
+ try {
16
+ const out = execFileSync(path, ["--version"], { encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] });
17
+ return VERSION_RE.exec(out)?.[0];
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ };
23
+ function isExecutableFile(path) {
24
+ try {
25
+ if (!statSync(path).isFile())
26
+ return false;
27
+ accessSync(path, constants.X_OK);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ export function findCliEntrypoints(opts = {}) {
35
+ const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
36
+ const execVersion = opts.execVersion ?? defaultExecVersion;
37
+ const name = opts.name ?? "trellis";
38
+ const seenDirs = new Set();
39
+ const seenReal = new Set();
40
+ const entries = [];
41
+ for (const dir of pathEnv.split(delimiter)) {
42
+ if (!dir || seenDirs.has(dir))
43
+ continue;
44
+ seenDirs.add(dir);
45
+ const candidate = join(dir, name);
46
+ if (!isExecutableFile(candidate))
47
+ continue;
48
+ let real;
49
+ try {
50
+ real = realpathSync(candidate);
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ if (seenReal.has(real))
56
+ continue;
57
+ seenReal.add(real);
58
+ // Spawn the PATH-visible path, not the resolved target: a shim's
59
+ // behavior depends on the name it is invoked by.
60
+ const version = execVersion(candidate);
61
+ if (version)
62
+ entries.push({ path: candidate, version });
63
+ }
64
+ return entries;
65
+ }
66
+ /** A finding message when two or more entries disagree; `undefined`
67
+ * otherwise. The first entry is the one a bare `trellis` runs. */
68
+ export function detectCliVersionSplit(entries) {
69
+ if (entries.length < 2)
70
+ return undefined;
71
+ if (new Set(entries.map((e) => e.version)).size < 2)
72
+ return undefined;
73
+ const lines = entries.map((e, i) => ` ${i === 0 ? "▶ runs" : " also"} ${e.path} ${e.version}`);
74
+ return [
75
+ `${entries.length} \`trellis\` entrypoints on PATH report different versions — a bare \`trellis\` runs ${entries[0].path} (${entries[0].version}), so upgrading another copy has no effect:`,
76
+ ...lines,
77
+ "Reconcile them (upgrade or remove the stale one, or reorder PATH) — Trellis does not modify entrypoints.",
78
+ ].join("\n");
79
+ }
@@ -19,6 +19,8 @@ export interface RefreshOptions {
19
19
  lockTimeoutMs?: number;
20
20
  }
21
21
  export declare class RefreshError extends Error {
22
+ readonly oauthError?: string | undefined;
23
+ constructor(message: string, oauthError?: string | undefined);
22
24
  }
23
25
  /**
24
26
  * Returns a token that is valid now, refreshing first if it isn't.
@@ -11,6 +11,11 @@
11
11
  import { isExpired, readToken, writeToken } from "./store.js";
12
12
  import { withServerLock } from "./lock.js";
13
13
  export class RefreshError extends Error {
14
+ oauthError;
15
+ constructor(message, oauthError) {
16
+ super(message);
17
+ this.oauthError = oauthError;
18
+ }
14
19
  }
15
20
  /**
16
21
  * Returns a token that is valid now, refreshing first if it isn't.
@@ -67,7 +72,17 @@ export async function performRefresh(serverName, token, opts = {}) {
67
72
  body: body.toString(),
68
73
  });
69
74
  if (!response.ok) {
70
- throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${await safeText(response)}`);
75
+ const detail = await safeText(response);
76
+ let oauthError;
77
+ try {
78
+ const payload = JSON.parse(detail);
79
+ if (typeof payload?.error === "string")
80
+ oauthError = payload.error;
81
+ }
82
+ catch {
83
+ // Providers may return plain text or an empty body.
84
+ }
85
+ throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${detail.slice(0, 200)}`, oauthError);
71
86
  }
72
87
  const payload = (await response.json());
73
88
  if (!payload.access_token) {
@@ -87,7 +102,7 @@ export async function performRefresh(serverName, token, opts = {}) {
87
102
  }
88
103
  async function safeText(response) {
89
104
  try {
90
- return (await response.text()).slice(0, 200);
105
+ return await response.text();
91
106
  }
92
107
  catch {
93
108
  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";
@@ -46200,6 +46200,11 @@ async function withServerLock(homeDir, serverName, fn, opts = {}) {
46200
46200
 
46201
46201
  // src/lib/oauth/refresh.ts
46202
46202
  var RefreshError = class extends Error {
46203
+ constructor(message, oauthError) {
46204
+ super(message);
46205
+ this.oauthError = oauthError;
46206
+ }
46207
+ oauthError;
46203
46208
  };
46204
46209
  async function ensureFreshToken(homeDir, serverName, opts = {}) {
46205
46210
  const now = opts.now ?? Date.now;
@@ -46242,7 +46247,14 @@ async function performRefresh(serverName, token, opts = {}) {
46242
46247
  body: body.toString()
46243
46248
  });
46244
46249
  if (!response.ok) {
46245
- throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${await safeText(response)}`);
46250
+ const detail = await safeText(response);
46251
+ let oauthError;
46252
+ try {
46253
+ const payload2 = JSON.parse(detail);
46254
+ if (typeof payload2?.error === "string") oauthError = payload2.error;
46255
+ } catch {
46256
+ }
46257
+ throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${detail.slice(0, 200)}`, oauthError);
46246
46258
  }
46247
46259
  const payload = await response.json();
46248
46260
  if (!payload.access_token) {
@@ -46262,7 +46274,7 @@ async function performRefresh(serverName, token, opts = {}) {
46262
46274
  }
46263
46275
  async function safeText(response) {
46264
46276
  try {
46265
- return (await response.text()).slice(0, 200);
46277
+ return await response.text();
46266
46278
  } catch {
46267
46279
  return "<unreadable body>";
46268
46280
  }
@@ -6,6 +6,16 @@ on a schedule, it refreshes whatever is expiring and reports — with a
6
6
  non-zero exit code — anything that needs a human, before an Agent session
7
7
  discovers it as a 401.
8
8
 
9
+ When a refresh token has expired, been revoked, or been rotated out, the
10
+ provider can reject renewal with OAuth `invalid_grant`. Run
11
+ `trellis mcp auth <name>`: it attempts a silent refresh first, then starts
12
+ a new browser login if the grant is no longer usable. No `--force` is
13
+ required. Network failures, rate limits, and temporary provider failures
14
+ remain errors to retry; they do not trigger another login. The prior
15
+ credential is replaced only after the new authorization succeeds, so
16
+ cancelling login preserves the existing file. Background renewal and the
17
+ gateway report the failure and never open a browser.
18
+
9
19
  ```
10
20
  trellis mcp renew # refresh what's expiring, report health
11
21
  trellis mcp renew --dry-run # report only; no token-endpoint call
package/docs/roadmap.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Roadmap
2
2
 
3
- *Current release: v0.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",