agent-trellis 0.4.0 → 0.6.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/codex.js +4 -1
- package/dist/adapters/jsonMcp.js +6 -3
- package/dist/adapters/kiro.js +9 -1
- package/dist/adapters/mcpPlan.d.ts +15 -0
- package/dist/adapters/mcpPlan.js +59 -3
- package/dist/adapters/symlinkPlan.js +3 -0
- package/dist/cli.js +56 -8
- package/dist/commands/doctor.d.ts +13 -3
- package/dist/commands/doctor.js +20 -10
- package/dist/commands/mcpAuth.d.ts +28 -0
- package/dist/commands/mcpAuth.js +99 -0
- package/dist/commands/mcpGateway.d.ts +53 -0
- package/dist/commands/mcpGateway.js +148 -0
- package/dist/commands/memory.d.ts +42 -19
- package/dist/commands/memory.js +99 -14
- package/dist/commands/migrate.d.ts +18 -1
- package/dist/commands/migrate.js +52 -6
- package/dist/commands/onboard.d.ts +34 -0
- package/dist/commands/onboard.js +110 -10
- package/dist/commands/onboardVerdict.d.ts +55 -0
- package/dist/commands/onboardVerdict.js +175 -0
- package/dist/core/adapter.d.ts +7 -0
- package/dist/core/canonical.d.ts +8 -6
- package/dist/core/canonical.js +26 -3
- package/dist/core/types.d.ts +42 -4
- package/dist/lib/backup.js +32 -3
- package/dist/lib/gatewayBackend.d.ts +75 -0
- package/dist/lib/gatewayBackend.js +131 -0
- package/dist/lib/mcpConnect.d.ts +53 -0
- package/dist/lib/mcpConnect.js +123 -0
- package/dist/lib/mcpMigrateRead.d.ts +12 -0
- package/dist/lib/mcpMigrateRead.js +47 -12
- package/dist/lib/mcpToolRegistry.d.ts +89 -0
- package/dist/lib/mcpToolRegistry.js +94 -0
- package/dist/lib/memoryGraph.d.ts +35 -0
- package/dist/lib/memoryGraph.js +62 -0
- package/dist/lib/oauth/discovery.d.ts +53 -0
- package/dist/lib/oauth/discovery.js +95 -0
- package/dist/lib/oauth/flow.d.ts +47 -0
- package/dist/lib/oauth/flow.js +193 -0
- package/dist/lib/oauth/lock.d.ts +25 -0
- package/dist/lib/oauth/lock.js +98 -0
- package/dist/lib/oauth/pkce.d.ts +27 -0
- package/dist/lib/oauth/pkce.js +40 -0
- package/dist/lib/oauth/refresh.d.ts +33 -0
- package/dist/lib/oauth/refresh.js +93 -0
- package/dist/lib/oauth/store.d.ts +55 -0
- package/dist/lib/oauth/store.js +85 -0
- package/dist/lib/slug.d.ts +9 -0
- package/dist/lib/slug.js +15 -0
- package/dist/lib/tomlSection.js +7 -1
- package/dist/pi-bridge/bundle.js +14383 -14338
- package/dist/pi-bridge/index.d.ts +5 -9
- package/dist/pi-bridge/index.js +6 -99
- package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
- package/dist/pi-bridge/schemaTranslate.js +8 -4
- package/docs/architecture.md +88 -19
- package/docs/getting-started.md +174 -11
- package/docs/research.md +190 -0
- package/docs/roadmap.md +20 -0
- package/package.json +4 -3
- package/schema/servers.example.yaml +42 -7
package/dist/adapters/codex.js
CHANGED
|
@@ -83,7 +83,10 @@ export class CodexAdapter {
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
for (const conflict of conflicts) {
|
|
86
|
-
|
|
86
|
+
const item = { action: "conflict", kind: "mcp", target: configTomlPath, description: conflict.message };
|
|
87
|
+
if (conflict.remediation)
|
|
88
|
+
item.remediation = conflict.remediation;
|
|
89
|
+
items.push(item);
|
|
87
90
|
}
|
|
88
91
|
const desiredNames = new Set(desired.map((d) => d.name));
|
|
89
92
|
const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
|
package/dist/adapters/jsonMcp.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* through untouched.
|
|
8
8
|
*/
|
|
9
9
|
import { deepEqual } from "../lib/deepEqual.js";
|
|
10
|
+
import { resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
|
|
10
11
|
import { resolveMcpPlan } from "./mcpPlan.js";
|
|
11
12
|
/** Renders the native JSON shape Claude Code/Kiro's `mcpServers` map
|
|
12
13
|
* expects — `env` names render as `${VAR}` references, `staticEnv`
|
|
@@ -23,8 +24,7 @@ export function renderJsonServerEntry(def) {
|
|
|
23
24
|
return entry;
|
|
24
25
|
}
|
|
25
26
|
const entry = { type: "stdio", command: def.command, args: def.args ?? [] };
|
|
26
|
-
const
|
|
27
|
-
const env = { ...nameRefs, ...(def.staticEnv ?? {}) };
|
|
27
|
+
const env = resolvedEnvTextMap(def);
|
|
28
28
|
if (Object.keys(env).length > 0) {
|
|
29
29
|
entry.env = env;
|
|
30
30
|
}
|
|
@@ -50,7 +50,10 @@ export function planJsonMcp(opts) {
|
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
52
|
for (const conflict of conflicts) {
|
|
53
|
-
|
|
53
|
+
const item = { action: "conflict", kind: "mcp", target: configPath, description: conflict.message };
|
|
54
|
+
if (conflict.remediation)
|
|
55
|
+
item.remediation = conflict.remediation;
|
|
56
|
+
items.push(item);
|
|
54
57
|
}
|
|
55
58
|
const desiredNames = new Set(desired.map((d) => d.name));
|
|
56
59
|
for (const [name, expected] of Object.entries(ownership ?? {})) {
|
package/dist/adapters/kiro.js
CHANGED
|
@@ -97,7 +97,15 @@ export class KiroAdapter {
|
|
|
97
97
|
parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
98
98
|
}
|
|
99
99
|
catch {
|
|
100
|
-
return [
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
action: "conflict",
|
|
103
|
+
kind: "kiro-approved-env-vars",
|
|
104
|
+
target: settingsPath,
|
|
105
|
+
description: `${settingsPath} is not valid JSON — refusing to touch it`,
|
|
106
|
+
remediation: `fix or restore valid JSON at ${settingsPath} (a backup under ~/.trellis/backups/ may have the last version Trellis wrote), then re-run sync`,
|
|
107
|
+
},
|
|
108
|
+
];
|
|
101
109
|
}
|
|
102
110
|
const current = parsed[APPROVED_ENV_VARS_KEY];
|
|
103
111
|
if (Array.isArray(current)) {
|
|
@@ -18,6 +18,18 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { AgentId, McpConfig, McpServerDef, SecretsPolicy } from "../core/types.js";
|
|
20
20
|
export declare const HUB_ENTRY_NAME = "trellis-hub";
|
|
21
|
+
export declare const GATEWAY_ENTRY_NAME = "trellis-gateway";
|
|
22
|
+
/** The command an agent spawns in gateway mode. Bare `trellis` rather than
|
|
23
|
+
* an absolute path: the entry has to keep working across reinstalls and
|
|
24
|
+
* version bumps, and every agent resolves it through the same PATH the
|
|
25
|
+
* user installed the CLI onto. */
|
|
26
|
+
export declare const GATEWAY_COMMAND = "trellis";
|
|
27
|
+
/**
|
|
28
|
+
* Gateway mode applies to every managed agent when `agents` is omitted —
|
|
29
|
+
* the expected normal case (design.md D14). An explicit list narrows it,
|
|
30
|
+
* letting some agents stay in direct or hub mode.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isGatewayAgent(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[]): boolean;
|
|
21
33
|
export interface DesiredMcpEntry {
|
|
22
34
|
name: string;
|
|
23
35
|
def: McpServerDef;
|
|
@@ -25,6 +37,9 @@ export interface DesiredMcpEntry {
|
|
|
25
37
|
export interface McpConflict {
|
|
26
38
|
name: string;
|
|
27
39
|
message: string;
|
|
40
|
+
/** The concrete next action, distinct from `message`'s restatement of
|
|
41
|
+
* why (trellis-onboard-closed-loop design.md D7). */
|
|
42
|
+
remediation?: string;
|
|
28
43
|
}
|
|
29
44
|
export interface McpPlanResult {
|
|
30
45
|
desired: DesiredMcpEntry[];
|
package/dist/adapters/mcpPlan.js
CHANGED
|
@@ -20,10 +20,32 @@ import { isInScope } from "../core/adapter.js";
|
|
|
20
20
|
import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
|
|
21
21
|
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
22
22
|
export const HUB_ENTRY_NAME = "trellis-hub";
|
|
23
|
+
export const GATEWAY_ENTRY_NAME = "trellis-gateway";
|
|
24
|
+
/** The command an agent spawns in gateway mode. Bare `trellis` rather than
|
|
25
|
+
* an absolute path: the entry has to keep working across reinstalls and
|
|
26
|
+
* version bumps, and every agent resolves it through the same PATH the
|
|
27
|
+
* user installed the CLI onto. */
|
|
28
|
+
export const GATEWAY_COMMAND = "trellis";
|
|
29
|
+
/**
|
|
30
|
+
* Gateway mode applies to every managed agent when `agents` is omitted —
|
|
31
|
+
* the expected normal case (design.md D14). An explicit list narrows it,
|
|
32
|
+
* letting some agents stay in direct or hub mode.
|
|
33
|
+
*/
|
|
34
|
+
export function isGatewayAgent(agentId, mcp, managedAgents) {
|
|
35
|
+
const gateway = mcp.gateway;
|
|
36
|
+
if (!gateway?.enabled)
|
|
37
|
+
return false;
|
|
38
|
+
if (gateway.agents)
|
|
39
|
+
return gateway.agents.includes(agentId);
|
|
40
|
+
return managedAgents.includes(agentId);
|
|
41
|
+
}
|
|
23
42
|
const CODEX_STDIO_URL_CRASH_NOTE = ' On Codex specifically, this crashes the entire process at startup ("url is not supported for stdio"), not just this one server — see docs/research.md.';
|
|
24
43
|
function collisionMessage(name, agentId) {
|
|
25
44
|
return `refusing to write MCP server "${name}": also appears in known_host_injected.${agentId === "codex" ? CODEX_STDIO_URL_CRASH_NOTE : ""}`;
|
|
26
45
|
}
|
|
46
|
+
function collisionRemediation(name) {
|
|
47
|
+
return `rename this server in servers.yaml, or remove "${name}" from known_host_injected if it's no longer actually host-injected on this machine`;
|
|
48
|
+
}
|
|
27
49
|
/**
|
|
28
50
|
* Known-dangerous literal patterns (design.md D5) — mirrors
|
|
29
51
|
* schema/secrets.policy.example.yaml's own examples. A narrow, hardcoded
|
|
@@ -53,18 +75,49 @@ function findLiteralSecret(def) {
|
|
|
53
75
|
* proposal.md "Why"). Uses the identical `resolveSecretEnv` `secrets
|
|
54
76
|
* audit`/the pi bridge already call, so this and a later `secrets audit`
|
|
55
77
|
* run can never disagree about what resolves.
|
|
78
|
+
*
|
|
79
|
+
* `envAliases`' values are themselves source variable names needing the
|
|
80
|
+
* exact same resolution — checked in the same pass so a differently-named
|
|
81
|
+
* reference gets the same pre-write refusal `env` already has, naming the
|
|
82
|
+
* unresolved *source* name (what the caller must find a value for), not
|
|
83
|
+
* the target key it would have been written under
|
|
84
|
+
* (trellis-migrate-env-var-alias).
|
|
56
85
|
*/
|
|
57
86
|
function findUnresolvedEnvName(def, policy) {
|
|
58
|
-
const names = def.env ?? [];
|
|
87
|
+
const names = [...(def.env ?? []), ...Object.values(def.envAliases ?? {})];
|
|
59
88
|
if (names.length === 0)
|
|
60
89
|
return undefined;
|
|
61
90
|
const resolved = resolveSecretEnv(names, policy);
|
|
62
91
|
return names.find((name) => !resolved[name]);
|
|
63
92
|
}
|
|
64
93
|
export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
|
|
94
|
+
// Checked before `hub`: only one entry can be "the" MCP entry for an
|
|
95
|
+
// agent, and gateway is the newer, purpose-built path (design.md D6).
|
|
96
|
+
// `hub` stays available, unchanged, for anyone pointing an agent at an
|
|
97
|
+
// externally-operated endpoint — including a different agent in the
|
|
98
|
+
// same canonical source.
|
|
99
|
+
if (isGatewayAgent(agentId, mcp, managedAgents)) {
|
|
100
|
+
if (mcp.knownHostInjected.includes(GATEWAY_ENTRY_NAME)) {
|
|
101
|
+
return {
|
|
102
|
+
desired: [],
|
|
103
|
+
conflicts: [{ name: GATEWAY_ENTRY_NAME, message: collisionMessage(GATEWAY_ENTRY_NAME, agentId), remediation: collisionRemediation(GATEWAY_ENTRY_NAME) }],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Every per-server check (enabled, scope, literal secrets, unresolved
|
|
107
|
+
// env names, Codex's header shape) still runs — but at gateway
|
|
108
|
+
// *startup*, inside the subcommand, since no adapter sees an
|
|
109
|
+
// individual server in this mode (design.md D6).
|
|
110
|
+
return {
|
|
111
|
+
desired: [{ name: GATEWAY_ENTRY_NAME, def: { transport: "stdio", command: GATEWAY_COMMAND, args: ["mcp-gateway", "--agent", agentId] } }],
|
|
112
|
+
conflicts: [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
65
115
|
if (mcp.hub) {
|
|
66
116
|
if (mcp.knownHostInjected.includes(HUB_ENTRY_NAME)) {
|
|
67
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
desired: [],
|
|
119
|
+
conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId), remediation: collisionRemediation(HUB_ENTRY_NAME) }],
|
|
120
|
+
};
|
|
68
121
|
}
|
|
69
122
|
return { desired: [{ name: HUB_ENTRY_NAME, def: { transport: "http", url: mcp.hub.url } }], conflicts: [] };
|
|
70
123
|
}
|
|
@@ -78,7 +131,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
|
|
|
78
131
|
continue;
|
|
79
132
|
}
|
|
80
133
|
if (mcp.knownHostInjected.includes(name)) {
|
|
81
|
-
conflicts.push({ name, message: collisionMessage(name, agentId) });
|
|
134
|
+
conflicts.push({ name, message: collisionMessage(name, agentId), remediation: collisionRemediation(name) });
|
|
82
135
|
continue;
|
|
83
136
|
}
|
|
84
137
|
const secretLabel = findLiteralSecret(def);
|
|
@@ -86,6 +139,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
|
|
|
86
139
|
conflicts.push({
|
|
87
140
|
name,
|
|
88
141
|
message: `refusing to write MCP server "${name}": a value matches a known-dangerous literal pattern (${secretLabel}) — configs must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
|
|
142
|
+
remediation: `replace the literal value in servers.yaml with a \`\${VAR_NAME}\` reference and put the real value wherever secrets.policy.yaml resolves it from`,
|
|
89
143
|
});
|
|
90
144
|
continue;
|
|
91
145
|
}
|
|
@@ -98,6 +152,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
|
|
|
98
152
|
conflicts.push({
|
|
99
153
|
name,
|
|
100
154
|
message: `refusing to write MCP server "${name}" for codex: its "headers" field isn't the single { Authorization: "Bearer \${VAR}" } shape Codex's own config format can express — Codex has no generic headers concept, only \`bearer_token_env_var\`. Still written normally for every other in-scope agent.`,
|
|
155
|
+
remediation: `reduce this server's headers to a single Authorization bearer token for Codex, enable gateway mode for Codex (which resolves headers itself and never hits this check), or accept that this server stays unreachable from Codex`,
|
|
101
156
|
});
|
|
102
157
|
continue;
|
|
103
158
|
}
|
|
@@ -107,6 +162,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
|
|
|
107
162
|
conflicts.push({
|
|
108
163
|
name,
|
|
109
164
|
message: `refusing to write MCP server "${name}": its declared env var "${unresolvedName}" has no resolvable value in ${source} — writing it now would silently break this server's connection once the agent starts it (trellis-mcp-static-env-and-disabled-servers)`,
|
|
165
|
+
remediation: `set "${unresolvedName}" in ${source}, or fix the name in servers.yaml if it was a typo, then re-run mcp sync`,
|
|
110
166
|
});
|
|
111
167
|
continue;
|
|
112
168
|
}
|
|
@@ -56,6 +56,9 @@ export function planSymlinks(opts) {
|
|
|
56
56
|
description: isForeign
|
|
57
57
|
? `${path} exists as a symlink to ${readlinkSync(path)}, not owned by Trellis — left untouched`
|
|
58
58
|
: `${path} exists and is not a Trellis-managed symlink — left untouched`,
|
|
59
|
+
remediation: isForeign
|
|
60
|
+
? `remove the existing symlink at ${path} if you want Trellis to manage it, then re-run sync — otherwise leave it, Trellis will not touch it`
|
|
61
|
+
: `back up ${path}'s real content if you need it, remove the file, then re-run sync — Trellis will not overwrite or merge into an existing real file`,
|
|
59
62
|
});
|
|
60
63
|
continue;
|
|
61
64
|
}
|
package/dist/cli.js
CHANGED
|
@@ -10,12 +10,14 @@ import { runMigrate } from "./commands/migrate.js";
|
|
|
10
10
|
import { runOnboard } from "./commands/onboard.js";
|
|
11
11
|
import { runSync } from "./commands/sync.js";
|
|
12
12
|
import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, parseMcpAddArgs } from "./commands/mcp.js";
|
|
13
|
+
import { parseMcpGatewayArgs, runMcpGateway } from "./commands/mcpGateway.js";
|
|
14
|
+
import { runMcpAuth } from "./commands/mcpAuth.js";
|
|
13
15
|
import { runSecretsAudit } from "./commands/secretsAudit.js";
|
|
14
16
|
import { runRollback } from "./commands/rollback.js";
|
|
15
17
|
import { runSkillList, runSkillAdd, runSkillRemove } from "./commands/skill.js";
|
|
16
|
-
import { runMemorySync } from "./commands/memory.js";
|
|
18
|
+
import { runMemoryExtraction, runMemorySync } from "./commands/memory.js";
|
|
17
19
|
import { parseSyncArgs } from "./lib/syncArgs.js";
|
|
18
|
-
const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "skill", "memory", "secrets", "rollback"];
|
|
20
|
+
const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "mcp-gateway", "skill", "memory", "secrets", "rollback"];
|
|
19
21
|
function printUsage() {
|
|
20
22
|
console.log(`trellis - a single source of capability for every coding agent
|
|
21
23
|
|
|
@@ -77,6 +79,14 @@ Commands:
|
|
|
77
79
|
[--enabled true|false]
|
|
78
80
|
--dry-run preview the plan, write nothing
|
|
79
81
|
--json machine-readable output, no report text
|
|
82
|
+
mcp auth <server-name>
|
|
83
|
+
Authorize a remote (http/sse) MCP server that uses OAuth —
|
|
84
|
+
opens a browser once, stores the result 0600 under
|
|
85
|
+
~/.trellis/mcp/oauth/. A still-valid token is a no-op; an
|
|
86
|
+
expired one is refreshed without a browser. The gateway
|
|
87
|
+
refreshes silently on its own and never prompts.
|
|
88
|
+
--force re-authorize even if the stored token is valid
|
|
89
|
+
--json machine-readable output, no report text
|
|
80
90
|
mcp remove <name>
|
|
81
91
|
Remove a canonical MCP server (canonical-side only — does
|
|
82
92
|
not touch any agent's already-synced native config)
|
|
@@ -104,6 +114,14 @@ Commands:
|
|
|
104
114
|
or relation this didn't create.
|
|
105
115
|
--dry-run preview the plan, write nothing
|
|
106
116
|
--json machine-readable output, no report text
|
|
117
|
+
memory extract
|
|
118
|
+
The reverse direction: read the same graph file's real,
|
|
119
|
+
non-Trellis entities (agent-accumulated content, never
|
|
120
|
+
Trellis's own) and write each as a new canonical
|
|
121
|
+
memories/*.md file — readable markdown, not meant to
|
|
122
|
+
round-trip byte-for-byte back through memory sync.
|
|
123
|
+
--dry-run preview the plan, write nothing
|
|
124
|
+
--json machine-readable output, no report text
|
|
107
125
|
secrets audit
|
|
108
126
|
Scan each present agent's real MCP config for leaked
|
|
109
127
|
credentials and unexpected env var names
|
|
@@ -160,6 +178,20 @@ async function main(argv) {
|
|
|
160
178
|
process.exitCode = exitCode;
|
|
161
179
|
return;
|
|
162
180
|
}
|
|
181
|
+
// Spawned by an agent, never typed by a person: stdout is the MCP
|
|
182
|
+
// protocol stream for the rest of this process's life, so nothing here
|
|
183
|
+
// may print to it (trellis-mcp-gateway-hosting design.md D2).
|
|
184
|
+
if (command === "mcp-gateway") {
|
|
185
|
+
const parsed = parseMcpGatewayArgs(rest);
|
|
186
|
+
if ("error" in parsed) {
|
|
187
|
+
console.error(parsed.error);
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const { exitCode } = await runMcpGateway({ agentId: parsed.agentId });
|
|
192
|
+
process.exitCode = exitCode;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
163
195
|
if (command === "sync") {
|
|
164
196
|
const { target, unknownArg } = parseSyncArgs(rest);
|
|
165
197
|
if (unknownArg) {
|
|
@@ -200,7 +232,18 @@ async function main(argv) {
|
|
|
200
232
|
process.exitCode = runMcpRemove(name, { json, dryRun }).exitCode;
|
|
201
233
|
return;
|
|
202
234
|
}
|
|
203
|
-
|
|
235
|
+
if (subcommand === "auth") {
|
|
236
|
+
const [serverName] = mcpRest;
|
|
237
|
+
if (!serverName || serverName.startsWith("--")) {
|
|
238
|
+
console.error("Usage: trellis mcp auth <server-name>");
|
|
239
|
+
process.exitCode = 1;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const { exitCode } = await runMcpAuth({ serverName, force: mcpRest.includes("--force"), json });
|
|
243
|
+
process.exitCode = exitCode;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|remove <name>|auth <name>\n`);
|
|
204
247
|
process.exitCode = 1;
|
|
205
248
|
return;
|
|
206
249
|
}
|
|
@@ -240,13 +283,18 @@ async function main(argv) {
|
|
|
240
283
|
}
|
|
241
284
|
if (command === "memory") {
|
|
242
285
|
const [subcommand, ...memoryRest] = rest;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
286
|
+
const json = memoryRest.includes("--json");
|
|
287
|
+
const dryRun = memoryRest.includes("--dry-run");
|
|
288
|
+
if (subcommand === "sync") {
|
|
289
|
+
process.exitCode = runMemorySync({ json, dryRun }).exitCode;
|
|
246
290
|
return;
|
|
247
291
|
}
|
|
248
|
-
|
|
249
|
-
|
|
292
|
+
if (subcommand === "extract") {
|
|
293
|
+
process.exitCode = runMemoryExtraction({ json, dryRun }).exitCode;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
console.error(`Unknown memory subcommand: ${subcommand ?? "(none)"}\nUsage: trellis memory sync|extract\n`);
|
|
297
|
+
process.exitCode = 1;
|
|
250
298
|
return;
|
|
251
299
|
}
|
|
252
300
|
if (command === "secrets") {
|
|
@@ -57,9 +57,15 @@ export declare function resolveKnownHostInjected(opts: RunDoctorOptions): readon
|
|
|
57
57
|
export declare function runDoctor(opts?: RunDoctorOptions): Promise<{
|
|
58
58
|
exitCode: number;
|
|
59
59
|
}>;
|
|
60
|
-
/**
|
|
61
|
-
*
|
|
62
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Exported separately from `runDoctor` so tests can assert on findings
|
|
62
|
+
* without capturing stdout — and, since trellis-onboard-closed-loop,
|
|
63
|
+
* so `trellis onboard` can call this against the scratch home its own
|
|
64
|
+
* tests use. `homeDir` defaults to the real `~` so `runDoctor`'s
|
|
65
|
+
* existing call site (which now passes `opts.homeDir`, itself normally
|
|
66
|
+
* `undefined`) is unaffected.
|
|
67
|
+
*/
|
|
68
|
+
export declare function collectDoctorReport(homeDir?: string, knownHostInjected?: readonly string[], probeMcp?: boolean): Promise<DoctorReport>;
|
|
63
69
|
/**
|
|
64
70
|
* Within one agent, across all of its skill roots: any realpath reached by
|
|
65
71
|
* more than one entry, where at least one of those entries is NOT a
|
|
@@ -86,3 +92,7 @@ export declare function detectCrossAgentDrift(snapshots: AgentSnapshot[]): Findi
|
|
|
86
92
|
export declare function detectCaseMismatches(snapshots: AgentSnapshot[]): Finding[];
|
|
87
93
|
export declare function detectParseDiagnostics(snapshots: AgentSnapshot[]): Finding[];
|
|
88
94
|
export declare function detectMcpUnreachable(snapshots: AgentSnapshot[]): Finding[];
|
|
95
|
+
/** Exported so `trellis onboard` can reuse it verbatim for its own final
|
|
96
|
+
* stage (trellis-onboard-closed-loop) rather than a second copy of this
|
|
97
|
+
* formatting. */
|
|
98
|
+
export declare function printReport(report: DoctorReport): void;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* current state against each other, the same manual process used
|
|
5
5
|
* throughout docs/research.md's investigation, now formalized.
|
|
6
6
|
*/
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import * as claudeCodeProbe from "../probes/claude-code.js";
|
|
8
9
|
import * as codexProbe from "../probes/codex.js";
|
|
9
10
|
import * as kiroProbe from "../probes/kiro.js";
|
|
@@ -47,24 +48,30 @@ export function resolveKnownHostInjected(opts) {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
export async function runDoctor(opts = {}) {
|
|
50
|
-
const report = await collectDoctorReport(resolveKnownHostInjected(opts), opts.probeMcp);
|
|
51
|
+
const report = await collectDoctorReport(opts.homeDir, resolveKnownHostInjected(opts), opts.probeMcp);
|
|
51
52
|
const exitCode = report.findings.some((f) => EXIT_NONZERO_KINDS.has(f.kind)) ? 1 : 0;
|
|
52
53
|
if (opts.json) {
|
|
53
54
|
console.log(JSON.stringify(report, null, 2));
|
|
54
55
|
}
|
|
55
56
|
else {
|
|
56
|
-
|
|
57
|
+
printReport(report);
|
|
57
58
|
}
|
|
58
59
|
return { exitCode };
|
|
59
60
|
}
|
|
60
|
-
/**
|
|
61
|
-
*
|
|
62
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Exported separately from `runDoctor` so tests can assert on findings
|
|
63
|
+
* without capturing stdout — and, since trellis-onboard-closed-loop,
|
|
64
|
+
* so `trellis onboard` can call this against the scratch home its own
|
|
65
|
+
* tests use. `homeDir` defaults to the real `~` so `runDoctor`'s
|
|
66
|
+
* existing call site (which now passes `opts.homeDir`, itself normally
|
|
67
|
+
* `undefined`) is unaffected.
|
|
68
|
+
*/
|
|
69
|
+
export async function collectDoctorReport(homeDir = homedir(), knownHostInjected = DEFAULT_KNOWN_HOST_INJECTED, probeMcp = false) {
|
|
63
70
|
const probeList = [
|
|
64
|
-
{ agent: "claude-code", run: () => claudeCodeProbe.probe(
|
|
65
|
-
{ agent: "codex", run: () => codexProbe.probe(
|
|
66
|
-
{ agent: "kiro", run: () => kiroProbe.probe(
|
|
67
|
-
{ agent: "pi", run: piProbe.probe },
|
|
71
|
+
{ agent: "claude-code", run: () => claudeCodeProbe.probe(homeDir, { probeMcp }) },
|
|
72
|
+
{ agent: "codex", run: () => codexProbe.probe(homeDir, { probeMcp }) },
|
|
73
|
+
{ agent: "kiro", run: () => kiroProbe.probe(homeDir, { probeMcp }) },
|
|
74
|
+
{ agent: "pi", run: () => piProbe.probe(homeDir) },
|
|
68
75
|
];
|
|
69
76
|
// One agent erroring must not blank the other three's results.
|
|
70
77
|
const settled = await Promise.allSettled(probeList.map((p) => p.run()));
|
|
@@ -226,7 +233,10 @@ export function detectMcpUnreachable(snapshots) {
|
|
|
226
233
|
}
|
|
227
234
|
return findings;
|
|
228
235
|
}
|
|
229
|
-
|
|
236
|
+
/** Exported so `trellis onboard` can reuse it verbatim for its own final
|
|
237
|
+
* stage (trellis-onboard-closed-loop) rather than a second copy of this
|
|
238
|
+
* formatting. */
|
|
239
|
+
export function printReport(report) {
|
|
230
240
|
const byAgent = new Map();
|
|
231
241
|
const crossAgent = [];
|
|
232
242
|
for (const finding of report.findings) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis mcp auth <server-name>` — the one piece of the OAuth story
|
|
3
|
+
* that genuinely requires a human (trellis-mcp-gateway-hosting design.md
|
|
4
|
+
* D9).
|
|
5
|
+
*
|
|
6
|
+
* The initial authorization-code exchange needs a real browser and a
|
|
7
|
+
* loopback callback, so it only makes sense as an explicit, one-time
|
|
8
|
+
* command. Everything after it — silently refreshing an expired token —
|
|
9
|
+
* happens inside the gateway, which never reaches this file.
|
|
10
|
+
*/
|
|
11
|
+
export interface RunMcpAuthOptions {
|
|
12
|
+
serverName: string;
|
|
13
|
+
/** Re-authorize even when a valid token is already stored. */
|
|
14
|
+
force?: boolean;
|
|
15
|
+
json?: boolean;
|
|
16
|
+
homeDir?: string;
|
|
17
|
+
/** Test seam — the real one launches the platform browser. */
|
|
18
|
+
openBrowser?: (url: string) => void | Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface McpAuthResult {
|
|
21
|
+
server: string;
|
|
22
|
+
status: "authorized" | "already-valid" | "refreshed";
|
|
23
|
+
tokenFile: string;
|
|
24
|
+
expiresAt?: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function runMcpAuth(opts: RunMcpAuthOptions): Promise<{
|
|
27
|
+
exitCode: number;
|
|
28
|
+
}>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis mcp auth <server-name>` — the one piece of the OAuth story
|
|
3
|
+
* that genuinely requires a human (trellis-mcp-gateway-hosting design.md
|
|
4
|
+
* D9).
|
|
5
|
+
*
|
|
6
|
+
* The initial authorization-code exchange needs a real browser and a
|
|
7
|
+
* loopback callback, so it only makes sense as an explicit, one-time
|
|
8
|
+
* command. Everything after it — silently refreshing an expired token —
|
|
9
|
+
* happens inside the gateway, which never reaches this file.
|
|
10
|
+
*/
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
13
|
+
import { discoverAuthorizationServer, parseResourceMetadataUrl } from "../lib/oauth/discovery.js";
|
|
14
|
+
import { authorize } from "../lib/oauth/flow.js";
|
|
15
|
+
import { ensureFreshToken } from "../lib/oauth/refresh.js";
|
|
16
|
+
import { isExpired, readToken, tokenPath, writeToken } from "../lib/oauth/store.js";
|
|
17
|
+
export async function runMcpAuth(opts) {
|
|
18
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
19
|
+
let result;
|
|
20
|
+
try {
|
|
21
|
+
result = await authorizeServer(opts, homeDir);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
25
|
+
return { exitCode: 1 };
|
|
26
|
+
}
|
|
27
|
+
if (opts.json) {
|
|
28
|
+
console.log(JSON.stringify(result, null, 2));
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const detail = result.expiresAt ? ` (expires ${new Date(result.expiresAt).toISOString()})` : "";
|
|
32
|
+
const verb = result.status === "already-valid"
|
|
33
|
+
? "already has a valid token"
|
|
34
|
+
: result.status === "refreshed"
|
|
35
|
+
? "token refreshed"
|
|
36
|
+
: "authorized";
|
|
37
|
+
console.log(`✅ ${result.server} — ${verb}${detail}`);
|
|
38
|
+
console.log(` credentials: ${result.tokenFile}`);
|
|
39
|
+
}
|
|
40
|
+
return { exitCode: 0 };
|
|
41
|
+
}
|
|
42
|
+
async function authorizeServer(opts, homeDir) {
|
|
43
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
44
|
+
const def = canonical.mcp.servers[opts.serverName];
|
|
45
|
+
if (!def) {
|
|
46
|
+
throw new Error(`no MCP server named "${opts.serverName}" in ~/.trellis/mcp/servers.yaml`);
|
|
47
|
+
}
|
|
48
|
+
if (!def.url) {
|
|
49
|
+
// stdio servers authenticate through `env`/`envAliases`, which is a
|
|
50
|
+
// different mechanism entirely — there is nothing to authorize here,
|
|
51
|
+
// and pretending otherwise would send someone hunting for a browser
|
|
52
|
+
// flow that does not exist.
|
|
53
|
+
throw new Error(`MCP server "${opts.serverName}" is a stdio server — OAuth applies to http/sse servers. Its credentials come from \`env\`/\`env_aliases\` and secrets.policy.yaml.`);
|
|
54
|
+
}
|
|
55
|
+
const file = tokenPath(homeDir, opts.serverName);
|
|
56
|
+
if (!opts.force) {
|
|
57
|
+
const existing = readToken(homeDir, opts.serverName);
|
|
58
|
+
if (existing && !isExpired(existing)) {
|
|
59
|
+
// Re-running is a no-op rather than a fresh grant: rotating a
|
|
60
|
+
// perfectly good credential is a way to break a working setup.
|
|
61
|
+
return { server: opts.serverName, status: "already-valid", tokenFile: file, ...(existing.expiresAt ? { expiresAt: existing.expiresAt } : {}) };
|
|
62
|
+
}
|
|
63
|
+
if (existing?.refreshToken) {
|
|
64
|
+
// Expired but renewable — no browser needed.
|
|
65
|
+
const refreshed = await ensureFreshToken(homeDir, opts.serverName);
|
|
66
|
+
if (refreshed) {
|
|
67
|
+
return { server: opts.serverName, status: "refreshed", tokenFile: file, ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}) };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const metadata = await discoverAuthorizationServer(def.url, { wwwAuthenticate: await probeWwwAuthenticate(def) });
|
|
72
|
+
if (!metadata) {
|
|
73
|
+
throw new Error(`could not discover OAuth metadata for "${opts.serverName}" (${def.url}) — it may not use OAuth, or may not publish RFC 8414/9728 metadata`);
|
|
74
|
+
}
|
|
75
|
+
const token = await authorize(opts.serverName, metadata, {
|
|
76
|
+
...(opts.openBrowser ? { openBrowser: opts.openBrowser } : {}),
|
|
77
|
+
...(metadata.scopesSupported?.length ? { scope: metadata.scopesSupported.join(" ") } : {}),
|
|
78
|
+
});
|
|
79
|
+
writeToken(homeDir, opts.serverName, token);
|
|
80
|
+
return { server: opts.serverName, status: "authorized", tokenFile: file, ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}) };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* An unauthenticated request to the resource, purely to read its
|
|
84
|
+
* `WWW-Authenticate` challenge — the server naming its own authorization
|
|
85
|
+
* server directly, which beats probing well-known paths. Failure is not
|
|
86
|
+
* an error: discovery falls back to probing.
|
|
87
|
+
*/
|
|
88
|
+
async function probeWwwAuthenticate(def) {
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetch(def.url, { method: "GET" });
|
|
91
|
+
if (response.status !== 401)
|
|
92
|
+
return null;
|
|
93
|
+
const header = response.headers.get("www-authenticate");
|
|
94
|
+
return parseResourceMetadataUrl(header) ? header : null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis mcp-gateway --agent <id>` — the stdio MCP server an agent
|
|
3
|
+
* spawns in gateway mode (trellis-mcp-gateway-hosting).
|
|
4
|
+
*
|
|
5
|
+
* Not a user-facing command in the usual sense: nobody types this. An
|
|
6
|
+
* agent's own MCP client spawns it exactly the way it spawns any other
|
|
7
|
+
* stdio server, talks to it for the session, and closes the pipe on exit
|
|
8
|
+
* (design.md D2). There is no daemon, no lifecycle command, and nothing
|
|
9
|
+
* for the user to notice.
|
|
10
|
+
*
|
|
11
|
+
* This file depends on `GatewayBackend` and nothing below it — no
|
|
12
|
+
* connection, no transport, no server definition (design.md D11). That is
|
|
13
|
+
* what lets v2 point the same stdio edge at one shared service instead of
|
|
14
|
+
* an in-process backend without this file, or any agent's config,
|
|
15
|
+
* changing.
|
|
16
|
+
*/
|
|
17
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
18
|
+
import { type GatewayBackend, type UpstreamSpec } from "../lib/gatewayBackend.js";
|
|
19
|
+
import type { AgentId } from "../core/types.js";
|
|
20
|
+
export interface RunMcpGatewayOptions {
|
|
21
|
+
agentId: AgentId;
|
|
22
|
+
/** Test seams, exactly as every other Trellis entry point exposes them. */
|
|
23
|
+
homeDir?: string;
|
|
24
|
+
connectTimeoutMs?: number;
|
|
25
|
+
/** Injected so tests can drive the protocol edge without real upstreams,
|
|
26
|
+
* and so v2 can substitute a forwarding backend (design.md D11). */
|
|
27
|
+
backendFactory?: (upstreams: UpstreamSpec[], homeDir: string) => Promise<GatewayBackend>;
|
|
28
|
+
}
|
|
29
|
+
export declare function parseMcpGatewayArgs(argv: readonly string[]): {
|
|
30
|
+
agentId: AgentId;
|
|
31
|
+
} | {
|
|
32
|
+
error: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* The in-scope upstream set for this agent, resolved through the very same
|
|
36
|
+
* `resolveMcpPlan` that direct mode uses — per-server `agents:` scope,
|
|
37
|
+
* `enabled: false`, host-injected collisions, literal-secret refusals and
|
|
38
|
+
* unresolved `env` names all apply here identically, because it is
|
|
39
|
+
* literally the same function (design.md D6).
|
|
40
|
+
*
|
|
41
|
+
* The one thing that must be stripped is gateway mode itself: asking
|
|
42
|
+
* `resolveMcpPlan` for this agent's plan while gateway mode is on would
|
|
43
|
+
* answer "one entry, pointing at the gateway" — the gateway being told to
|
|
44
|
+
* connect to itself. Resolving against a gateway-less view of the same
|
|
45
|
+
* canonical gives the set gateway mode replaced.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveGatewayUpstreams(agentId: AgentId, canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): {
|
|
48
|
+
upstreams: UpstreamSpec[];
|
|
49
|
+
conflicts: string[];
|
|
50
|
+
};
|
|
51
|
+
export declare function runMcpGateway(opts: RunMcpGatewayOptions): Promise<{
|
|
52
|
+
exitCode: number;
|
|
53
|
+
}>;
|