agent-trellis 0.9.1 → 0.10.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,7 +9,7 @@ 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, parseMcpAddArgs, parseMcpSetArgs } from "./commands/mcp.js";
13
13
  import { runMcpImport } from "./commands/mcpImport.js";
14
14
  import { parseMcpGatewayArgs, runMcpGateway, runMcpRuntime } from "./commands/mcpGateway.js";
15
15
  import { runMcpAuth } from "./commands/mcpAuth.js";
@@ -131,7 +131,15 @@ Commands:
131
131
  --dry-run preview without writing
132
132
  --json machine-readable output, no report text
133
133
  mcp set <name> --auth oauth|none
134
- Set or clear explicit OAuth classification; canonical only
134
+ Set or clear explicit OAuth classification; canonical only.
135
+ --auth oauth [--client-id <id>] [--client-secret-env <NAME>]
136
+ pre-register a client for a provider that refuses
137
+ dynamic registration. --client-id is the PROVIDER's
138
+ published identifier (public by design);
139
+ --client-secret-env is a variable NAME resolved via
140
+ secrets.policy.yaml, never a secret value.
141
+ A bare --auth oauth keeps existing client metadata;
142
+ --auth none removes it along with the classification.
135
143
  --dry-run preview the plan, write nothing
136
144
  --json machine-readable output, no report text
137
145
  mcp auth <server-name>
@@ -429,9 +437,7 @@ async function main(argv) {
429
437
  }
430
438
  if (subcommand === "set") {
431
439
  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;
440
+ process.exitCode = runMcpSetAuth(name, parseMcpSetArgs(mcpRest), { json, dryRun }).exitCode;
435
441
  return;
436
442
  }
