agent-trellis 0.9.1 → 0.11.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.
@@ -28,6 +28,11 @@ export declare const LEGACY_RUNTIME_ENTRY_NAME = "trellis-runtime";
28
28
  * user installed the CLI onto. */
29
29
  export declare const GATEWAY_COMMAND = "trellis";
30
30
  export declare const RUNTIME_COMMAND = "trellis";
31
+ /** Either form of the OAuth classification counts — a strict equality
32
+ * check against the scalar would read the metadata-bearing object
33
+ * (`{ kind: oauth, client_id }`) as "not OAuth" and silently drop the
34
+ * routing it exists to carry (trellis-mcp-oauth-static-client design.md
35
+ * D1). */
31
36
  export declare function isOAuthMcpServer(def: McpServerDef): boolean;
32
37
  /**
33
38
  * Gateway mode applies to every managed agent when `agents` is omitted —
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { isInScope } from "../core/adapter.js";
20
20
  import { capabilityDeliveryForAgent } from "../core/types.js";
21
+ import { isOAuthAuth } from "../core/types.js";
21
22
  import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
22
23
  import { resolveSecretEnv } from "../lib/secretEnv.js";
23
24
  export const HUB_ENTRY_NAME = "trellis-hub";
@@ -31,8 +32,13 @@ export const LEGACY_RUNTIME_ENTRY_NAME = "trellis-runtime";
31
32
  * user installed the CLI onto. */
32
33
  export const GATEWAY_COMMAND = "trellis";
33
34
  export const RUNTIME_COMMAND = "trellis";
35
+ /** Either form of the OAuth classification counts — a strict equality
36
+ * check against the scalar would read the metadata-bearing object
37
+ * (`{ kind: oauth, client_id }`) as "not OAuth" and silently drop the
38
+ * routing it exists to carry (trellis-mcp-oauth-static-client design.md
39
+ * D1). */
34
40
  export function isOAuthMcpServer(def) {
35
- return def.auth === "oauth";
41
+ return isOAuthAuth(def.auth);
36
42
  }
