agent-trellis 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/pi.d.ts +46 -7
- package/dist/adapters/pi.js +151 -8
- package/dist/adapters/piMcp.d.ts +35 -0
- package/dist/adapters/piMcp.js +58 -0
- package/dist/cli.js +17 -1
- package/dist/commands/doctor.d.ts +22 -2
- package/dist/commands/doctor.js +51 -3
- package/dist/commands/mcp.js +6 -0
- package/dist/commands/mcpGateway.d.ts +1 -0
- package/dist/commands/mcpGateway.js +32 -4
- package/dist/commands/mcpRenew.d.ts +52 -0
- package/dist/commands/mcpRenew.js +104 -0
- package/dist/lib/gatewayBackend.d.ts +5 -0
- package/dist/lib/gatewayBackend.js +2 -1
- package/dist/lib/mcpStatusProvider.d.ts +35 -1
- package/dist/lib/mcpStatusProvider.js +27 -4
- package/dist/lib/oauth/callbackPage.d.ts +11 -0
- package/dist/lib/oauth/callbackPage.js +89 -0
- package/dist/lib/oauth/flow.js +12 -9
- package/dist/lib/oauth/renew.d.ts +75 -0
- package/dist/lib/oauth/renew.js +86 -0
- package/dist/probes/pi.d.ts +7 -4
- package/dist/probes/pi.js +16 -11
- package/docs/getting-started.md +31 -0
- package/docs/mcp-authorization-ownership.md +22 -3
- package/docs/mcp-oauth-renewal.md +89 -0
- package/docs/research.md +7 -4
- package/package.json +1 -1
package/dist/adapters/pi.d.ts
CHANGED
|
@@ -3,22 +3,61 @@
|
|
|
3
3
|
* symlinked to canonical `agents.md` — specifically `AGENTS.md`, not
|
|
4
4
|
* `AGENTS.override.md` (design.md D3, trellis-sync-p1: pi checks the
|
|
5
5
|
* override name first, and Trellis's managed file is the baseline, not an
|
|
6
|
-
* override of something else).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* override of something else).
|
|
7
|
+
*
|
|
8
|
+
* MCP delivery is version-gated (trellis-pi-native-mcp-migration):
|
|
9
|
+
*
|
|
10
|
+
* - pi >= 1.1.0 has a native MCP client, so `trellis mcp sync` merges
|
|
11
|
+
* Trellis-managed entries into `~/.pi/agent/mcp.json` through the shared
|
|
12
|
+
* `planJsonMcp`/`applyJsonMcp` machinery (ownership ledger, conflict
|
|
13
|
+
* detection, user entries preserved) with the pi-shaped renderer from
|
|
14
|
+
* `piMcp.ts`. Gateway route → the single `trellis` runtime entry;
|
|
15
|
+
* agent-owned OAuth servers → bare `{url}` entries the user authorizes
|
|
16
|
+
* with `pi mcp login`.
|
|
17
|
+
* - Older pis (or an unprobeable version) keep the legacy delivery: a
|
|
18
|
+
* single symlinked bridge extension (`dist/pi-bridge/bundle.js`,
|
|
19
|
+
* trellis-pi-mcp-bridge-p4). The version gate never moves a machine
|
|
20
|
+
* FROM the bridge TO native until the mcp.json write succeeds — the
|
|
21
|
+
* bridge-removal item rides along in the same plan, ordered after the
|
|
22
|
+
* config write, and is withheld entirely when any write conflicts
|
|
23
|
+
* (design.md D3: the two deliveries may never be active at once, every
|
|
24
|
+
* tool would register twice).
|
|
11
25
|
*/
|
|
12
26
|
import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
|
|
13
27
|
import type { CanonicalSource } from "../core/types.js";
|
|
14
|
-
import type
|
|
28
|
+
import { type BackupSession } from "../lib/backup.js";
|
|
15
29
|
export declare class PiAdapter implements TrellisAdapter {
|
|
16
30
|
private readonly homeDir;
|
|
31
|
+
/** Test seam: the version gate must not depend on whatever pi happens
|
|
32
|
+
* to be installed on the machine running the tests. */
|
|
33
|
+
private readonly versionProbe;
|
|
17
34
|
readonly name = "pi";
|
|
18
35
|
readonly id: "pi";
|
|
19
|
-
constructor(homeDir?: string
|
|
36
|
+
constructor(homeDir?: string,
|
|
37
|
+
/** Test seam: the version gate must not depend on whatever pi happens
|
|
38
|
+
* to be installed on the machine running the tests. */
|
|
39
|
+
versionProbe?: () => string | undefined);
|
|
20
40
|
probe(): Promise<AdapterProbeResult>;
|
|
21
41
|
plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Native-delivery plan (design.md D1/D3). The write items come from the
|
|
44
|
+
* shared JSON machinery unchanged; the bridge-removal item is appended
|
|
45
|
+
* last and re-cast to kind "mcp" so it travels through `trellis mcp
|
|
46
|
+
* sync` — the same command that writes mcp.json — never through bare
|
|
47
|
+
* `trellis sync`. That is what makes the migration atomic from the
|
|
48
|
+
* user's point of view: the config lands and the bridge leaves in one
|
|
49
|
+
* apply, and a bare skills/instructions sync can neither remove the
|
|
50
|
+
* bridge early nor resurrect it afterwards.
|
|
51
|
+
*/
|
|
52
|
+
private planNativeMcp;
|
|
53
|
+
/**
|
|
54
|
+
* Removal ownership for the bridge, mirroring `planSymlinks`' rule: the
|
|
55
|
+
* raw readlink target must resolve under this installation's
|
|
56
|
+
* `dist/pi-bridge/` directory, or be a byte-identical bundle at the
|
|
57
|
+
* Trellis artifact shape left by a previous install root. Anything else
|
|
58
|
+
* is the user's and stays.
|
|
59
|
+
*/
|
|
60
|
+
private isOwnedBridgeSymlink;
|
|
22
61
|
apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
23
62
|
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
24
63
|
}
|
package/dist/adapters/pi.js
CHANGED
|
@@ -3,20 +3,39 @@
|
|
|
3
3
|
* symlinked to canonical `agents.md` — specifically `AGENTS.md`, not
|
|
4
4
|
* `AGENTS.override.md` (design.md D3, trellis-sync-p1: pi checks the
|
|
5
5
|
* override name first, and Trellis's managed file is the baseline, not an
|
|
6
|
-
* override of something else).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* override of something else).
|
|
7
|
+
*
|
|
8
|
+
* MCP delivery is version-gated (trellis-pi-native-mcp-migration):
|
|
9
|
+
*
|
|
10
|
+
* - pi >= 1.1.0 has a native MCP client, so `trellis mcp sync` merges
|
|
11
|
+
* Trellis-managed entries into `~/.pi/agent/mcp.json` through the shared
|
|
12
|
+
* `planJsonMcp`/`applyJsonMcp` machinery (ownership ledger, conflict
|
|
13
|
+
* detection, user entries preserved) with the pi-shaped renderer from
|
|
14
|
+
* `piMcp.ts`. Gateway route → the single `trellis` runtime entry;
|
|
15
|
+
* agent-owned OAuth servers → bare `{url}` entries the user authorizes
|
|
16
|
+
* with `pi mcp login`.
|
|
17
|
+
* - Older pis (or an unprobeable version) keep the legacy delivery: a
|
|
18
|
+
* single symlinked bridge extension (`dist/pi-bridge/bundle.js`,
|
|
19
|
+
* trellis-pi-mcp-bridge-p4). The version gate never moves a machine
|
|
20
|
+
* FROM the bridge TO native until the mcp.json write succeeds — the
|
|
21
|
+
* bridge-removal item rides along in the same plan, ordered after the
|
|
22
|
+
* config write, and is withheld entirely when any write conflicts
|
|
23
|
+
* (design.md D3: the two deliveries may never be active at once, every
|
|
24
|
+
* tool would register twice).
|
|
11
25
|
*/
|
|
26
|
+
import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
|
|
12
27
|
import { dirname, join, sep } from "node:path";
|
|
13
28
|
import { fileURLToPath } from "node:url";
|
|
14
29
|
import { homedir } from "node:os";
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
30
|
import { isInScope } from "../core/adapter.js";
|
|
17
31
|
import { usesNativeCapabilityDelivery } from "../core/types.js";
|
|
18
32
|
import * as piProbe from "../probes/pi.js";
|
|
33
|
+
import { probePiVersion } from "../probes/pi.js";
|
|
19
34
|
import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
|
|
35
|
+
import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
|
|
36
|
+
import { piRenderer, supportsNativeMcp } from "./piMcp.js";
|
|
37
|
+
import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
|
|
38
|
+
import { currentLinkTarget } from "../lib/backup.js";
|
|
20
39
|
const BRIDGE_SYMLINK_NAME = "trellis-mcp-bridge.js";
|
|
21
40
|
/**
|
|
22
41
|
* A source-checkout bridge can survive a switch to the packaged CLI. Adopt it
|
|
@@ -61,10 +80,15 @@ function resolveBridgeFile() {
|
|
|
61
80
|
}
|
|
62
81
|
export class PiAdapter {
|
|
63
82
|
homeDir;
|
|
83
|
+
versionProbe;
|
|
64
84
|
name = "pi";
|
|
65
85
|
id = "pi";
|
|
66
|
-
constructor(homeDir = homedir()
|
|
86
|
+
constructor(homeDir = homedir(),
|
|
87
|
+
/** Test seam: the version gate must not depend on whatever pi happens
|
|
88
|
+
* to be installed on the machine running the tests. */
|
|
89
|
+
versionProbe = probePiVersion) {
|
|
67
90
|
this.homeDir = homeDir;
|
|
91
|
+
this.versionProbe = versionProbe;
|
|
68
92
|
}
|
|
69
93
|
async probe() {
|
|
70
94
|
const snapshot = await piProbe.probe(this.homeDir);
|
|
@@ -88,6 +112,9 @@ export class PiAdapter {
|
|
|
88
112
|
canonicalRoot,
|
|
89
113
|
kind: "instructions",
|
|
90
114
|
});
|
|
115
|
+
if (supportsNativeMcp(this.versionProbe())) {
|
|
116
|
+
return [...skillItems, ...instructionsItems, ...this.planNativeMcp(canonical)];
|
|
117
|
+
}
|
|
91
118
|
const { file: bridgeFile, symlinkName } = resolveBridgeFile();
|
|
92
119
|
const extensionItems = planSymlinks({
|
|
93
120
|
rootDir: join(agentDir, "extensions"),
|
|
@@ -101,8 +128,104 @@ export class PiAdapter {
|
|
|
101
128
|
});
|
|
102
129
|
return [...skillItems, ...instructionsItems, ...extensionItems];
|
|
103
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Native-delivery plan (design.md D1/D3). The write items come from the
|
|
133
|
+
* shared JSON machinery unchanged; the bridge-removal item is appended
|
|
134
|
+
* last and re-cast to kind "mcp" so it travels through `trellis mcp
|
|
135
|
+
* sync` — the same command that writes mcp.json — never through bare
|
|
136
|
+
* `trellis sync`. That is what makes the migration atomic from the
|
|
137
|
+
* user's point of view: the config lands and the bridge leaves in one
|
|
138
|
+
* apply, and a bare skills/instructions sync can neither remove the
|
|
139
|
+
* bridge early nor resurrect it afterwards.
|
|
140
|
+
*/
|
|
141
|
+
planNativeMcp(canonical) {
|
|
142
|
+
const agentDir = join(this.homeDir, ".pi", "agent");
|
|
143
|
+
const configPath = join(agentDir, "mcp.json");
|
|
144
|
+
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
145
|
+
const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
|
|
146
|
+
const items = planJsonMcp({
|
|
147
|
+
configPath,
|
|
148
|
+
parsed,
|
|
149
|
+
mcp: canonical.mcp,
|
|
150
|
+
agentId: this.id,
|
|
151
|
+
managedAgents: canonical.managedAgents,
|
|
152
|
+
policy: canonical.secretsPolicy,
|
|
153
|
+
ownership,
|
|
154
|
+
render: piRenderer,
|
|
155
|
+
}).map((item) => {
|
|
156
|
+
// pi speaks stdio and streamable HTTP only — an sse server gets an
|
|
157
|
+
// honest conflict instead of an entry pi could never connect.
|
|
158
|
+
const write = item.mcpWrite;
|
|
159
|
+
if (write?.def.transport !== "sse")
|
|
160
|
+
return item;
|
|
161
|
+
return {
|
|
162
|
+
action: "conflict",
|
|
163
|
+
kind: "mcp",
|
|
164
|
+
target: item.target,
|
|
165
|
+
description: `MCP server "${write.name}" uses the sse transport, which pi does not support (stdio and streamable HTTP only) — no entry written to ${item.target}`,
|
|
166
|
+
remediation: "switch the server to streamable HTTP or stdio, or scope it away from pi",
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
const bridgePath = join(agentDir, "extensions", BRIDGE_SYMLINK_NAME);
|
|
170
|
+
const hasConflict = items.some((item) => item.action === "conflict");
|
|
171
|
+
if (!hasConflict && this.isOwnedBridgeSymlink(bridgePath)) {
|
|
172
|
+
items.push({
|
|
173
|
+
action: "remove",
|
|
174
|
+
kind: "mcp",
|
|
175
|
+
target: bridgePath,
|
|
176
|
+
description: `remove Trellis MCP bridge extension ${bridgePath} — pi >= 1.1.0 receives MCP natively via mcp.json`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return items;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Removal ownership for the bridge, mirroring `planSymlinks`' rule: the
|
|
183
|
+
* raw readlink target must resolve under this installation's
|
|
184
|
+
* `dist/pi-bridge/` directory, or be a byte-identical bundle at the
|
|
185
|
+
* Trellis artifact shape left by a previous install root. Anything else
|
|
186
|
+
* is the user's and stays.
|
|
187
|
+
*/
|
|
188
|
+
isOwnedBridgeSymlink(bridgePath) {
|
|
189
|
+
const stat = lstatSync(bridgePath, { throwIfNoEntry: false });
|
|
190
|
+
if (!stat?.isSymbolicLink())
|
|
191
|
+
return false;
|
|
192
|
+
const rawTarget = readlinkSync(bridgePath);
|
|
193
|
+
const { file } = resolveBridgeFile();
|
|
194
|
+
const bridgeRoot = dirname(file);
|
|
195
|
+
const resolved = rawTarget.startsWith(sep) ? rawTarget : join(dirname(bridgePath), rawTarget);
|
|
196
|
+
if (resolved === file || resolved.startsWith(bridgeRoot.endsWith(sep) ? bridgeRoot : `${bridgeRoot}${sep}`))
|
|
197
|
+
return true;
|
|
198
|
+
return isEquivalentTrellisBridge(resolved, file);
|
|
199
|
+
}
|
|
104
200
|
async apply(plan, backup) {
|
|
105
|
-
await applySymlinkPlan(plan, backup);
|
|
201
|
+
await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
|
|
202
|
+
// Real config writes carry mcpWrite/mcpRemove; the bridge-removal
|
|
203
|
+
// item is the only kind:"mcp" entry without either, and it must run
|
|
204
|
+
// strictly after the config write (design.md D3).
|
|
205
|
+
const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.mcpWrite || item.mcpRemove));
|
|
206
|
+
const bridgeRemovals = plan.filter((item) => item.kind === "mcp" && item.action === "remove" && !item.mcpWrite && !item.mcpRemove);
|
|
207
|
+
if (mcpWrites.length > 0) {
|
|
208
|
+
const configPath = mcpWrites[0].target;
|
|
209
|
+
const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
|
|
210
|
+
const merged = applyJsonMcp(parsed, mcpWrites, piRenderer);
|
|
211
|
+
backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
212
|
+
let ownership = loadMcpOwnership(this.homeDir);
|
|
213
|
+
for (const item of mcpWrites) {
|
|
214
|
+
if (item.action === "create" && item.mcpWrite) {
|
|
215
|
+
ownership = recordOwned(ownership, this.id, item.mcpWrite.name, piRenderer(item.mcpWrite.def, item.mcpWrite.name));
|
|
216
|
+
}
|
|
217
|
+
else if (item.action === "remove" && item.mcpRemove) {
|
|
218
|
+
ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
saveMcpOwnership(this.homeDir, ownership);
|
|
222
|
+
}
|
|
223
|
+
for (const item of bridgeRemovals) {
|
|
224
|
+
const oldTarget = await currentLinkTarget(item.target);
|
|
225
|
+
if (oldTarget === undefined)
|
|
226
|
+
continue; // already gone — nothing to record
|
|
227
|
+
await backup.removeSymlink(item.target, oldTarget);
|
|
228
|
+
}
|
|
106
229
|
}
|
|
107
230
|
async verify(canonical) {
|
|
108
231
|
const snapshot = await piProbe.probe(this.homeDir);
|
|
@@ -122,6 +245,26 @@ export class PiAdapter {
|
|
|
122
245
|
mismatches.push(`skill "${name}" is present on disk but not in canonical scope for pi`);
|
|
123
246
|
}
|
|
124
247
|
}
|
|
248
|
+
if (supportsNativeMcp(this.versionProbe())) {
|
|
249
|
+
// Native mode drift: anything the plan still wants to change is by
|
|
250
|
+
// definition drift. The plan only ever names Trellis-owned entries
|
|
251
|
+
// (the ownership ledger gates removals), so user-authored servers
|
|
252
|
+
// can never surface here.
|
|
253
|
+
for (const item of this.planNativeMcp(canonical)) {
|
|
254
|
+
if (item.action === "create" && item.mcpWrite) {
|
|
255
|
+
mismatches.push(`mcp.json entry "${item.mcpWrite.name}" is missing or differs from canonical — run trellis mcp sync`);
|
|
256
|
+
}
|
|
257
|
+
else if (item.action === "remove" && item.mcpRemove) {
|
|
258
|
+
mismatches.push(`mcp.json entry "${item.mcpRemove.name}" is Trellis-owned but no longer in canonical — run trellis mcp sync`);
|
|
259
|
+
}
|
|
260
|
+
else if (item.action === "remove") {
|
|
261
|
+
mismatches.push(`the Trellis MCP bridge extension is still installed but pi >= 1.1.0 uses native mcp.json — run trellis mcp sync`);
|
|
262
|
+
}
|
|
263
|
+
else if (item.action === "conflict") {
|
|
264
|
+
mismatches.push(item.description);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
125
268
|
return mismatches.length === 0 ? { ok: true } : { ok: false, mismatches };
|
|
126
269
|
}
|
|
127
270
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's native MCP config (pi >= 1.1.0) — the entry renderer and the
|
|
3
|
+
* version gate that chooses between it and the legacy bridge extension
|
|
4
|
+
* (openspec/changes/pi-native-mcp-migration design.md D1/D2).
|
|
5
|
+
*
|
|
6
|
+
* pi's mcp.json has no `type` discriminator: an entry is stdio when it
|
|
7
|
+
* carries `command`, streamable HTTP when it carries `url`. `${VAR}`
|
|
8
|
+
* references in `env`/`headers` values are expanded by pi at spawn
|
|
9
|
+
* (verified empirically against pi 1.1.0 on 2026-10-11), so the
|
|
10
|
+
* zero-plaintext rendering every other JSON agent relies on works
|
|
11
|
+
* unchanged. sse is not a pi transport — the adapter turns sse servers
|
|
12
|
+
* into conflict items rather than writing entries pi cannot connect.
|
|
13
|
+
*/
|
|
14
|
+
import type { McpServerDef } from "../core/types.js";
|
|
15
|
+
/** The pi release that introduced the native MCP client (`pi mcp`,
|
|
16
|
+
* `~/.pi/agent/mcp.json`). */
|
|
17
|
+
export declare const PI_NATIVE_MCP_SINCE = "1.1.0";
|
|
18
|
+
/**
|
|
19
|
+
* Version gate (design.md D2): native delivery only when the probed
|
|
20
|
+
* version is known to understand mcp.json. Anything unparseable —
|
|
21
|
+
* including "binary not on PATH" (undefined) — falls back to the bridge,
|
|
22
|
+
* never the other way round: writing config a pi cannot read is worse
|
|
23
|
+
* than keeping a working bridge.
|
|
24
|
+
*/
|
|
25
|
+
export declare function supportsNativeMcp(version: string | undefined): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* pi-shaped rendering of a canonical server definition — the same shape
|
|
28
|
+
* `pi mcp add` writes (observed 2026-10-11): stdio as
|
|
29
|
+
* `{command, args, env?}`, http as `{url, headers?}`, no `type` field.
|
|
30
|
+
* `env` names render as `${VAR}` references exactly like the other JSON
|
|
31
|
+
* agents; OAuth `auth` metadata is Trellis-side routing information and
|
|
32
|
+
* is never rendered (an agent-owned OAuth server becomes a bare `{url}`
|
|
33
|
+
* entry that `pi mcp login <name>` authorizes into pi's own token store).
|
|
34
|
+
*/
|
|
35
|
+
export declare function piRenderer(def: McpServerDef, _name: string): Record<string, unknown>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's native MCP config (pi >= 1.1.0) — the entry renderer and the
|
|
3
|
+
* version gate that chooses between it and the legacy bridge extension
|
|
4
|
+
* (openspec/changes/pi-native-mcp-migration design.md D1/D2).
|
|
5
|
+
*
|
|
6
|
+
* pi's mcp.json has no `type` discriminator: an entry is stdio when it
|
|
7
|
+
* carries `command`, streamable HTTP when it carries `url`. `${VAR}`
|
|
8
|
+
* references in `env`/`headers` values are expanded by pi at spawn
|
|
9
|
+
* (verified empirically against pi 1.1.0 on 2026-10-11), so the
|
|
10
|
+
* zero-plaintext rendering every other JSON agent relies on works
|
|
11
|
+
* unchanged. sse is not a pi transport — the adapter turns sse servers
|
|
12
|
+
* into conflict items rather than writing entries pi cannot connect.
|
|
13
|
+
*/
|
|
14
|
+
import { resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
|
|
15
|
+
/** The pi release that introduced the native MCP client (`pi mcp`,
|
|
16
|
+
* `~/.pi/agent/mcp.json`). */
|
|
17
|
+
export const PI_NATIVE_MCP_SINCE = "1.1.0";
|
|
18
|
+
/**
|
|
19
|
+
* Version gate (design.md D2): native delivery only when the probed
|
|
20
|
+
* version is known to understand mcp.json. Anything unparseable —
|
|
21
|
+
* including "binary not on PATH" (undefined) — falls back to the bridge,
|
|
22
|
+
* never the other way round: writing config a pi cannot read is worse
|
|
23
|
+
* than keeping a working bridge.
|
|
24
|
+
*/
|
|
25
|
+
export function supportsNativeMcp(version) {
|
|
26
|
+
if (version === undefined)
|
|
27
|
+
return false;
|
|
28
|
+
const match = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(version.trim());
|
|
29
|
+
if (!match)
|
|
30
|
+
return false;
|
|
31
|
+
const major = Number(match[1]);
|
|
32
|
+
const minor = Number(match[2]);
|
|
33
|
+
return major > 1 || (major === 1 && minor >= 1);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* pi-shaped rendering of a canonical server definition — the same shape
|
|
37
|
+
* `pi mcp add` writes (observed 2026-10-11): stdio as
|
|
38
|
+
* `{command, args, env?}`, http as `{url, headers?}`, no `type` field.
|
|
39
|
+
* `env` names render as `${VAR}` references exactly like the other JSON
|
|
40
|
+
* agents; OAuth `auth` metadata is Trellis-side routing information and
|
|
41
|
+
* is never rendered (an agent-owned OAuth server becomes a bare `{url}`
|
|
42
|
+
* entry that `pi mcp login <name>` authorizes into pi's own token store).
|
|
43
|
+
*/
|
|
44
|
+
export function piRenderer(def, _name) {
|
|
45
|
+
if (def.transport === "http" || def.transport === "sse") {
|
|
46
|
+
const entry = { url: def.url };
|
|
47
|
+
if (def.headers && Object.keys(def.headers).length > 0) {
|
|
48
|
+
entry.headers = def.headers;
|
|
49
|
+
}
|
|
50
|
+
return entry;
|
|
51
|
+
}
|
|
52
|
+
const entry = { command: def.command, args: def.args ?? [] };
|
|
53
|
+
const env = resolvedEnvTextMap(def);
|
|
54
|
+
if (Object.keys(env).length > 0) {
|
|
55
|
+
entry.env = env;
|
|
56
|
+
}
|
|
57
|
+
return entry;
|
|
58
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { parseScopeArgs } from "./lib/scopeSelection.js";
|
|
|
14
14
|
import { runMcpImport } from "./commands/mcpImport.js";
|
|
15
15
|
import { parseMcpGatewayArgs, runMcpGateway, runMcpRuntime } from "./commands/mcpGateway.js";
|
|
16
16
|
import { runMcpAuth } from "./commands/mcpAuth.js";
|
|
17
|
+
import { runMcpRenew } from "./commands/mcpRenew.js";
|
|
17
18
|
import { runSecretsAudit } from "./commands/secretsAudit.js";
|
|
18
19
|
import { runRollback } from "./commands/rollback.js";
|
|
19
20
|
import { runSkillList, runSkillAdd, runSkillRemove, runSkillScope, runSkillUpdateBuiltin } from "./commands/skill.js";
|
|
@@ -159,6 +160,16 @@ Commands:
|
|
|
159
160
|
refreshes silently on its own and never prompts.
|
|
160
161
|
--force re-authorize even if the stored token is valid
|
|
161
162
|
--json machine-readable output, no report text
|
|
163
|
+
mcp renew
|
|
164
|
+
Silently refresh every Trellis-owned stored OAuth token that
|
|
165
|
+
needs it —
|
|
166
|
+
never opens a browser, never performs an initial grant.
|
|
167
|
+
Reports per-server health and exits non-zero when any
|
|
168
|
+
classified server needs 'mcp auth'. Safe under a scheduler
|
|
169
|
+
(launchd): idempotent on valid tokens, refreshes serialized
|
|
170
|
+
per server, overlapping runs degrade to one refresh.
|
|
171
|
+
--dry-run report status without any token-endpoint call
|
|
172
|
+
--json machine-readable output, no report text
|
|
162
173
|
mcp-runtime --agent <agent>
|
|
163
174
|
Internal MCP edge exposing Trellis providers and selected
|
|
164
175
|
upstream capabilities; existing mcp-gateway configs remain valid
|
|
@@ -470,6 +481,11 @@ async function main(argv) {
|
|
|
470
481
|
process.exitCode = (await runMcpRemove(name, { json, dryRun })).exitCode;
|
|
471
482
|
return;
|
|
472
483
|
}
|
|
484
|
+
if (subcommand === "renew") {
|
|
485
|
+
const { exitCode } = await runMcpRenew({ json, dryRun });
|
|
486
|
+
process.exitCode = exitCode;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
473
489
|
if (subcommand === "auth") {
|
|
474
490
|
const [serverName] = mcpRest;
|
|
475
491
|
if (!serverName || serverName.startsWith("--")) {
|
|
@@ -481,7 +497,7 @@ async function main(argv) {
|
|
|
481
497
|
process.exitCode = exitCode;
|
|
482
498
|
return;
|
|
483
499
|
}
|
|
484
|
-
console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|scope <name>|remove <name>|auth <name
|
|
500
|
+
console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|scope <name>|remove <name>|auth <name>|renew\n`);
|
|
485
501
|
process.exitCode = 1;
|
|
486
502
|
return;
|
|
487
503
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* throughout docs/research.md's investigation, now formalized.
|
|
6
6
|
*/
|
|
7
7
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
8
|
-
import type { AgentId, AgentSnapshot } from "../core/types.js";
|
|
8
|
+
import type { AgentId, AgentSnapshot, CanonicalSource } from "../core/types.js";
|
|
9
9
|
/**
|
|
10
10
|
* Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
|
|
11
11
|
* real `known_host_injected` field (P0 has no canonical source at all —
|
|
@@ -14,7 +14,7 @@ import type { AgentId, AgentSnapshot } from "../core/types.js";
|
|
|
14
14
|
* machine.
|
|
15
15
|
*/
|
|
16
16
|
export declare const DEFAULT_KNOWN_HOST_INJECTED: readonly string[];
|
|
17
|
-
export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "mcp-unreachable";
|
|
17
|
+
export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable";
|
|
18
18
|
export interface Finding {
|
|
19
19
|
kind: FindingKind;
|
|
20
20
|
/** Omitted for a finding that names more than one agent (drift). */
|
|
@@ -48,9 +48,19 @@ export interface RunDoctorOptions {
|
|
|
48
48
|
* every other Trellis entry point uses. */
|
|
49
49
|
homeDir?: string;
|
|
50
50
|
}
|
|
51
|
+
export interface OAuthHealthEntry {
|
|
52
|
+
server: string;
|
|
53
|
+
status: "valid" | "expired" | "absent" | "unreadable";
|
|
54
|
+
expiresAt?: number;
|
|
55
|
+
}
|
|
51
56
|
export interface DoctorReport {
|
|
52
57
|
snapshots: AgentSnapshot[];
|
|
53
58
|
findings: Finding[];
|
|
59
|
+
/** Credential health for canonical OAuth-classified servers. Absent
|
|
60
|
+
* when no canonical source exists (doctor stays standalone, P0) or no
|
|
61
|
+
* server is OAuth-classified. Read-only by construction
|
|
62
|
+
* (trellis-mcp-oauth-renew design.md D4). */
|
|
63
|
+
oauth?: OAuthHealthEntry[];
|
|
54
64
|
}
|
|
55
65
|
/** Exported so tests can assert on this resolution directly, without
|
|
56
66
|
* routing through real per-agent probes `runDoctor` also runs. */
|
|
@@ -67,6 +77,16 @@ export declare function runDoctor(opts?: RunDoctorOptions): Promise<{
|
|
|
67
77
|
* `undefined`) is unaffected.
|
|
68
78
|
*/
|
|
69
79
|
export declare function collectDoctorReport(homeDir?: string, knownHostInjected?: readonly string[], probeMcp?: boolean): Promise<DoctorReport>;
|
|
80
|
+
/**
|
|
81
|
+
* Read-only OAuth credential health (trellis-mcp-oauth-renew design.md
|
|
82
|
+
* D4): classified from the stored token file alone — no token endpoint,
|
|
83
|
+
* no writes. Scope is canonical Trellis-owned OAuth servers only, the
|
|
84
|
+
* same rule `trellis mcp renew` iterates (design.md D5 +
|
|
85
|
+
* trellis-desktop-oauth-ownership: an agent-owned server's authorization
|
|
86
|
+
* lives in the Agent's own client, so Trellis's empty token store for it
|
|
87
|
+
* is expected, never a finding), via the same classifier (design.md D2).
|
|
88
|
+
*/
|
|
89
|
+
export declare function collectOAuthHealth(homeDir: string, canonical: Pick<CanonicalSource, "mcp">): OAuthHealthEntry[];
|
|
70
90
|
/**
|
|
71
91
|
* Within one agent, across all of its skill roots: any realpath reached by
|
|
72
92
|
* more than one entry, where at least one of those entries is NOT a
|
package/dist/commands/doctor.js
CHANGED
|
@@ -13,7 +13,9 @@ import * as kimiCodeProbe from "../probes/kimi-code.js";
|
|
|
13
13
|
import * as zcodeProbe from "../probes/zcode.js";
|
|
14
14
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
15
15
|
import { GATEWAY_ENTRY_NAME, RUNTIME_ENTRY_NAME, resolveMcpPlan } from "../adapters/mcpPlan.js";
|
|
16
|
-
import { ALL_AGENTS } from "../core/types.js";
|
|
16
|
+
import { ALL_AGENTS, isOAuthAuth, oauthOwner } from "../core/types.js";
|
|
17
|
+
import { classifyStoredToken, remediationFor } from "../lib/oauth/renew.js";
|
|
18
|
+
import { readToken } from "../lib/oauth/store.js";
|
|
17
19
|
/**
|
|
18
20
|
* Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
|
|
19
21
|
* real `known_host_injected` field (P0 has no canonical source at all —
|
|
@@ -36,6 +38,10 @@ const EXIT_NONZERO_KINDS = new Set([
|
|
|
36
38
|
"case-mismatch",
|
|
37
39
|
"parse-diagnostic",
|
|
38
40
|
"runtime-drift",
|
|
41
|
+
// A stored credential that will 401 on next use is exactly what doctor
|
|
42
|
+
// exists to catch *before* the session does (trellis-mcp-oauth-renew
|
|
43
|
+
// design.md D4).
|
|
44
|
+
"oauth-expired",
|
|
39
45
|
]);
|
|
40
46
|
/** Exported so tests can assert on this resolution directly, without
|
|
41
47
|
* routing through real per-agent probes `runDoctor` also runs. */
|
|
@@ -103,8 +109,42 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
|
|
|
103
109
|
// Doctor remains useful as a standalone cross-agent scanner when
|
|
104
110
|
// canonical has not been initialized yet.
|
|
105
111
|
}
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
const oauth = canonical ? collectOAuthHealth(homeDir, canonical) : [];
|
|
113
|
+
findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots), ...oauth.flatMap((entry) => (entry.status === "valid" ? [] : [oauthFinding(entry)])));
|
|
114
|
+
return { snapshots, findings, ...(oauth.length > 0 ? { oauth } : {}) };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Read-only OAuth credential health (trellis-mcp-oauth-renew design.md
|
|
118
|
+
* D4): classified from the stored token file alone — no token endpoint,
|
|
119
|
+
* no writes. Scope is canonical Trellis-owned OAuth servers only, the
|
|
120
|
+
* same rule `trellis mcp renew` iterates (design.md D5 +
|
|
121
|
+
* trellis-desktop-oauth-ownership: an agent-owned server's authorization
|
|
122
|
+
* lives in the Agent's own client, so Trellis's empty token store for it
|
|
123
|
+
* is expected, never a finding), via the same classifier (design.md D2).
|
|
124
|
+
*/
|
|
125
|
+
export function collectOAuthHealth(homeDir, canonical) {
|
|
126
|
+
return Object.entries(canonical.mcp.servers)
|
|
127
|
+
.filter(([, def]) => isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis")
|
|
128
|
+
.map(([name]) => name)
|
|
129
|
+
.sort()
|
|
130
|
+
.map((name) => {
|
|
131
|
+
const status = classifyStoredToken(homeDir, name);
|
|
132
|
+
const expiresAt = readToken(homeDir, name)?.expiresAt;
|
|
133
|
+
const collapsed = status === "valid" ? "valid"
|
|
134
|
+
: status === "no-token" ? "absent"
|
|
135
|
+
: status === "unreadable" ? "unreadable"
|
|
136
|
+
: "expired";
|
|
137
|
+
return { server: name, status: collapsed, ...(expiresAt !== undefined ? { expiresAt } : {}) };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function oauthFinding(entry) {
|
|
141
|
+
const reason = entry.status === "absent" ? "has never been authorized"
|
|
142
|
+
: entry.status === "unreadable" ? "has an unreadable credential file"
|
|
143
|
+
: "has an expired stored token";
|
|
144
|
+
return {
|
|
145
|
+
kind: "oauth-expired",
|
|
146
|
+
message: `OAuth server "${entry.server}" ${reason} — ${remediationFor(entry.status === "expired" ? "expired-renewable" : "needs-auth", entry.server)}`,
|
|
147
|
+
};
|
|
108
148
|
}
|
|
109
149
|
/**
|
|
110
150
|
* Within one agent, across all of its skill roots: any realpath reached by
|
|
@@ -308,6 +348,14 @@ export function printReport(report) {
|
|
|
308
348
|
console.log(` - ${finding.message}`);
|
|
309
349
|
}
|
|
310
350
|
}
|
|
351
|
+
if (report.oauth && report.oauth.length > 0) {
|
|
352
|
+
console.log("\nOAuth credentials:");
|
|
353
|
+
for (const entry of report.oauth) {
|
|
354
|
+
const expiry = entry.expiresAt !== undefined ? ` (expires ${new Date(entry.expiresAt).toISOString()})` : "";
|
|
355
|
+
const icon = entry.status === "valid" ? "✅" : "❌";
|
|
356
|
+
console.log(`${icon} ${entry.server} — ${entry.status}${expiry}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
311
359
|
if (crossAgent.length > 0) {
|
|
312
360
|
console.log("\nCross-agent:");
|
|
313
361
|
for (const finding of crossAgent) {
|
package/dist/commands/mcp.js
CHANGED
|
@@ -111,6 +111,12 @@ export function printReport(report, dryRun) {
|
|
|
111
111
|
for (const item of [...created, ...removed, ...conflicts]) {
|
|
112
112
|
console.log(` - [${item.action}] ${item.description}`);
|
|
113
113
|
}
|
|
114
|
+
// pi does not watch mcp.json (verified 2026-10-11): without this hint
|
|
115
|
+
// a changed config silently only takes effect on the next pi launch
|
|
116
|
+
// (trellis-pi-native-mcp-migration design.md D5).
|
|
117
|
+
if (agent === "pi" && (created.length > 0 || removed.length > 0)) {
|
|
118
|
+
console.log(" pi does not watch mcp.json — restart pi or run /reload to pick up MCP changes");
|
|
119
|
+
}
|
|
114
120
|
}
|
|
115
121
|
}
|
|
116
122
|
function serversYamlPath(homeDir) {
|
|
@@ -46,6 +46,7 @@ export declare function parseMcpGatewayArgs(argv: readonly string[]): {
|
|
|
46
46
|
*/
|
|
47
47
|
export declare function resolveGatewayUpstreams(agentId: AgentId, canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): {
|
|
48
48
|
upstreams: UpstreamSpec[];
|
|
49
|
+
oauthServers: string[];
|
|
49
50
|
conflicts: string[];
|
|
50
51
|
};
|
|
51
52
|
export declare function runMcpGateway(opts: RunMcpGatewayOptions): Promise<{
|
|
@@ -27,7 +27,7 @@ import { TaskProvider } from "../lib/taskProvider.js";
|
|
|
27
27
|
import { AgentBridgeProvider } from "../lib/agentBridgeProvider.js";
|
|
28
28
|
import { RuntimeMemoryProvider } from "../lib/memoryProvider.js";
|
|
29
29
|
import { SkillProvider } from "../lib/skillProvider.js";
|
|
30
|
-
import { ALL_AGENTS } from "../core/types.js";
|
|
30
|
+
import { ALL_AGENTS, isOAuthAuth } from "../core/types.js";
|
|
31
31
|
const CLIENT_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
|
|
32
32
|
const SERVER_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
|
|
33
33
|
const RUNTIME_CLIENT_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
|
|
@@ -60,11 +60,18 @@ export function resolveGatewayUpstreams(agentId, canonical) {
|
|
|
60
60
|
const route = canonical.mcp.routes?.[agentId];
|
|
61
61
|
const hubActive = route ? route.mode === "hub" : Boolean(canonical.mcp.hub) && !isGatewayAgent(agentId, canonical.mcp, canonical.managedAgents);
|
|
62
62
|
if (hubActive) {
|
|
63
|
-
|
|
63
|
+
// Hub mode writes no direct entries at all (mcpPlan's hub branch), so
|
|
64
|
+
// there are no OAuth servers for the status tool to report either.
|
|
65
|
+
return { upstreams: [], oauthServers: [], conflicts: [] };
|
|
64
66
|
}
|
|
65
67
|
const directRoutes = canonical.mcp.routes && route
|
|
66
68
|
? { ...canonical.mcp.routes, [agentId]: { mode: "direct", ...(route.servers ? { servers: route.servers } : {}) } }
|
|
67
69
|
: canonical.mcp.routes;
|
|
70
|
+
// Trellis-owned OAuth servers stay in the plan as ordinary upstreams
|
|
71
|
+
// (auth stripped here so the direct-mode plan keeps them; the original
|
|
72
|
+
// defs are restored below so withOAuthHeader can attach their tokens at
|
|
73
|
+
// connect). Agent-owned ones are excluded entirely: sync writes those as
|
|
74
|
+
// direct native entries beside the gateway (trellis-desktop-oauth-ownership).
|
|
68
75
|
const ordinaryServers = Object.fromEntries(Object.entries(canonical.mcp.servers)
|
|
69
76
|
.filter(([, def]) => !isAgentOwnedOAuth(def))
|
|
70
77
|
.map(([name, def]) => { const { auth: _auth, ...ordinary } = def; return [name, ordinary]; }));
|
|
@@ -81,8 +88,20 @@ export function resolveGatewayUpstreams(agentId, canonical) {
|
|
|
81
88
|
// every server, and every secret resolved into it, to the one agent the
|
|
82
89
|
// user just said to stop involving.
|
|
83
90
|
const { desired, conflicts } = resolveMcpPlan(agentId, direct, canonical.managedAgents, canonical.secretsPolicy);
|
|
91
|
+
// Agent-owned OAuth servers bypass the gateway as direct entries, so no
|
|
92
|
+
// connection state exists for them here — but Trellis owns the token
|
|
93
|
+
// store, so `trellis.mcp.status` reports their credential health for
|
|
94
|
+
// exactly this set. It is resolved through a second direct-mode plan
|
|
95
|
+
// over just those servers, so scope, `enabled: false` and route
|
|
96
|
+
// restrictions match what sync writes (the identical planDirectServers
|
|
97
|
+
// checks; trellis-mcp-status-credential-health design.md D4).
|
|
98
|
+
const agentOwnedServers = Object.fromEntries(Object.entries(canonical.mcp.servers).filter(([, def]) => isAgentOwnedOAuth(def)));
|
|
99
|
+
const oauthServers = Object.keys(agentOwnedServers).length === 0
|
|
100
|
+
? []
|
|
101
|
+
: resolveMcpPlan(agentId, { ...direct, servers: agentOwnedServers }, canonical.managedAgents, canonical.secretsPolicy).desired.map(({ name }) => name);
|
|
84
102
|
return {
|
|
85
103
|
upstreams: desired.map(({ name }) => ({ name, def: canonical.mcp.servers[name] })),
|
|
104
|
+
oauthServers,
|
|
86
105
|
conflicts: conflicts.map((conflict) => conflict.message),
|
|
87
106
|
};
|
|
88
107
|
}
|
|
@@ -109,7 +128,7 @@ async function runMcpEdge(opts, clientInfo, serverInfo) {
|
|
|
109
128
|
console.error(`trellis-mcp-gateway: ${err instanceof Error ? err.message : String(err)}`);
|
|
110
129
|
return { exitCode: 1 };
|
|
111
130
|
}
|
|
112
|
-
const { upstreams, conflicts } = resolveGatewayUpstreams(opts.agentId, canonical);
|
|
131
|
+
const { upstreams, oauthServers, conflicts } = resolveGatewayUpstreams(opts.agentId, canonical);
|
|
113
132
|
for (const message of conflicts) {
|
|
114
133
|
console.error(`trellis-mcp-gateway: ${message}`);
|
|
115
134
|
}
|
|
@@ -124,7 +143,16 @@ async function runMcpEdge(opts, clientInfo, serverInfo) {
|
|
|
124
143
|
// process has no terminal to run one in (design.md D9).
|
|
125
144
|
homeDir,
|
|
126
145
|
});
|
|
127
|
-
|
|
146
|
+
// The status tool's oauth section covers both ownership kinds
|
|
147
|
+
// (trellis-desktop-oauth-ownership): trellis-owned servers arrive as
|
|
148
|
+
// upstreams (auth intact) — their Trellis-store credential health is
|
|
149
|
+
// what the gateway itself will rely on at connect; agent-owned servers
|
|
150
|
+
// bypass the gateway as direct entries and are marked agent-managed.
|
|
151
|
+
const oauthReport = [
|
|
152
|
+
...upstreams.filter((u) => isOAuthAuth(u.def.auth)).map((u) => ({ name: u.name, owner: "trellis" })),
|
|
153
|
+
...oauthServers.map((name) => ({ name, owner: "agent" })),
|
|
154
|
+
];
|
|
155
|
+
const registry = new BuiltinRegistry([new SkillProvider(), new RuntimeMemoryProvider(), new RuntimeControlProvider(backend), new McpStatusProvider(backend, { homeDir, oauthServers: oauthReport }), new TaskProvider(), new AgentBridgeProvider(), new UpstreamProvider(backend)]);
|
|
128
156
|
const server = serveStdio(() => createRuntimeServer(serverInfo, { agentId: opts.agentId, homeDir }, registry));
|
|
129
157
|
await waitForShutdown(registry, server);
|
|
130
158
|
return { exitCode: 0 };
|