437
443
  if (subcommand === "remove") {
@@ -52,7 +52,16 @@ export declare function printReport(report: McpSyncReport, dryRun: boolean): voi
52
52
  export interface McpListEntry {
53
53
  name: string;
54
54
  transport: Transport;
55
+ /** Normalized: `oauth` in either the scalar or the object form reports as
56
+ * the scalar here — the listing reports classification, not metadata. */
55
57
  auth?: McpAuthMode;
58
+ /** Presence of pre-registered client metadata
59
+ * (trellis-mcp-oauth-static-client task 3.2) — `true` when the object form
60
+ * carries a `client_id`. A presence flag, deliberately not the id itself:
61
+ * the id is public-by-design, but listing output stays stable if that
62
+ * ever changes and there is one less place a future secret-looking field
63
+ * could leak through. */
64
+ preRegisteredClient?: true;
56
65
  enabled: boolean;
57
66
  agents: readonly AgentId[];
58
67
  command?: string;
@@ -98,6 +107,7 @@ export interface McpAddRawArgs {
98
107
  enabled?: string;
99
108
  }
100
109
  export declare function parseMcpAddArgs(argv: readonly string[]): McpAddRawArgs;
110
+ export declare function parseMcpSetArgs(argv: readonly string[]): McpSetAuthRawArgs;
101
111
  export type McpAddAction = "create" | "already-present" | "conflict" | "invalid-input";
102
112
  export interface McpAddPlan {
103
113
  name: string;
@@ -122,8 +132,37 @@ export interface McpSetAuthPlan {
122
132
  detail: string;
123
133
  def?: McpServerDef;
124
134
  }
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?: {
135
+ /** Raw, unvalidated CLI flag values for `mcp set` — same posture as
136
+ * `McpAddRawArgs`: the parser stays a dumb reader, validation lives in the
137
+ * plan collector. */
138
+ export interface McpSetAuthRawArgs {
139
+ auth?: string;
140
+ /** Public literal by design (design.md D2) — the one field it is fine to
141
+ * pass on a command line. Never echoed back in output or errors. */
142
+ clientId?: string;
143
+ /** A variable NAME, never a secret value. */
144
+ clientSecretEnv?: string;
145
+ }
146
+ /**
147
+ * Sets or clears the OAuth classification, and optionally the
148
+ * pre-registered client metadata that goes with it
149
+ * (trellis-mcp-oauth-static-client tasks.md 3.1).
150
+ *
151
+ * Three rules the flag interaction has to get right:
152
+ * - A bare `--auth oauth` is a re-classification, not a reset: an entry
153
+ * that already carries a `client_id` keeps it. Silently dropping
154
+ * metadata here would turn "make sure this is OAuth" into "break my
155
+ * Figma setup", which is exactly the kind of invisible precedence
156
+ * `mcp set` exists to avoid.
157
+ * - `--auth none` is the reset — it strips the whole `auth` key,
158
+ * metadata included, because that is the only reading of "none" that
159
+ * leaves the file in a self-consistent state.
160
+ * - `--client-id` / `--client-secret-env` require an explicit
161
+ * `--auth oauth` in the same invocation. Implying it would make a
162
+ * typo'd flag name mutate the classification.
163
+ */
164
+ export declare function collectMcpSetAuthPlan(name: string | undefined, raw: McpSetAuthRawArgs, homeDir?: string): McpSetAuthPlan;
165
+ export declare function runMcpSetAuth(name: string | undefined, raw: McpSetAuthRawArgs, opts?: {
127
166
  homeDir?: string;
128
167
  json?: boolean;
129
168
  dryRun?: boolean;
@@ -16,7 +16,8 @@ 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";
20
21
  import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
21
22
  import { CodexAdapter } from "../adapters/codex.js";
22
23
  import { KiroAdapter } from "../adapters/kiro.js";
@@ -118,7 +119,8 @@ export function collectMcpListPlan(homeDir = homedir()) {
118
119
  return Object.entries(canonical.mcp.servers).map(([name, def]) => ({
119
120
  name,
120
121
  transport: def.transport,
121
- auth: def.auth,
122
+ auth: isOAuthAuth(def.auth) ? "oauth" : undefined,
123
+ preRegisteredClient: oauthClientMetadata(def.auth).clientId !== undefined ? true : undefined,
122
124
  enabled: def.enabled ?? true,
123
125
  agents: resolveScope(def.agents, canonical.managedAgents),
124
126
  command: def.command,
@@ -148,7 +150,7 @@ export function runMcpList(opts = {}) {
148
150
  else {
149
151
  for (const e of entries) {
150
152
  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}`);
153
+ console.log(`${e.name} (${e.transport}${e.auth === "oauth" ? ", oauth" : ""}${e.preRegisteredClient ? ", pre-registered client" : ""})${e.enabled ? "" : " [disabled]"} — ${scope}`);
152
154
  if (e.env && e.env.length > 0)
153
155
  console.log(` env: ${e.env.join(", ")} (values never read/printed)`);
154
156
  if (e.staticEnv)
@@ -175,6 +177,17 @@ export function parseMcpAddArgs(argv) {
175
177
  enabled: flag("--enabled"),
176
178
  };
177
179
  }
180
+ export function parseMcpSetArgs(argv) {
181
+ const flag = (name) => {
182
+ const i = argv.indexOf(name);
183
+ return i >= 0 ? argv[i + 1] : undefined;
184
+ };
185
+ return {
186
+ auth: flag("--auth"),
187
+ clientId: flag("--client-id"),
188
+ clientSecretEnv: flag("--client-secret-env"),
189
+ };
190
+ }
178
191
  function parseCsv(raw) {
179
192
  if (!raw)
180
193
  return undefined;
@@ -254,12 +267,54 @@ export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
254
267
  }
255
268
  return { name, action: "create", detail: "will add to servers.yaml", def };
256
269
  }
257
- export function collectMcpSetAuthPlan(name, auth, homeDir = homedir()) {
258
- const mode = auth === "oauth" || auth === "none" ? auth : undefined;
270
+ /**
271
+ * Sets or clears the OAuth classification, and optionally the
272
+ * pre-registered client metadata that goes with it
273
+ * (trellis-mcp-oauth-static-client tasks.md 3.1).
274
+ *
275
+ * Three rules the flag interaction has to get right:
276
+ * - A bare `--auth oauth` is a re-classification, not a reset: an entry
277
+ * that already carries a `client_id` keeps it. Silently dropping
278
+ * metadata here would turn "make sure this is OAuth" into "break my
279
+ * Figma setup", which is exactly the kind of invisible precedence
280
+ * `mcp set` exists to avoid.
281
+ * - `--auth none` is the reset — it strips the whole `auth` key,
282
+ * metadata included, because that is the only reading of "none" that
283
+ * leaves the file in a self-consistent state.
284
+ * - `--client-id` / `--client-secret-env` require an explicit
285
+ * `--auth oauth` in the same invocation. Implying it would make a
286
+ * typo'd flag name mutate the classification.
287
+ */
288
+ export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
289
+ const mode = raw.auth === "oauth" || raw.auth === "none" ? raw.auth : undefined;
259
290
  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)"})` };
291
+ 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>]" };
292
+ const hasClientFlags = raw.clientId !== undefined || raw.clientSecretEnv !== undefined;
293
+ const clientFlagsDetail = "--client-id / --client-secret-env require an explicit --auth oauth in the same command";
294
+ if (mode === undefined) {
295
+ // Naming the missing --auth is the actionable error when that is what
296
+ // the user was reaching for; otherwise report the invalid value itself.
297
+ if (hasClientFlags)
298
+ return { name, auth: "none", action: "invalid-input", detail: clientFlagsDetail };
299
+ return { name, auth: "none", action: "invalid-input", detail: `--auth must be "oauth" or "none" (got ${raw.auth ?? "(missing)"})` };
300
+ }
301
+ if (hasClientFlags && mode !== "oauth") {
302
+ return { name, auth: mode, action: "invalid-input", detail: clientFlagsDetail };
303
+ }
304
+ if (raw.clientId === "") {
305
+ return { name, auth: mode, action: "invalid-input", detail: "--client-id must not be empty" };
306
+ }
307
+ if (raw.clientSecretEnv !== undefined && !isValidSecretVarName(raw.clientSecretEnv)) {
308
+ // The value is deliberately not echoed: a name-shaped field failing
309
+ // this check is at best a typo and at worst a pasted credential
310
+ // (design.md D2).
311
+ return {
312
+ name,
313
+ auth: mode,
314
+ action: "invalid-input",
315
+ detail: "--client-secret-env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value",
316
+ };
317
+ }
263
318
  const canonical = loadCanonicalSource(homeDir);
264
319
  const current = canonical.mcp.servers[name];
265
320
  if (!current)
@@ -267,19 +322,61 @@ export function collectMcpSetAuthPlan(name, auth, homeDir = homedir()) {
267
322
  if (mode === "oauth" && !current.url) {
268
323
  return { name, auth: mode, action: "invalid-input", detail: "auth: oauth requires an http or sse MCP server" };
269
324
  }
270
- const next = mode === "oauth" ? { ...current, auth: "oauth" } : (() => {
325
+ // Same load-time guard as `assertAuthMetadataClean`, applied at write
326
+ // time so an offending value never reaches the file in the first place.
327
+ const offending = [
328
+ ["client-id", raw.clientId],
329
+ ["client-secret-env", raw.clientSecretEnv],
330
+ ].find(([, value]) => value !== undefined && canonical.secretsPolicy.rejectPatterns.some((pattern) => pattern.test(value)));
331
+ if (offending) {
332
+ return {
333
+ name,
334
+ auth: mode,
335
+ action: "invalid-input",
336
+ 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`,
337
+ };
338
+ }
339
+ let next;
340
+ if (mode === "none") {
271
341
  const { auth: _auth, ...withoutAuth } = current;
272
- return withoutAuth;
273
- })();
342
+ next = withoutAuth;
343
+ }
344
+ else if (!hasClientFlags && isOAuthAuth(current.auth)) {
345
+ // Already classified and no new metadata: a true no-op. Rewriting the
346
+ // scalar into a metadata-less object here would churn the file to say
347
+ // exactly what it already said (design.md D1 makes them equivalent).
348
+ next = current;
349
+ }
350
+ else if (!hasClientFlags) {
351
+ // Newly classified, nothing to record — the scalar is the documented
352
+ // shorthand, so write the smaller of two equivalent forms.
353
+ next = { ...current, auth: "oauth" };
354
+ }
355
+ else {
356
+ const existing = oauthClientMetadata(current.auth);
357
+ // Per-field override, not replacement: `--client-id` alone keeps a
358
+ // `client_secret_env` that is already configured.
359
+ const clientId = raw.clientId ?? existing.clientId;
360
+ const clientSecretEnv = raw.clientSecretEnv ?? existing.clientSecretEnv;
361
+ next = {
362
+ ...current,
363
+ auth: {
364
+ kind: "oauth",
365
+ ...(clientId !== undefined ? { clientId } : {}),
366
+ ...(clientSecretEnv !== undefined ? { clientSecretEnv } : {}),
367
+ },
368
+ };
369
+ }
274
370
  if (JSON.stringify(current) === JSON.stringify(next))
275
371
  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 };
372
+ const changed = hasClientFlags ? " and client metadata" : "";
373
+ return { name, auth: mode, action: "updated", detail: `set auth to ${mode}${changed}`, def: next };
277
374
  }
278
- export function runMcpSetAuth(name, auth, opts = {}) {
375
+ export function runMcpSetAuth(name, raw, opts = {}) {
279
376
  const homeDir = opts.homeDir ?? homedir();
280
377
  let plan;
281
378
  try {
282
- plan = collectMcpSetAuthPlan(name, auth, homeDir);
379
+ plan = collectMcpSetAuthPlan(name, raw, homeDir);
283
380
  }
284
381
  catch (err) {
285
382
  console.error(err instanceof Error ? err.message : String(err));
@@ -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
@@ -10,13 +10,23 @@ import type { BackupSession } from "../lib/backup.js";
10
10
  * The on-disk shape for one server entry — `static_env`/`env_aliases`
11
11
  * (snake_case, like every other multi-word key across `.trellis/*.yaml`)
12
12
  * are translated to `McpServerDef.staticEnv`/`envAliases` (camelCase)
13
- * below; every other field happens to already be a single word, so no
14
- * server-def field needed this treatment before
15
- * (trellis-mcp-static-env-and-disabled-servers, trellis-migrate-env-var-alias).
13
+ * below, and `auth`'s object form carries `client_id`/`client_secret_env`
14
+ * the same way (trellis-mcp-static-env-and-disabled-servers,
15
+ * trellis-migrate-env-var-alias, trellis-mcp-oauth-static-client).
16
16
  */
17
- type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases"> & {
17
+ type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth"> & {
18
18
  static_env?: Record<string, string>;
19
19
  env_aliases?: Record<string, string>;
20
+ auth?: McpAuthYaml;
21
+ };
22
+ /** The on-disk `auth`: the `oauth` scalar exactly as before, or the object
23
+ * form whose keys are snake_case like every other multi-word key in
24
+ * `.trellis/*.yaml`. Values are `unknown` until `fromAuthYaml` validates
25
+ * them — a YAML cast is optimistic, validation is this module's job. */
26
+ type McpAuthYaml = string | {
27
+ kind?: unknown;
28
+ client_id?: unknown;
29
+ client_secret_env?: unknown;
20
30
  };
21
31
  /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
22
32
  * `undefined` fields so the written YAML never gets a literal `null`
@@ -127,13 +137,6 @@ export declare function ensureGitignoreEntry(gitignorePath: string, line: string
127
137
  * `export` prefix of its own.
128
138
  */
129
139
  export declare function ensureShellEnvSource(rcPath: string, envFilePath: string, backup?: BackupSession): void;
130
- /**
131
- * `homeDir` defaults to the real `~` and is only ever overridden for tests
132
- * and `scripts/sandbox.sh` — the same seam P0's probes use
133
- * (src/probes/*.ts) and for the same reason: never touch a developer's
134
- * real dotfiles from a test. It is not a workspace/project root — see
135
- * specs/canonical-source-loading's global-only requirement, which this
136
- * parameter does not weaken.
137
- */
140
+ /** Load the global canonical source from the supplied home directory. */
138
141
  export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
139
142
  export {};
@@ -8,26 +8,75 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
8
8
  import { homedir } from "node:os";
9
9
  import { basename, dirname, join } from "node:path";
10
10
  import { isMap, isSeq, parse as parseYaml, parseDocument } from "yaml";
11
- import { ALL_AGENTS } from "./types.js";
12
- function fromServerDefYaml(def) {
13
- const { static_env, env_aliases, ...rest } = def;
11
+ import { ALL_AGENTS, oauthClientMetadata } from "./types.js";
12
+ import { isValidSecretVarName } from "../lib/secretEnv.js";
13
+ function fromServerDefYaml(serverName, def) {
14
+ const { static_env, env_aliases, auth, ...rest } = def;
14
15
  const out = { ...rest };
15
16
  if (static_env)
16
17
  out.staticEnv = static_env;
17
18
  if (env_aliases)
18
19
  out.envAliases = env_aliases;
20
+ if (auth !== undefined)
21
+ out.auth = fromAuthYaml(serverName, auth);
22
+ return out;
23
+ }
24
+ /**
25
+ * `auth` translation and shape validation
26
+ * (trellis-mcp-oauth-static-client tasks 1.1/1.3): the scalar and the
27
+ * object form share one discriminator — `kind: oauth` — and the object
28
+ * accepts exactly `kind`, `client_id`, `client_secret_env`. Everything
29
+ * else is refused here at load, naming the field but never echoing the
30
+ * value: an invalid `client_secret_env` is at best a typo, at worst a
31
+ * pasted secret.
32
+ */
33
+ function fromAuthYaml(serverName, auth) {
34
+ if (auth === "oauth")
35
+ return "oauth";
36
+ if (typeof auth !== "object" || auth === null) {
37
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env? }`);
38
+ }
39
+ const { kind, client_id, client_secret_env, ...extra } = auth;
40
+ const unknownKey = Object.keys(extra)[0];
41
+ if (unknownKey !== undefined) {
42
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, client_id, client_secret_env`);
43
+ }
44
+ if (kind !== "oauth") {
45
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.kind must be "oauth" — kind is the only recognized discriminator`);
46
+ }
47
+ if (client_id !== undefined && typeof client_id !== "string") {
48
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_id must be a string`);
49
+ }
50
+ if (client_secret_env !== undefined && (typeof client_secret_env !== "string" || !isValidSecretVarName(client_secret_env))) {
51
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
52
+ }
53
+ const out = { kind: "oauth" };
54
+ if (typeof client_id === "string")
55
+ out.clientId = client_id;
56
+ if (typeof client_secret_env === "string")
57
+ out.clientSecretEnv = client_secret_env;
19
58
  return out;
20
59
  }
21
60
  /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
22
61
  * `undefined` fields so the written YAML never gets a literal `null`
23
62
  * for an omitted optional. */
24
63
  export function toServerDefYaml(def) {
25
- const { staticEnv, envAliases, ...rest } = def;
64
+ const { staticEnv, envAliases, auth, ...rest } = def;
26
65
  const out = { ...rest };
27
66
  if (staticEnv)
28
67
  out.static_env = staticEnv;
29
68
  if (envAliases)
30
69
  out.env_aliases = envAliases;
70
+ if (auth !== undefined) {
71
+ out.auth =
72
+ typeof auth === "string"
73
+ ? auth
74
+ : {
75
+ kind: auth.kind,
76
+ ...(auth.clientId !== undefined ? { client_id: auth.clientId } : {}),
77
+ ...(auth.clientSecretEnv !== undefined ? { client_secret_env: auth.clientSecretEnv } : {}),
78
+ };
79
+ }
31
80
  for (const key of Object.keys(out)) {
32
81
  if (out[key] === undefined)
33
82
  delete out[key];
@@ -83,7 +132,7 @@ function loadServersYaml(path) {
83
132
  return { servers: {}, knownHostInjected: [] };
84
133
  }
85
134
  const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
86
- const servers = Object.fromEntries(Object.entries(parsed.servers ?? {}).map(([name, def]) => [name, fromServerDefYaml(def)]));
135
+ const servers = Object.fromEntries(Object.entries(parsed.servers ?? {}).map(([name, def]) => [name, fromServerDefYaml(name, def)]));
87
136
  return {
88
137
  servers,
89
138
  knownHostInjected: parsed.known_host_injected ?? [],
@@ -452,7 +501,7 @@ export function ensureShellEnvSource(rcPath, envFilePath, backup) {
452
501
  * all managed agents by default. A recognized but empty list is left as
453
502
  * authored (an explicitly agent-less scope), not coerced to "all". */
454
503
  /** Missing file and `agents: []` both resolve to `[]` — zero managed
455
- * agents (D1), never "everyone." An unrecognized id is dropped with a
504
+ * agents, never "everyone." An unrecognized id is dropped with a
456
505
  * diagnostic, same posture as an unrecognized scope.yaml agent id. */
457
506
  function loadManagedYaml(path, diagnostics) {
458
507
  if (!existsSync(path)) {
@@ -480,17 +529,36 @@ function validAgentIds(scope) {
480
529
  return scope.every((id) => ALL_AGENTS.includes(id));
481
530
  }
482
531
  /**
483
- * `homeDir` defaults to the real `~` and is only ever overridden for tests
484
- * and `scripts/sandbox.sh` — the same seam P0's probes use
485
- * (src/probes/*.ts) and for the same reason: never touch a developer's
486
- * real dotfiles from a test. It is not a workspace/project root — see
487
- * specs/canonical-source-loading's global-only requirement, which this
488
- * parameter does not weaken.
532
+ * Load-time literal-value scan for OAuth client metadata
533
+ * (trellis-mcp-oauth-static-client task 1.2). `secrets audit` reports
534
+ * whole-file pattern hits as findings; this check goes further for the
535
+ * two fields a pasted secret is most likely to reach and refuses the
536
+ * load outright — the offending server and field are named, the value
537
+ * never printed.
489
538
  */
539
+ function assertAuthMetadataClean(mcp, policy) {
540
+ for (const [name, def] of Object.entries(mcp.servers)) {
541
+ const meta = oauthClientMetadata(def.auth);
542
+ const fields = [
543
+ ["client_id", meta.clientId],
544
+ ["client_secret_env", meta.clientSecretEnv],
545
+ ];
546
+ for (const [field, value] of fields) {
547
+ if (value === undefined)
548
+ continue;
549
+ for (const pattern of policy.rejectPatterns) {
550
+ if (pattern.test(value)) {
551
+ throw new Error(`mcp/servers.yaml: "${name}" auth.${field} 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`);
552
+ }
553
+ }
554
+ }
555
+ }
556
+ }
557
+ /** Load the global canonical source from the supplied home directory. */
490
558
  export function loadCanonicalSource(homeDir = homedir()) {
491
559
  const root = trellisRoot(homeDir);
492
560
  if (!existsSync(root)) {
493
- throw new Error(`No canonical source at ${root}. Create it before running trellis sync — see docs/architecture.md's canonical schema.`);
561
+ throw new Error(`No canonical source at ${root}. Run trellis init before trellis sync.`);
494
562
  }
495
563
  const diagnostics = [];
496
564
  const managedAgents = loadManagedYaml(join(root, "managed.yaml"), diagnostics);
@@ -527,17 +595,17 @@ export function loadCanonicalSource(homeDir = homedir()) {
527
595
  }
528
596
  }
529
597
  }
598
+ const mcp = loadServersYaml(join(root, "mcp", "servers.yaml"));
599
+ const secretsPolicy = loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir);
600
+ assertAuthMetadataClean(mcp, secretsPolicy);
530
601
  return {
531
602
  instructionsFile: join(root, "agents.md"),
532
603
  managedAgents,
533
604
  skills,
534
605
  agents,
535
606
  memories,
536
- mcp: loadServersYaml(join(root, "mcp", "servers.yaml")),
537
- // P2's pre-write guard (src/adapters/mcpPlan.ts) uses its own narrow,
538
- // hardcoded floor instead of this field — see design.md D1 in
539
- // trellis-secrets-audit-p3.
540
- secretsPolicy: loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir),
607
+ mcp,
608
+ secretsPolicy,
541
609
  diagnostics,
542
610
  };
543
611
  }
@@ -6,6 +6,29 @@
6
6
  */
7
7
  export type Transport = "stdio" | "http" | "sse";
8
8
  export type McpAuthMode = "oauth";
9
+ /**
10
+ * Object form of the OAuth classification (trellis-mcp-oauth-static-client
11
+ * design.md D1). `kind` is the only discriminator; `client_id` is a public
12
+ * literal by design; `client_secret_env` is a variable NAME resolved
13
+ * through the secrets policy at authorization time. There is deliberately
14
+ * no field for a secret value in any form.
15
+ */
16
+ export interface McpAuthConfig {
17
+ kind: McpAuthMode;
18
+ clientId?: string;
19
+ clientSecretEnv?: string;
20
+ }
21
+ /** True when `auth` classifies the server as OAuth, in either form. Every
22
+ * former `def.auth === "oauth"` check must go through this now that the
23
+ * object form exists — a strict-equality check would silently strip the
24
+ * OAuth routing from metadata-bearing servers. */
25
+ export declare function isOAuthAuth(auth: McpAuthMode | McpAuthConfig | undefined): boolean;
26
+ /** The pre-registered client metadata a classification carries. A scalar
27
+ * carries none — both forms route identically (design.md D1). */
28
+ export declare function oauthClientMetadata(auth: McpAuthMode | McpAuthConfig | undefined): {
29
+ clientId?: string;
30
+ clientSecretEnv?: string;
31
+ };
9
32
  export type AgentId = "claude-code" | "codex" | "kiro" | "pi" | "kimi-code" | "zcode";
10
33
  export declare const ALL_AGENTS: readonly AgentId[];
11
34
  /**
@@ -28,8 +51,11 @@ export declare function resolveScope(scope: Scope, managedAgents: readonly Agent
28
51
  export interface McpServerDef {
29
52
  transport: Transport;
30
53
  /** Explicit authorization classification. Absence means ordinary MCP;
31
- * Trellis never infers OAuth from a URL or a transient 401. */
32
- auth?: McpAuthMode;
54
+ * Trellis never infers OAuth from a URL or a transient 401. The scalar
55
+ * form and a metadata-less object are equivalent — metadata extends the
56
+ * authorization flow, it never changes routing
57
+ * (trellis-mcp-oauth-static-client design.md D1). */
58
+ auth?: McpAuthMode | McpAuthConfig;
33
59
  /** stdio only */
34
60
  command?: string;
35
61
  args?: string[];
@@ -4,6 +4,25 @@
4
4
  * for what a "canonical source" object looks like in memory; the YAML/MD
5
5
  * files on disk are its serialization, not the other way around.
6
6
  */
7
+ /** True when `auth` classifies the server as OAuth, in either form. Every
8
+ * former `def.auth === "oauth"` check must go through this now that the
9
+ * object form exists — a strict-equality check would silently strip the
10
+ * OAuth routing from metadata-bearing servers. */
11
+ export function isOAuthAuth(auth) {
12
+ return auth === "oauth" || (typeof auth === "object" && auth !== null && auth.kind === "oauth");
13
+ }
14
+ /** The pre-registered client metadata a classification carries. A scalar
15
+ * carries none — both forms route identically (design.md D1). */
16
+ export function oauthClientMetadata(auth) {
17
+ if (typeof auth !== "object" || auth === null)
18
+ return {};
19
+ const meta = {};
20
+ if (auth.clientId !== undefined)
21
+ meta.clientId = auth.clientId;
22
+ if (auth.clientSecretEnv !== undefined)
23
+ meta.clientSecretEnv = auth.clientSecretEnv;
24
+ return meta;
25
+ }
7
26
  export const ALL_AGENTS = [
8
27
  "claude-code",
9
28
  "codex",