37
43
  /**
38
44
  * Gateway mode applies to every managed agent when `agents` is omitted —
package/dist/cli.js CHANGED
@@ -9,13 +9,14 @@ import { runInit } from "./commands/init.js";
9
9
  import { runMigrate } from "./commands/migrate.js";
10
10
  import { runOnboard } from "./commands/onboard.js";
11
11
  import { runSync } from "./commands/sync.js";
12
- import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, runMcpSetAuth, parseMcpAddArgs } from "./commands/mcp.js";
12
+ import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, runMcpSetAuth, runMcpScope, parseMcpAddArgs, parseMcpSetArgs } from "./commands/mcp.js";
13
+ import { parseScopeArgs } from "./lib/scopeSelection.js";
13
14
  import { runMcpImport } from "./commands/mcpImport.js";
14
15
  import { parseMcpGatewayArgs, runMcpGateway, runMcpRuntime } from "./commands/mcpGateway.js";
15
16
  import { runMcpAuth } from "./commands/mcpAuth.js";
16
17
  import { runSecretsAudit } from "./commands/secretsAudit.js";
17
18
  import { runRollback } from "./commands/rollback.js";
18
- import { runSkillList, runSkillAdd, runSkillRemove, runSkillUpdateBuiltin } from "./commands/skill.js";
19
+ import { runSkillList, runSkillAdd, runSkillRemove, runSkillScope, runSkillUpdateBuiltin } from "./commands/skill.js";
19
20
  import { runRemoteSkillAdd, runRemoteSkillList, runRemoteSkillUpdate } from "./commands/remoteSkill.js";
20
21
  import { collectMemoryList, runMemoryExtraction, runMemorySync } from "./commands/memory.js";
21
22
  import { parseManageArgs, runManage } from "./commands/manage.js";
@@ -131,9 +132,25 @@ Commands:
131
132
  --dry-run preview without writing
132
133
  --json machine-readable output, no report text
133
134
  mcp set <name> --auth oauth|none
134
- Set or clear explicit OAuth classification; canonical only
135
+ Set or clear explicit OAuth classification; canonical only.
136
+ --auth oauth [--client-id <id>] [--client-secret-env <NAME>]
137
+ pre-register a client for a provider that refuses
138
+ dynamic registration. --client-id is the PROVIDER's
139
+ published identifier (public by design);
140
+ --client-secret-env is a variable NAME resolved via
141
+ secrets.policy.yaml, never a secret value.
142
+ A bare --auth oauth keeps existing client metadata;
143
+ --auth none removes it along with the classification.
135
144
  --dry-run preview the plan, write nothing
136
145
  --json machine-readable output, no report text
146
+ mcp scope <name> --agents <ids> | --all | --none
147
+ Choose which managed agents reach a canonical MCP server, then
148
+ run the same sync as mcp sync. Exactly one selector is
149
+ required; ids must be managed agents. Selecting every managed
150
+ agent records no explicit scope, so a newly managed agent is
151
+ included. An entry Trellis did not write is never removed.
152
+ --dry-run preview the plan, write nothing
153
+ --json machine-readable output, no report text
137
154
  mcp auth <server-name>
138
155
  Authorize a remote (http/sse) MCP server that uses OAuth —
139
156
  opens a browser once, stores the result 0600 under
@@ -161,6 +178,12 @@ Commands:
161
178
  --dry-run preview the plan, write nothing
162
179
  --json machine-readable output, no report text
163
180
  skill remove <name>
181
+ skill scope <name> --agents <ids> | --all | --none
182
+ Choose which managed agents receive a canonical skill, then
183
+ sync it. Same selector rules as mcp scope; a built-in skill's
184
+ scope is not editable
185
+ --dry-run preview the plan, write nothing
186
+ --json machine-readable output, no report text
164
187
  skill update-builtin
165
188
  Refresh the package-owned trellis-runtime Skill in canonical
166
189
  source; existing native symlinks see the update
@@ -429,9 +452,12 @@ async function main(argv) {
429
452
  }
430
453
  if (subcommand === "set") {
431
454
  const [name] = mcpRest;
432
- const authIndex = mcpRest.indexOf("--auth");
433
- const auth = authIndex >= 0 ? mcpRest[authIndex + 1] : undefined;
434
- process.exitCode = runMcpSetAuth(name, auth, { json, dryRun }).exitCode;
455
+ process.exitCode = runMcpSetAuth(name, parseMcpSetArgs(mcpRest), { json, dryRun }).exitCode;
456
+ return;
457
+ }
458
+ if (subcommand === "scope") {
459
+ const [name] = mcpRest;
460
+ process.exitCode = (await runMcpScope(name && !name.startsWith("--") ? name : undefined, parseScopeArgs(mcpRest), { json, dryRun })).exitCode;
435
461
  return;
436
462
  }
437
463
  if (subcommand === "remove") {
@@ -455,7 +481,7 @@ async function main(argv) {
455
481
  process.exitCode = exitCode;
456
482
  return;
457
483
  }
458
- console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|remove <name>|auth <name>\n`);
484
+ console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|scope <name>|remove <name>|auth <name>\n`);
459
485
  process.exitCode = 1;
460
486
  return;
461
487
  }
@@ -489,11 +515,16 @@ async function main(argv) {
489
515
  process.exitCode = (await runSkillRemove(name, { json, dryRun })).exitCode;
490
516
  return;
491
517
  }
518
+ if (subcommand === "scope") {
519
+ const [name] = skillRest;
520
+ process.exitCode = (await runSkillScope(name && !name.startsWith("--") ? name : undefined, parseScopeArgs(skillRest), { json, dryRun })).exitCode;
521
+ return;
522
+ }
492
523
  if (subcommand === "update-builtin") {
493
524
  process.exitCode = runSkillUpdateBuiltin({ json, dryRun }).exitCode;
494
525
  return;
495
526
  }
496
- console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>|update-builtin\n`);
527
+ console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>|scope <name> --agents <ids>|--all|--none|update-builtin\n`);
497
528
  process.exitCode = 1;
498
529
  return;
499
530
  }
@@ -15,6 +15,7 @@
15
15
  import { type ServersYamlWriteResult } from "../core/canonical.js";
16
16
  import type { AdapterPlanItem } from "../core/adapter.js";
17
17
  import type { AgentId, McpAuthMode, McpConfig, McpServerDef, Transport } from "../core/types.js";
18
+ import { type ScopeSelectorRaw } from "../lib/scopeSelection.js";
18
19
  import { type BackupSession } from "../lib/backup.js";
19
20
  export interface RunMcpSyncOptions {
20
21
  json?: boolean;
@@ -52,7 +53,22 @@ export declare function printReport(report: McpSyncReport, dryRun: boolean): voi
52
53
  export interface McpListEntry {
53
54
  name: string;
54
55
  transport: Transport;
56
+ /** Normalized: `oauth` in either the scalar or the object form reports as
57
+ * the scalar here — the listing reports classification, not metadata. */
55
58
  auth?: McpAuthMode;
59
+ /** Presence of pre-registered client metadata
60
+ * (trellis-mcp-oauth-static-client task 3.2) — `true` when the object form
61
+ * carries a `client_id`. A presence flag, deliberately not the id itself:
62
+ * the id is public-by-design, but listing output stays stable if that
63
+ * ever changes and there is one less place a future secret-looking field
64
+ * could leak through. */
65
+ preRegisteredClient?: true;
66
+ /** State of the stored OAuth credential, for OAuth-classified servers only
67
+ * (trellis-scope-editing-and-auth-status design.md D6). It describes the
68
+ * credential, not the connection: an `authorized` server can still be down. */
69
+ authStatus?: McpAuthStatus;
70
+ /** Epoch ms, when the stored token advertises an expiry. */
71
+ authExpiresAt?: number;
56
72
  enabled: boolean;
57
73
  agents: readonly AgentId[];
58
74
  command?: string;
@@ -68,6 +84,18 @@ export interface McpListEntry {
68
84
  * printed in full. */
69
85
  staticEnv?: Record<string, string>;
70
86
  }
87
+ export type McpAuthStatus = "not-authorized" | "authorized" | "refreshable" | "expired";
88
+ /**
89
+ * Reads only the per-server token store and returns the state, never a
90
+ * token. Classification stays explicit — the caller only asks for servers
91
+ * already declared OAuth, so a stray token file never makes one. A server
92
+ * name that cannot be a token filename, or an unreadable file, simply has no
93
+ * credential: one odd entry must not take down the whole listing.
94
+ */
95
+ export declare function mcpCredentialState(homeDir: string, serverName: string): {
96
+ authStatus: McpAuthStatus;
97
+ authExpiresAt?: number;
98
+ };
71
99
  export declare function collectMcpListPlan(homeDir?: string): McpListEntry[];
72
100
  export declare function runMcpList(opts?: {
73
101
  homeDir?: string;
@@ -98,6 +126,7 @@ export interface McpAddRawArgs {
98
126
  enabled?: string;
99
127
  }
100
128
  export declare function parseMcpAddArgs(argv: readonly string[]): McpAddRawArgs;
129
+ export declare function parseMcpSetArgs(argv: readonly string[]): McpSetAuthRawArgs;
101
130
  export type McpAddAction = "create" | "already-present" | "conflict" | "invalid-input";
102
131
  export interface McpAddPlan {
103
132
  name: string;
@@ -122,14 +151,84 @@ export interface McpSetAuthPlan {
122
151
  detail: string;
123
152
  def?: McpServerDef;
124
153
  }
125
- export declare function collectMcpSetAuthPlan(name: string | undefined, auth: string | undefined, homeDir?: string): McpSetAuthPlan;
126
- export declare function runMcpSetAuth(name: string | undefined, auth: string | undefined, opts?: {
154
+ /** Raw, unvalidated CLI flag values for `mcp set` — same posture as
155
+ * `McpAddRawArgs`: the parser stays a dumb reader, validation lives in the
156
+ * plan collector. */
157
+ export interface McpSetAuthRawArgs {
158
+ auth?: string;
159
+ /** Public literal by design (design.md D2) — the one field it is fine to
160
+ * pass on a command line. Never echoed back in output or errors. */
161
+ clientId?: string;
162
+ /** A variable NAME, never a secret value. */
163
+ clientSecretEnv?: string;
164
+ }
165
+ /**
166
+ * Sets or clears the OAuth classification, and optionally the
167
+ * pre-registered client metadata that goes with it
168
+ * (trellis-mcp-oauth-static-client tasks.md 3.1).
169
+ *
170
+ * Three rules the flag interaction has to get right:
171
+ * - A bare `--auth oauth` is a re-classification, not a reset: an entry
172
+ * that already carries a `client_id` keeps it. Silently dropping
173
+ * metadata here would turn "make sure this is OAuth" into "break my
174
+ * Figma setup", which is exactly the kind of invisible precedence
175
+ * `mcp set` exists to avoid.
176
+ * - `--auth none` is the reset — it strips the whole `auth` key,
177
+ * metadata included, because that is the only reading of "none" that
178
+ * leaves the file in a self-consistent state.
179
+ * - `--client-id` / `--client-secret-env` require an explicit
180
+ * `--auth oauth` in the same invocation. Implying it would make a
181
+ * typo'd flag name mutate the classification.
182
+ */
183
+ export declare function collectMcpSetAuthPlan(name: string | undefined, raw: McpSetAuthRawArgs, homeDir?: string): McpSetAuthPlan;
184
+ export declare function runMcpSetAuth(name: string | undefined, raw: McpSetAuthRawArgs, opts?: {
127
185
  homeDir?: string;
128
186
  json?: boolean;
129
187
  dryRun?: boolean;
130
188
  }): {
131
189
  exitCode: number;
132
190
  };
191
+ export type McpScopeAction = "updated" | "already-set" | "not-found" | "invalid-input";
192
+ export interface McpScopePlan {
193
+ name: string;
194
+ action: McpScopeAction;
195
+ detail: string;
196
+ /** Recorded as `agents:` on the entry; `undefined` removes the key so the
197
+ * server reaches every managed agent. */
198
+ scope?: AgentId[];
199
+ mode?: "all" | "none" | "explicit";
200
+ normalizedFromFull?: boolean;
201
+ effective?: readonly AgentId[];
202
+ /** Only set when action === "updated"; consumed by applyMcpScopeWithSync. */
203
+ def?: McpServerDef;
204
+ }
205
+ /**
206
+ * `mcp scope <name> --agents a,b | --all | --none`
207
+ * (trellis-scope-editing-and-auth-status tasks.md 1.2). Same selector rules as
208
+ * `skill scope`, which is why both go through `resolveScopeSelection`.
209
+ */
210
+ export declare function collectMcpScopePlan(name: string | undefined, raw: ScopeSelectorRaw, homeDir?: string): McpScopePlan;
211
+ export interface McpScopeOutcome {
212
+ plan: McpScopePlan;
213
+ writeError?: string;
214
+ sync?: McpSyncReport;
215
+ }
216
+ /** Write inside a backup session, then the same `mcp sync` `add` / `remove`
217
+ * run. Removal of an entry from an agent narrowed away stays gated by the
218
+ * ownership ledger, so an entry Trellis did not write is never deleted
219
+ * (design.md D5). */
220
+ export declare function applyMcpScopeWithSync(plan: McpScopePlan, opts?: {
221
+ homeDir?: string;
222
+ dryRun?: boolean;
223
+ backupSession?: BackupSession;
224
+ }): Promise<McpScopeOutcome>;
225
+ export declare function runMcpScope(name: string | undefined, raw: ScopeSelectorRaw, opts?: {
226
+ homeDir?: string;
227
+ json?: boolean;
228
+ dryRun?: boolean;
229
+ }): Promise<{
230
+ exitCode: number;
231
+ }>;
133
232
  export declare function applyMcpAddPlan(plan: McpAddPlan, homeDir?: string, backup?: BackupSession): ServersYamlWriteResult;
134
233
  export interface McpAddOutcome {
135
234
  plan: McpAddPlan;
@@ -16,7 +16,10 @@ import { homedir } from "node:os";
16
16
  import { existsSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { loadCanonicalSource, upsertServerYaml, removeServerYaml } from "../core/canonical.js";
19
- import { ALL_AGENTS, resolveScope } from "../core/types.js";
19
+ import { ALL_AGENTS, isOAuthAuth, oauthClientMetadata, resolveScope } from "../core/types.js";
20
+ import { isValidSecretVarName } from "../lib/secretEnv.js";
21
+ import { isExpired, readToken } from "../lib/oauth/store.js";
22
+ import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
20
23
  import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
21
24
  import { CodexAdapter } from "../adapters/codex.js";
22
25
  import { KiroAdapter } from "../adapters/kiro.js";
@@ -113,12 +116,36 @@ export function printReport(report, dryRun) {
113
116
  function serversYamlPath(homeDir) {
114
117
  return join(homeDir, ".trellis", "mcp", "servers.yaml");
115
118
  }
119
+ /**
120
+ * Reads only the per-server token store and returns the state, never a
121
+ * token. Classification stays explicit — the caller only asks for servers
122
+ * already declared OAuth, so a stray token file never makes one. A server
123
+ * name that cannot be a token filename, or an unreadable file, simply has no
124
+ * credential: one odd entry must not take down the whole listing.
125
+ */
126
+ export function mcpCredentialState(homeDir, serverName) {
127
+ let token;
128
+ try {
129
+ token = readToken(homeDir, serverName);
130
+ }
131
+ catch {
132
+ return { authStatus: "not-authorized" };
133
+ }
134
+ if (!token?.accessToken)
135
+ return { authStatus: "not-authorized" };
136
+ const expiry = token.expiresAt !== undefined ? { authExpiresAt: token.expiresAt } : {};
137
+ if (!isExpired(token))
138
+ return { authStatus: "authorized", ...expiry };
139
+ return { authStatus: token.refreshToken ? "refreshable" : "expired", ...expiry };
140
+ }
116
141
  export function collectMcpListPlan(homeDir = homedir()) {
117
142
  const canonical = loadCanonicalSource(homeDir);
118
143
  return Object.entries(canonical.mcp.servers).map(([name, def]) => ({
119
144
  name,
120
145
  transport: def.transport,
121
- auth: def.auth,
146
+ auth: isOAuthAuth(def.auth) ? "oauth" : undefined,
147
+ preRegisteredClient: oauthClientMetadata(def.auth).clientId !== undefined ? true : undefined,
148
+ ...(isOAuthAuth(def.auth) ? mcpCredentialState(homeDir, name) : {}),
122
149
  enabled: def.enabled ?? true,
123
150
  agents: resolveScope(def.agents, canonical.managedAgents),
124
151
  command: def.command,
@@ -148,7 +175,7 @@ export function runMcpList(opts = {}) {
148
175
  else {
149
176
  for (const e of entries) {
150
177
  const scope = e.agents.length > 0 ? e.agents.join(", ") : "(no managed agent reaches it)";
151
- console.log(`${e.name} (${e.transport}${e.auth === "oauth" ? ", oauth" : ""})${e.enabled ? "" : " [disabled]"} — ${scope}`);
178
+ console.log(`${e.name} (${e.transport}${e.auth === "oauth" ? ", oauth" : ""}${e.preRegisteredClient ? ", pre-registered client" : ""}${e.authStatus ? `, credential: ${e.authStatus}` : ""})${e.enabled ? "" : " [disabled]"} — ${scope}`);
152
179
  if (e.env && e.env.length > 0)
153
180
  console.log(` env: ${e.env.join(", ")} (values never read/printed)`);
154
181
  if (e.staticEnv)
@@ -175,6 +202,17 @@ export function parseMcpAddArgs(argv) {
175
202
  enabled: flag("--enabled"),
176
203
  };
177
204
  }
205
+ export function parseMcpSetArgs(argv) {
206
+ const flag = (name) => {
207
+ const i = argv.indexOf(name);
208
+ return i >= 0 ? argv[i + 1] : undefined;
209
+ };
210
+ return {
211
+ auth: flag("--auth"),
212
+ clientId: flag("--client-id"),
213
+ clientSecretEnv: flag("--client-secret-env"),
214
+ };
215
+ }
178
216
  function parseCsv(raw) {
179
217
  if (!raw)
180
218
  return undefined;
@@ -254,12 +292,54 @@ export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
254
292
  }
255
293
  return { name, action: "create", detail: "will add to servers.yaml", def };
256
294
  }
257
- export function collectMcpSetAuthPlan(name, auth, homeDir = homedir()) {
258
- const mode = auth === "oauth" || auth === "none" ? auth : undefined;
295
+ /**
296
+ * Sets or clears the OAuth classification, and optionally the
297
+ * pre-registered client metadata that goes with it
298
+ * (trellis-mcp-oauth-static-client tasks.md 3.1).
299
+ *
300
+ * Three rules the flag interaction has to get right:
301
+ * - A bare `--auth oauth` is a re-classification, not a reset: an entry
302
+ * that already carries a `client_id` keeps it. Silently dropping
303
+ * metadata here would turn "make sure this is OAuth" into "break my
304
+ * Figma setup", which is exactly the kind of invisible precedence
305
+ * `mcp set` exists to avoid.
306
+ * - `--auth none` is the reset — it strips the whole `auth` key,
307
+ * metadata included, because that is the only reading of "none" that
308
+ * leaves the file in a self-consistent state.
309
+ * - `--client-id` / `--client-secret-env` require an explicit
310
+ * `--auth oauth` in the same invocation. Implying it would make a
311
+ * typo'd flag name mutate the classification.
312
+ */
313
+ export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
314
+ const mode = raw.auth === "oauth" || raw.auth === "none" ? raw.auth : undefined;
259
315
  if (!name)
260
- return { name: "(none)", auth: (mode ?? "none"), action: "invalid-input", detail: "usage: trellis mcp set <name> --auth oauth|none" };
261
- if (!mode)
262
- return { name, auth: "none", action: "invalid-input", detail: `--auth must be "oauth" or "none" (got ${auth ?? "(missing)"})` };
316
+ return { name: "(none)", auth: (mode ?? "none"), action: "invalid-input", detail: "usage: trellis mcp set <name> --auth oauth|none [--client-id <id>] [--client-secret-env <NAME>]" };
317
+ const hasClientFlags = raw.clientId !== undefined || raw.clientSecretEnv !== undefined;
318
+ const clientFlagsDetail = "--client-id / --client-secret-env require an explicit --auth oauth in the same command";
319
+ if (mode === undefined) {
320
+ // Naming the missing --auth is the actionable error when that is what
321
+ // the user was reaching for; otherwise report the invalid value itself.
322
+ if (hasClientFlags)
323
+ return { name, auth: "none", action: "invalid-input", detail: clientFlagsDetail };
324
+ return { name, auth: "none", action: "invalid-input", detail: `--auth must be "oauth" or "none" (got ${raw.auth ?? "(missing)"})` };
325
+ }
326
+ if (hasClientFlags && mode !== "oauth") {
327
+ return { name, auth: mode, action: "invalid-input", detail: clientFlagsDetail };
328
+ }
329
+ if (raw.clientId === "") {
330
+ return { name, auth: mode, action: "invalid-input", detail: "--client-id must not be empty" };
331
+ }
332
+ if (raw.clientSecretEnv !== undefined && !isValidSecretVarName(raw.clientSecretEnv)) {
333
+ // The value is deliberately not echoed: a name-shaped field failing
334
+ // this check is at best a typo and at worst a pasted credential
335
+ // (design.md D2).
336
+ return {
337
+ name,
338
+ auth: mode,
339
+ action: "invalid-input",
340
+ detail: "--client-secret-env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value",
341
+ };
342
+ }
263
343
  const canonical = loadCanonicalSource(homeDir);
264
344
  const current = canonical.mcp.servers[name];
265
345
  if (!current)
@@ -267,19 +347,61 @@ export function collectMcpSetAuthPlan(name, auth, homeDir = homedir()) {
267
347
  if (mode === "oauth" && !current.url) {
268
348
  return { name, auth: mode, action: "invalid-input", detail: "auth: oauth requires an http or sse MCP server" };
269
349
  }
270
- const next = mode === "oauth" ? { ...current, auth: "oauth" } : (() => {
350
+ // Same load-time guard as `assertAuthMetadataClean`, applied at write
351
+ // time so an offending value never reaches the file in the first place.
352
+ const offending = [
353
+ ["client-id", raw.clientId],
354
+ ["client-secret-env", raw.clientSecretEnv],
355
+ ].find(([, value]) => value !== undefined && canonical.secretsPolicy.rejectPatterns.some((pattern) => pattern.test(value)));
356
+ if (offending) {
357
+ return {
358
+ name,
359
+ auth: mode,
360
+ action: "invalid-input",
361
+ detail: `--${offending[0]} matches a secrets.policy.yaml reject pattern — client-id must be the provider-published id, client-secret-env a variable name; the offending value is intentionally not printed`,
362
+ };
363
+ }
364
+ let next;
365
+ if (mode === "none") {
271
366
  const { auth: _auth, ...withoutAuth } = current;
272
- return withoutAuth;
273
- })();
367
+ next = withoutAuth;
368
+ }
369
+ else if (!hasClientFlags && isOAuthAuth(current.auth)) {
370
+ // Already classified and no new metadata: a true no-op. Rewriting the
371
+ // scalar into a metadata-less object here would churn the file to say
372
+ // exactly what it already said (design.md D1 makes them equivalent).
373
+ next = current;
374
+ }
375
+ else if (!hasClientFlags) {
376
+ // Newly classified, nothing to record — the scalar is the documented
377
+ // shorthand, so write the smaller of two equivalent forms.
378
+ next = { ...current, auth: "oauth" };
379
+ }
380
+ else {
381
+ const existing = oauthClientMetadata(current.auth);
382
+ // Per-field override, not replacement: `--client-id` alone keeps a
383
+ // `client_secret_env` that is already configured.
384
+ const clientId = raw.clientId ?? existing.clientId;
385
+ const clientSecretEnv = raw.clientSecretEnv ?? existing.clientSecretEnv;
386
+ next = {
387
+ ...current,
388
+ auth: {
389
+ kind: "oauth",
390
+ ...(clientId !== undefined ? { clientId } : {}),
391
+ ...(clientSecretEnv !== undefined ? { clientSecretEnv } : {}),
392
+ },
393
+ };
394
+ }
274
395
  if (JSON.stringify(current) === JSON.stringify(next))
275
396
  return { name, auth: mode, action: "already-set", detail: `auth is already ${mode}`, def: next };
276
- return { name, auth: mode, action: "updated", detail: `set auth to ${mode}`, def: next };
397
+ const changed = hasClientFlags ? " and client metadata" : "";
398
+ return { name, auth: mode, action: "updated", detail: `set auth to ${mode}${changed}`, def: next };
277
399
  }
278
- export function runMcpSetAuth(name, auth, opts = {}) {
400
+ export function runMcpSetAuth(name, raw, opts = {}) {
279
401
  const homeDir = opts.homeDir ?? homedir();
280
402
  let plan;
281
403
  try {
282
- plan = collectMcpSetAuthPlan(name, auth, homeDir);
404
+ plan = collectMcpSetAuthPlan(name, raw, homeDir);
283
405
  }
284
406
  catch (err) {
285
407
  console.error(err instanceof Error ? err.message : String(err));
@@ -302,6 +424,85 @@ export function runMcpSetAuth(name, auth, opts = {}) {
302
424
  }
303
425
  return { exitCode: ["invalid-input", "not-found"].includes(plan.action) || Boolean(writeError) ? 1 : 0 };
304
426
  }
427
+ /**
428
+ * `mcp scope <name> --agents a,b | --all | --none`
429
+ * (trellis-scope-editing-and-auth-status tasks.md 1.2). Same selector rules as
430
+ * `skill scope`, which is why both go through `resolveScopeSelection`.
431
+ */
432
+ export function collectMcpScopePlan(name, raw, homeDir = homedir()) {
433
+ if (!name)
434
+ return { name: "(none)", action: "invalid-input", detail: "usage: trellis mcp scope <name> --agents <ids> | --all | --none" };
435
+ const canonical = loadCanonicalSource(homeDir);
436
+ const selection = resolveScopeSelection(raw, canonical.managedAgents);
437
+ if (!selection.ok)
438
+ return { name, action: "invalid-input", detail: selection.detail };
439
+ const current = canonical.mcp.servers[name];
440
+ if (!current)
441
+ return { name, action: "not-found", detail: `no canonical MCP server named "${name}"` };
442
+ const base = {
443
+ name,
444
+ scope: selection.scope,
445
+ mode: selection.mode,
446
+ normalizedFromFull: selection.normalizedFromFull,
447
+ effective: resolveScope(selection.scope, canonical.managedAgents),
448
+ };
449
+ const summary = describeScope(selection.scope, canonical.managedAgents);
450
+ if (sameScope(current.agents, selection.scope)) {
451
+ return { ...base, action: "already-set", detail: `scope is already ${summary}` };
452
+ }
453
+ const { agents: _agents, ...withoutAgents } = current;
454
+ const def = selection.scope === undefined ? withoutAgents : { ...withoutAgents, agents: selection.scope };
455
+ const normalized = selection.normalizedFromFull ? " (every managed agent selected — recorded as the default, so a newly managed agent is included)" : "";
456
+ return { ...base, action: "updated", detail: `set scope to ${summary}${normalized}`, def };
457
+ }
458
+ /** Write inside a backup session, then the same `mcp sync` `add` / `remove`
459
+ * run. Removal of an entry from an agent narrowed away stays gated by the
460
+ * ownership ledger, so an entry Trellis did not write is never deleted
461
+ * (design.md D5). */
462
+ export async function applyMcpScopeWithSync(plan, opts = {}) {
463
+ const homeDir = opts.homeDir ?? homedir();
464
+ const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "updated" ? openBackupSession(homeDir, "mcp-scope") : undefined;
465
+ const session = opts.backupSession ?? ownSession;
466
+ let writeError;
467
+ if (!opts.dryRun && plan.action === "updated" && plan.def) {
468
+ const result = upsertServerYaml(serversYamlPath(homeDir), plan.name, plan.def, session);
469
+ if (!result.ok)
470
+ writeError = result.error;
471
+ }
472
+ ownSession?.finalize();
473
+ let syncReport;
474
+ if (!writeError && plan.action === "updated" && existsSync(join(homeDir, ".trellis"))) {
475
+ syncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun });
476
+ }
477
+ return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
478
+ }
479
+ export async function runMcpScope(name, raw, opts = {}) {
480
+ const homeDir = opts.homeDir ?? homedir();
481
+ let outcome;
482
+ try {
483
+ outcome = await applyMcpScopeWithSync(collectMcpScopePlan(name, raw, homeDir), { homeDir, dryRun: opts.dryRun });
484
+ }
485
+ catch (err) {
486
+ console.error(err instanceof Error ? err.message : String(err));
487
+ return { exitCode: 1 };
488
+ }
489
+ const { plan, writeError, sync: syncReport } = outcome;
490
+ if (opts.json) {
491
+ const payload = writeError ? { ...plan, writeError } : plan;
492
+ console.log(JSON.stringify(syncReport ? { ...payload, sync: syncReport } : payload, null, 2));
493
+ }
494
+ else {
495
+ console.log(`${opts.dryRun ? "[dry run] " : ""}mcp scope ${plan.name}`);
496
+ console.log(` [${plan.action}] ${plan.detail}`);
497
+ if (writeError)
498
+ console.error(` write failed: ${writeError}`);
499
+ if (syncReport)
500
+ printReport(syncReport, opts.dryRun ?? false);
501
+ }
502
+ const failed = plan.action === "invalid-input" || plan.action === "not-found" || Boolean(writeError);
503
+ const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
504
+ return { exitCode: failed || syncConflict ? 1 : 0 };
505
+ }
305
506
  export function applyMcpAddPlan(plan, homeDir = homedir(), backup) {
306
507
  if (plan.action !== "create" || !plan.def)
307
508
  return { ok: true };
@@ -13,7 +13,9 @@ import { loadCanonicalSource } from "../core/canonical.js";
13
13
  import { discoverAuthorizationServer, parseResourceMetadataUrl } from "../lib/oauth/discovery.js";
14
14
  import { authorize } from "../lib/oauth/flow.js";
15
15
  import { ensureFreshToken } from "../lib/oauth/refresh.js";
16
+ import { resolveSecretEnv } from "../lib/secretEnv.js";
16
17
  import { isExpired, readToken, tokenPath, writeToken } from "../lib/oauth/store.js";
18
+ import { oauthClientMetadata } from "../core/types.js";
17
19
  export async function runMcpAuth(opts) {
18
20
  const homeDir = opts.homeDir ?? homedir();
19
21
  let result;
@@ -72,13 +74,39 @@ async function authorizeServer(opts, homeDir) {
72
74
  if (!metadata) {
73
75
  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
76
  }
77
+ // Pre-registered client metadata from canonical (design.md D3): a
78
+ // `client_id` here means the RFC 7591 step is skipped entirely, which is
79
+ // the only way to authorize against a provider that refuses registration.
80
+ // The secret is resolved by NAME through the same policy machinery `env`
81
+ // uses, and the resolved value never leaves this call — it goes straight
82
+ // into `authorize`, which pairs it with the grant it persists.
83
+ const client = oauthClientMetadata(def.auth);
84
+ let clientSecret;
85
+ if (client.clientSecretEnv) {
86
+ const { [client.clientSecretEnv]: value } = resolveSecretEnv([client.clientSecretEnv], canonical.secretsPolicy);
87
+ if (value === undefined) {
88
+ throw new Error(`auth.client_secret_env is "${client.clientSecretEnv}", but no value for that variable was found in ${secretSourceLabel(canonical.secretsPolicy)} — set it there, or drop the field if this provider issues a public client`);
89
+ }
90
+ clientSecret = value;
91
+ }
75
92
  const token = await authorize(opts.serverName, metadata, {
76
93
  ...(opts.openBrowser ? { openBrowser: opts.openBrowser } : {}),
77
94
  ...(metadata.scopesSupported?.length ? { scope: metadata.scopesSupported.join(" ") } : {}),
95
+ ...(client.clientId !== undefined ? { clientId: client.clientId } : {}),
96
+ ...(clientSecret !== undefined ? { clientSecret } : {}),
78
97
  });
79
98
  writeToken(homeDir, opts.serverName, token);
80
99
  return { server: opts.serverName, status: "authorized", tokenFile: file, ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}) };
81
100
  }
101
+ /**
102
+ * Names where a declared secret was looked for, so an unresolvable name
103
+ * points at the file to edit. Mirrors `resolveSecretEnv`'s own two
104
+ * sources: an explicit `env_file` is the sole source when set, otherwise
105
+ * the ambient process environment.
106
+ */
107
+ function secretSourceLabel(policy) {
108
+ return policy.envFile ? `${policy.envFile} (secrets.policy.yaml env_file)` : "the process environment (no env_file is set in secrets.policy.yaml)";
109
+ }
82
110
  /**
83
111
  * An unauthenticated request to the resource, purely to read its
84
112
  * `WWW-Authenticate` challenge — the server naming its own authorization