@yagni-app/code-staging 1.1.6-staging.1445.1 → 1.1.6-staging.1448.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -957,7 +957,29 @@ export async function registerYagni(pi, deps = {}) {
957
957
  }),
958
958
  prefixConsultTransportAttribution: () => attributionHeaders(env),
959
959
  onPrefixConsult: makePrefixErrorSink(),
960
- guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
960
+ // The Guardian consult rides the same in-process direct transport as
961
+ // the prefix consult: one /v1/chat/completions POST per review,
962
+ // no child spawn, no grounded context. Kill switches compose: the
963
+ // workspace/env kill switch still disables the consult entirely (checked
964
+ // in the gate), and YAGNI_GUARDIAN_LEGACY_TRANSPORT forces the proven
965
+ // child-spawn path as a targeted transport rollback (reviewCommand
966
+ // itself reads the knob — the wiring here never needs to know). Transport
967
+ // absence otherwise falls back to the legacy child path, which never
968
+ // happens in production (baseUrl/getTokenFn are session-lifetime
969
+ // values) — the fallback is the injectable test seam.
970
+ guardianReview: (command, deps) => reviewCommand(command, {
971
+ ...deps,
972
+ modelTier: guardianTier,
973
+ ...(guardianDisabled
974
+ ? {}
975
+ : {
976
+ transport: {
977
+ baseUrl,
978
+ getToken: getTokenFn,
979
+ attribution: () => attributionHeaders(env),
980
+ },
981
+ }),
982
+ }),
961
983
  onGuardianReview: (ev) => {
962
984
  guardianLogSink(ev);
963
985
  // A malformed verdict (repair FAILED — still really failing) is a real
@@ -26,6 +26,12 @@ import { type StoredOAuthEntry } from "./authStore.js";
26
26
  export interface AuthDeps {
27
27
  /** Open a URL in the browser; injected for tests, default is a best-effort opener. */
28
28
  openUrl?: (url: string) => Promise<void>;
29
+ /**
30
+ * Called with the authorization URL just before the browser is opened, so
31
+ * the caller can show it: the manual fallback when the opener fails or
32
+ * mangles the link.
33
+ */
34
+ onAuthorizationUrl?: (url: string) => void;
29
35
  /** Traditional fetch for revocation (SDK auth uses its own fetch). */
30
36
  fetch?: typeof fetch;
31
37
  }
@@ -22,10 +22,10 @@ import { createServer, get as httpGet } from "node:http";
22
22
  import { randomBytes } from "node:crypto";
23
23
  import { auth as sdkAuth, discoverOAuthServerInfo } from "@modelcontextprotocol/sdk/client/auth.js";
24
24
  import { expandServerEnv } from "./config.js";
25
- import { runningUnderTest } from "../crashReport.js";
26
25
  import { getStoredOAuthEntry, updateStoredOAuthEntry } from "./authStore.js";
27
26
  import { deleteStoredOAuthEntry as clearStoredOAuthEntry } from "./authStore.js";
28
27
  import { logMcpEvent, redactSensitiveUrlParams } from "./log.js";
28
+ import { defaultOpenUrl } from "./openUrl.js";
29
29
  import { renderAuthErrorPage, renderAuthStateMismatchPage, renderAuthSuccessPage, } from "./callbackPage.js";
30
30
  /** Cancellation via an AbortSignal (Ctrl-C / Esc in the /mcp panel). */
31
31
  export class AuthenticationCancelledError extends Error {
@@ -207,8 +207,15 @@ export class YagniAuthProvider {
207
207
  async saveClientInformation(info) {
208
208
  updateStoredOAuthEntry(this.serverName, this.config, (entry) => {
209
209
  entry.clientId = info.client_id;
210
- if (info.client_secret != null)
210
+ if (info.client_secret != null) {
211
211
  entry.clientSecret = info.client_secret;
212
+ entry.clientSecretSource = "dcr";
213
+ }
214
+ else if (entry.clientSecretSource === "dcr") {
215
+ // A previous registration's secret must not outlive its client.
216
+ delete entry.clientSecret;
217
+ delete entry.clientSecretSource;
218
+ }
212
219
  // The SDK only calls this from dynamic client registration, so the
213
220
  // redirect this provider advertised is exactly what the server pinned.
214
221
  entry.redirectUri = this.redirectUri;
@@ -325,7 +332,15 @@ export async function authenticate(serverName, config, deps = {}, opts = {}) {
325
332
  // that errors before redirecting cannot wedge the command loop.
326
333
  const state = await provider.state();
327
334
  const codePromise = waitForCode(listener, state, { ...bounds, onEvent: (evt, fields) => logMcpEvent(serverName, evt, { ...fields, redirectUri }) });
328
- await openUrl(authorizationUrl);
335
+ deps.onAuthorizationUrl?.(authorizationUrl);
336
+ try {
337
+ await openUrl(authorizationUrl);
338
+ }
339
+ catch (err) {
340
+ // Any opener, injected or default: the flow keeps waiting, because the
341
+ // user has the printed URL. The log records why the window is missing.
342
+ logMcpEvent(serverName, "oauth_browser_open_failed", { detail: errorText(err) });
343
+ }
329
344
  const code = await loggedPhase(serverName, "callback", { redirectUri }, () => codePromise);
330
345
  if (!code) {
331
346
  logMcpEvent(serverName, "oauth_result", { phase: "callback", outcome: "provider_denied", errorDescription: "no authorization code received", redirectUri });
@@ -397,26 +412,39 @@ async function trySilentRefresh(serverName, config, stored, serverUrl, bounds) {
397
412
  * when the listener we hold is on a different port (the pinned one was
398
413
  * unavailable, or `oauth.callbackPort` changed) the client is dropped so the
399
414
  * SDK re-registers for the port we will actually answer on. Ports, not
400
- * strings, are what the registration pins. A pre-registered `oauth.clientId`
401
- * never carries `redirectUri` and is never touched. A drop that cannot be
402
- * persisted fails the flow: the alternative is sending a redirect the stored
403
- * client is not registered for, which strict servers refuse.
415
+ * strings, are what the registration pins. A stored client with NO redirect
416
+ * on record was issued by a CLI from before pinning: it is bound to a port we
417
+ * cannot know, so it is dropped too (`legacy_unpinned`). The auth store only
418
+ * ever holds DCR-issued ids; a pre-registered `oauth.clientId` lives in the
419
+ * config and is never touched. A drop that cannot be persisted fails the
420
+ * flow: the alternative is sending a redirect the stored client is not
421
+ * registered for, which strict servers refuse.
404
422
  */
405
423
  function dropPinnedClientIfPortChanged(serverName, config, stored, listener, redirectUri) {
406
- if (!stored?.clientId || !stored.redirectUri)
424
+ if (!stored?.clientId)
407
425
  return;
408
- if (portOfRedirectUri(stored.redirectUri) === listener.port)
426
+ const legacy = !stored.redirectUri;
427
+ if (!legacy && portOfRedirectUri(stored.redirectUri) === listener.port)
409
428
  return;
410
- const reason = config.oauth?.callbackPort !== undefined ? "callback_port_changed" : "pinned_port_unavailable";
429
+ const reason = legacy ? "legacy_unpinned" : config.oauth?.callbackPort !== undefined ? "callback_port_changed" : "pinned_port_unavailable";
411
430
  const { errors } = updateStoredOAuthEntry(serverName, config, (entry) => {
412
431
  delete entry.clientId;
413
- delete entry.clientSecret;
414
432
  delete entry.redirectUri;
433
+ // The secret goes with the DCR client unless it is the operator's
434
+ // (`--client-secret`), which belongs to the config's pre-registered
435
+ // client and lives nowhere else. Entries from before provenance was
436
+ // recorded are read the way the old CLI wrote them: an operator's secret
437
+ // only ever appeared next to a config `oauth.clientId`.
438
+ const operators = entry.clientSecretSource === "operator" || (entry.clientSecretSource === undefined && Boolean(config.oauth?.clientId));
439
+ if (!operators) {
440
+ delete entry.clientSecret;
441
+ delete entry.clientSecretSource;
442
+ }
415
443
  });
416
444
  if (errors.length > 0) {
417
445
  closeQuietly(listener.server);
418
446
  logMcpEvent(serverName, "oauth_client_reregister_failed", { reason, previousRedirectUri: stored.redirectUri, redirectUri, error: errors[0] });
419
- throw new Error(`could not reset the OAuth client registration for ${serverName} (${errors[0]}); its client is pinned to ${stored.redirectUri}`);
447
+ throw new Error(`could not reset the OAuth client registration for ${serverName} (${errors[0]}); its client is pinned to ${stored.redirectUri ?? "a redirect this CLI has no record of"}`);
420
448
  }
421
449
  logMcpEvent(serverName, "oauth_client_reregister", {
422
450
  reason,
@@ -684,27 +712,6 @@ export async function revokeServerTokens(serverName, config, metadata, clientInf
684
712
  await revokeOne(entry.refreshToken, "refresh_token");
685
713
  await revokeOne(entry.accessToken, "access_token");
686
714
  }
687
- /** Best-effort browser open without a shell (injected in tests).
688
- *
689
- * A no-op under the test runner (crashReport.ts's runningUnderTest): a test
690
- * that forgets to inject `openUrl` must not pop a real browser window on the
691
- * developer's machine. Same philosophy as crash-report suppression — make the
692
- * whole class of leak impossible rather than fixing one call site at a time.
693
- */
694
- async function defaultOpenUrl(url) {
695
- if (runningUnderTest())
696
- return;
697
- const { spawn } = await import("node:child_process");
698
- const platform = process.platform;
699
- const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
700
- const args = platform === "win32" ? ["/c", "start", "", url] : [url];
701
- try {
702
- spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
703
- }
704
- catch {
705
- // best-effort — the URL is surfaced to the user by the panel caller
706
- }
707
- }
708
715
  /**
709
716
  * Revoke tokens + clear stored credentials for a server being removed. Runs
710
717
  * RFC 9728/8414 discovery to find the revocation endpoint, revokes (refresh →
@@ -15,10 +15,26 @@ import type { McpHttpServerConfig } from "./config.js";
15
15
  export interface StoredOAuthEntry {
16
16
  serverName: string;
17
17
  serverUrl: string;
18
- /** Client id from DCR, or a pre-registered `oauth.clientId`. */
18
+ /**
19
+ * Client id issued by dynamic client registration. A pre-registered id
20
+ * lives in the config (`oauth.clientId`) and is never written here, which
21
+ * is what lets the interactive flow drop a stored id whose redirect is
22
+ * unknown (auth.ts `legacy_unpinned`) without discarding an operator's.
23
+ */
19
24
  clientId?: string;
20
- /** Pre-registered client secret (`yagni mcp add --client-secret`), NOT a DCR secret. */
25
+ /**
26
+ * Client secret: the one DCR issued alongside `clientId` when the server
27
+ * returns one (Atlassian's current authorization server does), or a
28
+ * pre-registered one from `yagni mcp add --client-secret`.
29
+ */
21
30
  clientSecret?: string;
31
+ /**
32
+ * Who issued `clientSecret`: `dcr` (it belongs to `clientId` and is
33
+ * dropped with it) or `operator` (`--client-secret`; it belongs to the
34
+ * config's `oauth.clientId` and survives a DCR client drop). Absent on
35
+ * entries written before this field existed.
36
+ */
37
+ clientSecretSource?: "dcr" | "operator";
22
38
  /**
23
39
  * The redirect_uri registered with the authorization server when `clientId`
24
40
  * came from dynamic client registration. Strict servers (Atlassian, most
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Open a URL in the user's default browser without going through a shell.
3
+ *
4
+ * The OAuth authorize URL is long and full of `&`. `cmd /c start <url>` on
5
+ * Windows re-parses its arguments as a shell line, so cmd.exe split the URL
6
+ * at the first `&` and the browser opened `…/authorize?response_type=code`;
7
+ * Atlassian ID then showed "The authorize request was incomplete or invalid"
8
+ * (2026-09-21 customer report). The same re-parse would let a hostile
9
+ * authorization endpoint execute commands. This mirrors the CLI login's
10
+ * opener (`yagni-code-cli/src/login.ts`; the extension cannot import that
11
+ * package, and the CLI's login tests pin the two resolvers together): the
12
+ * URL is always one literal argv element, and on Windows rundll32's
13
+ * FileProtocolHandler opens the default browser with no cmd.exe in between.
14
+ *
15
+ * A failure to open is thrown, never swallowed here: `authenticate()` logs
16
+ * it and keeps waiting, because the user still has the printed URL.
17
+ */
18
+ export type OpenRunner = (cmd: string, args: string[]) => Promise<void>;
19
+ export declare function resolveOpenCommand(url: string, platform?: NodeJS.Platform): {
20
+ cmd: string;
21
+ args: string[];
22
+ };
23
+ /**
24
+ * Hand an http(s) URL to the OS opener. Anything else (file:, javascript:,
25
+ * a custom scheme, garbage) is refused without invoking the runner; the
26
+ * refused URL is named in the error, since it carries no OAuth state. A
27
+ * runner failure rejects with the command named, so the log can tell a
28
+ * missing opener from a user who ignored the window.
29
+ */
30
+ export declare function openUrlWith(url: string, runner: OpenRunner, platform?: NodeJS.Platform): Promise<void>;
31
+ /**
32
+ * The real runner: a detached child with no stdio. `spawn` reports a missing
33
+ * binary (ENOENT) asynchronously; waiting for 'spawn' or 'error' turns that
34
+ * into a rejection instead of an unhandled 'error' event that would take
35
+ * the session down.
36
+ */
37
+ export declare const spawnRunner: OpenRunner;
38
+ /**
39
+ * The default browser opener. A no-op under the test runner: a test that
40
+ * forgets to inject `openUrl` must not pop a real browser window.
41
+ */
42
+ export declare function defaultOpenUrl(url: string): Promise<void>;
43
+ //# sourceMappingURL=openUrl.d.ts.map
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Open a URL in the user's default browser without going through a shell.
3
+ *
4
+ * The OAuth authorize URL is long and full of `&`. `cmd /c start <url>` on
5
+ * Windows re-parses its arguments as a shell line, so cmd.exe split the URL
6
+ * at the first `&` and the browser opened `…/authorize?response_type=code`;
7
+ * Atlassian ID then showed "The authorize request was incomplete or invalid"
8
+ * (2026-09-21 customer report). The same re-parse would let a hostile
9
+ * authorization endpoint execute commands. This mirrors the CLI login's
10
+ * opener (`yagni-code-cli/src/login.ts`; the extension cannot import that
11
+ * package, and the CLI's login tests pin the two resolvers together): the
12
+ * URL is always one literal argv element, and on Windows rundll32's
13
+ * FileProtocolHandler opens the default browser with no cmd.exe in between.
14
+ *
15
+ * A failure to open is thrown, never swallowed here: `authenticate()` logs
16
+ * it and keeps waiting, because the user still has the printed URL.
17
+ */
18
+ import { runningUnderTest } from "../crashReport.js";
19
+ /** Keeps a failure's detail to one mcp.log-sized line (auth.ts caps at the same length). */
20
+ const DETAIL_MAX = 300;
21
+ export function resolveOpenCommand(url, platform = process.platform) {
22
+ if (platform === "darwin")
23
+ return { cmd: "open", args: [url] };
24
+ if (platform === "win32")
25
+ return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
26
+ return { cmd: "xdg-open", args: [url] };
27
+ }
28
+ /**
29
+ * Hand an http(s) URL to the OS opener. Anything else (file:, javascript:,
30
+ * a custom scheme, garbage) is refused without invoking the runner; the
31
+ * refused URL is named in the error, since it carries no OAuth state. A
32
+ * runner failure rejects with the command named, so the log can tell a
33
+ * missing opener from a user who ignored the window.
34
+ */
35
+ export async function openUrlWith(url, runner, platform = process.platform) {
36
+ let parsed;
37
+ try {
38
+ parsed = new URL(url);
39
+ }
40
+ catch {
41
+ /* refused below */
42
+ }
43
+ if (!parsed || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
44
+ throw new Error(`refused: not an http(s) URL (${url})`.slice(0, DETAIL_MAX));
45
+ }
46
+ const { cmd, args } = resolveOpenCommand(url, platform);
47
+ try {
48
+ await runner(cmd, args);
49
+ }
50
+ catch (err) {
51
+ throw new Error(`${cmd}: ${err instanceof Error ? err.message : String(err)}`.slice(0, DETAIL_MAX));
52
+ }
53
+ }
54
+ /**
55
+ * The real runner: a detached child with no stdio. `spawn` reports a missing
56
+ * binary (ENOENT) asynchronously; waiting for 'spawn' or 'error' turns that
57
+ * into a rejection instead of an unhandled 'error' event that would take
58
+ * the session down.
59
+ */
60
+ export const spawnRunner = async (cmd, args) => {
61
+ const { spawn } = await import("node:child_process");
62
+ await new Promise((resolve, reject) => {
63
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
64
+ child.once("error", reject);
65
+ child.once("spawn", () => {
66
+ child.unref();
67
+ resolve();
68
+ });
69
+ });
70
+ };
71
+ /**
72
+ * The default browser opener. A no-op under the test runner: a test that
73
+ * forgets to inject `openUrl` must not pop a real browser window.
74
+ */
75
+ export async function defaultOpenUrl(url) {
76
+ if (runningUnderTest())
77
+ return;
78
+ await openUrlWith(url, spawnRunner);
79
+ }
80
+ //# sourceMappingURL=openUrl.js.map
@@ -106,6 +106,10 @@ export function registerMcpPanel(pi, manager, configErrors) {
106
106
  await doReconnect(sub[1], ctx.ui);
107
107
  return;
108
108
  }
109
+ if ((sub[0] === "connect" || sub[0] === "authenticate") && sub[1]) {
110
+ await doAuthenticate(sub[1], ctx.ui, ctx.signal);
111
+ return;
112
+ }
109
113
  if (sub[0] === "enable" || sub[0] === "disable") {
110
114
  await doToggle(sub[0], sub.slice(1), ctx.ui);
111
115
  return;
@@ -124,7 +128,9 @@ export function registerMcpPanel(pi, manager, configErrors) {
124
128
  return;
125
129
  }
126
130
  ui.notify(`Starting OAuth for ${name} — your browser will open…`, "info");
127
- const result = await manager.authenticateServer(name, signal);
131
+ const result = await manager.authenticateServer(name, signal, {
132
+ onAuthorizationUrl: (url) => ui.notify(`Browser didn't open? Open this URL to continue:\n${url}`, "info"),
133
+ });
128
134
  if (result?.status === "connected") {
129
135
  await refreshForServer(name);
130
136
  ui.notify(`✓ ${name} authenticated and connected`, "info");
@@ -2,9 +2,17 @@
2
2
  * The Guardian — LLM auto-review of prompt-band bash commands (YAG-504).
3
3
  *
4
4
  * Pure half: verdict types, state tracking, circuit breaker, JSON parsing.
5
- * I/O half: reviewCommand spawns a locked-down child pi (same runStage seam
6
- * the advisor uses) on the efficient tier with read-only tools and a risk
7
- * policy persona. The child returns a JSON verdict; the gate acts on it.
5
+ * I/O half: reviewCommand consults the guardian persona over the in-process
6
+ * direct transport (one /v1/chat/completions POST, same seam the prefix
7
+ * consult uses) on the efficient tier. The legacy child-spawn path
8
+ * (runStage, the advisor's seam) remains as the injectable test fallback.
9
+ * The consult returns a JSON verdict; the gate acts on it.
10
+ *
11
+ * The one deliberate verdict-semantics edge of the direct consult: it has no
12
+ * tools, so a command referencing an opaque script/config is judged from
13
+ * command text alone where the child could read the file first (Claude
14
+ * Code's equivalent check is also tool-less). Verdict parsing, the repair
15
+ * ladder, and the gate's handling are unchanged.
8
16
  *
9
17
  * Same pure/IO split as advisor.ts (decideConsult pure, askAdvisorTool I/O)
10
18
  * and permission.ts (decideGate pure, registerPermissionGate I/O), for the
@@ -46,14 +54,15 @@ export interface GuardianLimits {
46
54
  }
47
55
  export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
48
56
  /**
49
- * Client-side safety margin on top of the backend's advertised wall ceiling
50
- * (YAG-562). The backend now owns the real timeout (first-output + wall) and
51
- * retries once, so the client must NOT pre-empt it; it derives its own
52
- * deadline from the catalog's `guardianTimeoutMs * attempts` plus this margin
53
- * for child boot + read-tool round-trips + teardown, and treats that bound as
54
- * a last-resort floor, not the normal path.
57
+ * Client-side safety margin on top of the backend's advertised wall ceiling.
58
+ * The backend owns the real timeout and retries, so the client must
59
+ * NOT pre-empt it; it derives its own deadline from the catalog's
60
+ * `guardianTimeoutMs * attempts` plus this margin, and treats that bound as a
61
+ * last-resort floor, not the normal path. Sized for the direct HTTP transport
62
+ * (one request, no child boot or read-tool round-trips); the legacy child
63
+ * path's spawn+teardown cost sat inside the backend's own advertised ceiling.
55
64
  */
56
- export declare const GUARDIAN_CLIENT_OVERHEAD_MS = 10000;
65
+ export declare const GUARDIAN_CLIENT_OVERHEAD_MS = 2000;
57
66
  /**
58
67
  * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
59
68
  * overrides the sliding-window review cap; anything non-numeric or < 1 falls
@@ -64,7 +73,7 @@ export declare const GUARDIAN_CLIENT_OVERHEAD_MS = 10000;
64
73
  export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
65
74
  /**
66
75
  * Derive the client's whole-consult deadline from the backend's advertised
67
- * wall ceiling and attempt count (YAG-562). Falls back to the resolved limit
76
+ * wall ceiling and attempt count. Falls back to the resolved limit
68
77
  * when the catalog omitted the fields (older backend).
69
78
  */
70
79
  export declare function deriveGuardianTimeoutMs(base: GuardianLimits, advertised: {
@@ -75,6 +84,7 @@ export declare function deriveGuardianTimeoutMs(base: GuardianLimits, advertised
75
84
  export declare const GUARDIAN_MODEL_TIER = "efficient";
76
85
  /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
77
86
  export declare const GUARDIAN_TOOLS: string[];
87
+ export declare function _setGuardianPersonaBodyResolverForTest(resolver: (() => string) | null): void;
78
88
  /**
79
89
  * The review-cap window. `reviews` counts consults inside a SLIDING window
80
90
  * rather than for the session's lifetime: a 24/7 session (a fleet operator's
@@ -148,7 +158,33 @@ export interface ReviewCommandDeps {
148
158
  * an `ask` rationale can add information beyond the static rule text.
149
159
  */
150
160
  execJustification?: string;
161
+ /**
162
+ * The direct transport (the production path): one in-process
163
+ * /v1/chat/completions POST with the guardian persona as the system prompt —
164
+ * no child spawn, no grounded context. Absent → the legacy child-spawn path
165
+ * (the injectable test seam, same retention as the prefix consult).
166
+ *
167
+ * Accepted regression, pinned in tests: the OpenAI-shaped response carries
168
+ * token usage but no per-consult dollar cost, so `ReviewResult.cost` is 0
169
+ * on this path. The backend owns billing via the caller header either way.
170
+ */
171
+ transport?: {
172
+ baseUrl: string;
173
+ getToken: () => string | undefined;
174
+ /** Attribution headers (x-yagni-session-id / x-yagni-run-id). */
175
+ attribution?: () => Record<string, string>;
176
+ };
151
177
  }
178
+ /**
179
+ * The transport revert knob (read per consult — `reviewCommand` itself calls
180
+ * it, so a flipped env var takes effect on the next gated command without a
181
+ * session restart): truthy forces the legacy child-spawn transport even when
182
+ * the direct transport is wired, so a transport-specific regression has a
183
+ * targeted rollback that does not disable the consult entirely. The gate's
184
+ * independent kill switch stays orthogonal.
185
+ */
186
+ export declare function guardianLegacyTransportKnob(env?: NodeJS.ProcessEnv): boolean;
187
+ export declare function _resetGuardianLegacyTransportWarnForTest(): void;
152
188
  /**
153
189
  * The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
154
190
  * (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
@@ -2,9 +2,17 @@
2
2
  * The Guardian — LLM auto-review of prompt-band bash commands (YAG-504).
3
3
  *
4
4
  * Pure half: verdict types, state tracking, circuit breaker, JSON parsing.
5
- * I/O half: reviewCommand spawns a locked-down child pi (same runStage seam
6
- * the advisor uses) on the efficient tier with read-only tools and a risk
7
- * policy persona. The child returns a JSON verdict; the gate acts on it.
5
+ * I/O half: reviewCommand consults the guardian persona over the in-process
6
+ * direct transport (one /v1/chat/completions POST, same seam the prefix
7
+ * consult uses) on the efficient tier. The legacy child-spawn path
8
+ * (runStage, the advisor's seam) remains as the injectable test fallback.
9
+ * The consult returns a JSON verdict; the gate acts on it.
10
+ *
11
+ * The one deliberate verdict-semantics edge of the direct consult: it has no
12
+ * tools, so a command referencing an opaque script/config is judged from
13
+ * command text alone where the child could read the file first (Claude
14
+ * Code's equivalent check is also tool-less). Verdict parsing, the repair
15
+ * ladder, and the gate's handling are unchanged.
8
16
  *
9
17
  * Same pure/IO split as advisor.ts (decideConsult pure, askAdvisorTool I/O)
10
18
  * and permission.ts (decideGate pure, registerPermissionGate I/O), for the
@@ -28,21 +36,25 @@
28
36
  * alternating deny/ask.
29
37
  */
30
38
  import { runStage as defaultRunStage } from "../pipeline/runner.js";
39
+ import { directConsult } from "../pipeline/directConsult.js";
40
+ import { PERSONA_BODIES } from "../pipeline/personas.js";
31
41
  import { scrubSecrets } from "../pipeline/scrubSecrets.js";
42
+ import { logEvent } from "../errorSink.js";
32
43
  export const DEFAULT_GUARDIAN_LIMITS = {
33
44
  maxReviews: 120,
34
45
  maxConsecutiveDenials: 3,
35
46
  timeoutMs: 15_000,
36
47
  };
37
48
  /**
38
- * Client-side safety margin on top of the backend's advertised wall ceiling
39
- * (YAG-562). The backend now owns the real timeout (first-output + wall) and
40
- * retries once, so the client must NOT pre-empt it; it derives its own
41
- * deadline from the catalog's `guardianTimeoutMs * attempts` plus this margin
42
- * for child boot + read-tool round-trips + teardown, and treats that bound as
43
- * a last-resort floor, not the normal path.
49
+ * Client-side safety margin on top of the backend's advertised wall ceiling.
50
+ * The backend owns the real timeout and retries, so the client must
51
+ * NOT pre-empt it; it derives its own deadline from the catalog's
52
+ * `guardianTimeoutMs * attempts` plus this margin, and treats that bound as a
53
+ * last-resort floor, not the normal path. Sized for the direct HTTP transport
54
+ * (one request, no child boot or read-tool round-trips); the legacy child
55
+ * path's spawn+teardown cost sat inside the backend's own advertised ceiling.
44
56
  */
45
- export const GUARDIAN_CLIENT_OVERHEAD_MS = 10_000;
57
+ export const GUARDIAN_CLIENT_OVERHEAD_MS = 2_000;
46
58
  /**
47
59
  * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
48
60
  * overrides the sliding-window review cap; anything non-numeric or < 1 falls
@@ -61,7 +73,7 @@ export function resolveGuardianLimits(env = process.env) {
61
73
  }
62
74
  /**
63
75
  * Derive the client's whole-consult deadline from the backend's advertised
64
- * wall ceiling and attempt count (YAG-562). Falls back to the resolved limit
76
+ * wall ceiling and attempt count. Falls back to the resolved limit
65
77
  * when the catalog omitted the fields (older backend).
66
78
  */
67
79
  export function deriveGuardianTimeoutMs(base, advertised) {
@@ -74,6 +86,31 @@ export function deriveGuardianTimeoutMs(base, advertised) {
74
86
  export const GUARDIAN_MODEL_TIER = "efficient";
75
87
  /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
76
88
  export const GUARDIAN_TOOLS = ["read"];
89
+ /**
90
+ * The guardian persona body — the system prompt on the direct transport. A
91
+ * missing/renamed persona must never silently degrade to an empty system
92
+ * prompt (every consult would run unprompted and the verdicts would drift
93
+ * with no telemetry signature), so a warn fires ONCE when it resolves empty.
94
+ * Same reset-export pattern as the prefix persona resolver.
95
+ */
96
+ let guardianPersonaBodyWarned = false;
97
+ let resolveGuardianPersonaBody = () => PERSONA_BODIES.guardian ?? "";
98
+ export function _setGuardianPersonaBodyResolverForTest(resolver) {
99
+ resolveGuardianPersonaBody = resolver ?? (() => PERSONA_BODIES.guardian ?? "");
100
+ guardianPersonaBodyWarned = false;
101
+ }
102
+ function guardianPersonaBody() {
103
+ const body = resolveGuardianPersonaBody();
104
+ if (body.length === 0 && !guardianPersonaBodyWarned) {
105
+ guardianPersonaBodyWarned = true;
106
+ logEvent({
107
+ source: "guardian",
108
+ level: "warn",
109
+ event: "guardian_persona_body_missing",
110
+ });
111
+ }
112
+ return body;
113
+ }
77
114
  // --- State ---
78
115
  /**
79
116
  * The review-cap window. `reviews` counts consults inside a SLIDING window
@@ -293,6 +330,28 @@ export function formatGuardianSubtotal(state, limits) {
293
330
  * it cannot balloon a log line or a Sentry payload.
294
331
  */
295
332
  export const GUARDIAN_RAW_OUTPUT_CAP = 2048;
333
+ /**
334
+ * The transport revert knob (read per consult — `reviewCommand` itself calls
335
+ * it, so a flipped env var takes effect on the next gated command without a
336
+ * session restart): truthy forces the legacy child-spawn transport even when
337
+ * the direct transport is wired, so a transport-specific regression has a
338
+ * targeted rollback that does not disable the consult entirely. The gate's
339
+ * independent kill switch stays orthogonal.
340
+ */
341
+ export function guardianLegacyTransportKnob(env = process.env) {
342
+ const v = env.YAGNI_GUARDIAN_LEGACY_TRANSPORT;
343
+ return v === "1" || v === "true";
344
+ }
345
+ /**
346
+ * One-time sink warn when the LEGACY child transport runs in a session whose
347
+ * gate wired the direct transport — the two paths are indistinguishable in
348
+ * outcome telemetry, so this line is the only place a transport regression
349
+ * (forcing the knob for a workspace) becomes visible.
350
+ */
351
+ let guardianLegacyTransportWarned = false;
352
+ export function _resetGuardianLegacyTransportWarnForTest() {
353
+ guardianLegacyTransportWarned = false;
354
+ }
296
355
  /**
297
356
  * The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
298
357
  * (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
@@ -328,39 +387,17 @@ export async function reviewCommand(command, deps) {
328
387
  else
329
388
  deps.signal.addEventListener("abort", () => controller.abort(), { once: true });
330
389
  }
331
- try {
332
- const justificationLine = deps.execJustification
333
- ? `\n\nWhy this command was routed to you (static policy): ${deps.execJustification}`
334
- : "";
335
- const result = await runStage(stage, { ticket: `Review this shell command for safety. Output only the JSON verdict.${justificationLine}\n\nCommand:\n${command}` }, {
336
- cwd: deps.cwd,
337
- signal: controller.signal,
338
- callerLabel: "guardian",
339
- });
340
- const cost = result.usage?.cost ?? 0;
341
- const output = result.finalOutput.trim();
342
- // An aborted child often RESOLVES with empty/partial output instead of
343
- // throwing (observed live: ESC → 433ms "empty"). Any result produced
344
- // while the caller's signal is aborted is an abort, not an outage — the
345
- // gate must not warn or fall back to an ask on it.
346
- if (deps.signal?.aborted) {
347
- return { verdict: null, error: "aborted", cost };
348
- }
349
- // The backend now owns the timeout (YAG-562) and streams a terminal error
350
- // frame when the consult fails; a result with a `stopReason: "error"` or an
351
- // `errorMessage` is a real backend failure, NOT the silent-empty shape. Map
352
- // it to a concrete error so the gate shows "review timed out" / "service
353
- // unavailable" instead of the misleading "no response". A timeout-shaped
354
- // message (stream_idle_timeout / "timed out") is distinguished so the gate
355
- // can add its timeout-specific note.
356
- if (!output) {
357
- if (result.stopReason === "error" || result.errorMessage) {
358
- const msg = (result.errorMessage ?? "").toLowerCase();
359
- const isTimeout = /stream_idle_timeout|timed out|idle|wall|timeout/i.test(msg);
360
- return { verdict: null, error: isTimeout ? "timeout" : "network", cost };
361
- }
362
- return { verdict: null, error: "empty", cost };
363
- }
390
+ const justificationLine = deps.execJustification
391
+ ? `\n\nWhy this command was routed to you (static policy): ${deps.execJustification}`
392
+ : "";
393
+ const consultPrompt = `Review this shell command for safety. Output only the JSON verdict.${justificationLine}\n\nCommand:\n${command}`;
394
+ /**
395
+ * Classify a non-empty model output into a ReviewResult: verdict,
396
+ * repaired-verdict, or malformed with the scrubbed/capped raw capture.
397
+ * Shared by both transports so verdict semantics cannot drift between
398
+ * them (the acceptance criterion: only the transport changes).
399
+ */
400
+ const classifyOutput = (output, cost) => {
364
401
  const parsed = parseVerdictDetailed(output);
365
402
  if (!parsed) {
366
403
  // Scrubbed + capped so the local sink and Sentry can see the exact
@@ -385,18 +422,161 @@ export async function reviewCommand(command, deps) {
385
422
  };
386
423
  }
387
424
  return { verdict: parsed.verdict, cost };
388
- }
389
- catch (err) {
390
- // Distinguish the caller aborting (user hit ESC — must NOT be treated as
391
- // an outage, or the fallback ask would pop a dialog on an aborted turn),
392
- // our own timeout, and real network/process errors.
425
+ };
426
+ /**
427
+ * Classify a thrown consult error: the caller aborting (user hit ESC — must
428
+ * NOT be treated as an outage, or the fallback ask would pop a dialog on an
429
+ * aborted turn), our own deadline, and real network/transport errors. The
430
+ * thrown message itself never reaches telemetry (it can carry provider
431
+ * payloads — the prefix consult's scrub doctrine); the ERROR CLASS rides
432
+ * the local sink line — emitted only for the NETWORK classification, with
433
+ * the resolved outcome in fields, so routine aborts and deadline timeouts
434
+ * (the two shapes that must NOT read as outages) never emit a warn line.
435
+ */
436
+ const classifyThrow = (err) => {
437
+ const errorClass = err instanceof Error ? err.constructor.name : typeof err;
393
438
  if (deps.signal?.aborted) {
394
439
  return { verdict: null, error: "aborted", cost: 0 };
395
440
  }
396
441
  if (controller.signal.aborted) {
397
442
  return { verdict: null, error: "timeout", cost: 0 };
398
443
  }
444
+ logEvent({
445
+ source: "guardian",
446
+ level: "warn",
447
+ event: "guardian_consult_error",
448
+ fields: { outcome: "network", errorClass },
449
+ });
399
450
  return { verdict: null, error: "network", cost: 0 };
451
+ };
452
+ /**
453
+ * The direct transport: one /v1/chat/completions POST with the guardian
454
+ * persona as the system prompt and the consult prompt as user content —
455
+ * no child spawn, no grounded context, ~1k input tokens instead of ~31k.
456
+ */
457
+ const reviewViaDirect = async () => {
458
+ const t = deps.transport;
459
+ // A missing/renamed guardian persona fails CLOSED: an empty system prompt
460
+ // would consult with no risk taxonomy and no JSON contract — a
461
+ // permissive bias on a safety gate. Returning an error class lets the
462
+ // gate's bounded fallback (ask the user / fail closed) handle it. The
463
+ // outcome stays "network" (the gate treats it identically), but a
464
+ // PER-OCCURRENCE sink line marks the true shape — a config/packaging bug
465
+ // must read as "persona missing" at 2am in any session, not as an
466
+ // outage (the once-per-process body warn may predate a fresh session).
467
+ const systemPrompt = guardianPersonaBody();
468
+ if (systemPrompt.length === 0) {
469
+ logEvent({
470
+ source: "guardian",
471
+ level: "error",
472
+ event: "guardian_persona_missing",
473
+ fields: { outcome: "network" },
474
+ });
475
+ return { verdict: null, error: "network", cost: 0 };
476
+ }
477
+ try {
478
+ const res = await directConsult({ systemPrompt, userContent: consultPrompt, modelTier: deps.modelTier ?? GUARDIAN_MODEL_TIER }, {
479
+ baseUrl: t.baseUrl,
480
+ getToken: t.getToken,
481
+ attribution: t.attribution ?? (() => ({})),
482
+ callerLabel: "guardian",
483
+ signal: controller.signal,
484
+ });
485
+ const output = res.text.trim();
486
+ // An aborted request can resolve with empty/partial output instead of
487
+ // throwing — any result produced while the caller's signal is aborted
488
+ // is an abort, not an outage.
489
+ if (deps.signal?.aborted) {
490
+ return { verdict: null, error: "aborted", cost: res.cost };
491
+ }
492
+ if (!output) {
493
+ // A 200 with no/blank content is an EMPTY result: the backend's
494
+ // non-stream pipeline has already done its own empty-result retry
495
+ // and failover by this point, so the client does not retry again —
496
+ // the gate's bounded error fallback handles it. Aligned with the
497
+ // child path: a result resolving after the client's own deadline
498
+ // fired reads as a timeout, never as a silent empty.
499
+ if (controller.signal.aborted) {
500
+ return { verdict: null, error: "timeout", cost: res.cost };
501
+ }
502
+ return { verdict: null, error: "empty", cost: res.cost };
503
+ }
504
+ return classifyOutput(output, res.cost);
505
+ }
506
+ catch (err) {
507
+ return classifyThrow(err);
508
+ }
509
+ };
510
+ /**
511
+ * The legacy child transport (the injectable test seam; also the targeted
512
+ * rollback when the revert knob forces it). Spawns a locked-down child pi
513
+ * via runStage and reduces its NDJSON stream.
514
+ */
515
+ const reviewViaChild = async () => {
516
+ try {
517
+ return await runChild();
518
+ }
519
+ catch (err) {
520
+ return classifyThrow(err);
521
+ }
522
+ };
523
+ const runChild = async () => {
524
+ const result = await runStage(stage, { ticket: consultPrompt }, {
525
+ cwd: deps.cwd,
526
+ signal: controller.signal,
527
+ callerLabel: "guardian",
528
+ });
529
+ const cost = result.usage?.cost ?? 0;
530
+ const output = result.finalOutput.trim();
531
+ // An aborted child often RESOLVES with empty/partial output instead of
532
+ // throwing (observed live: ESC → 433ms "empty"). Any result produced
533
+ // while the caller's signal is aborted is an abort, not an outage — the
534
+ // gate must not warn or fall back to an ask on it.
535
+ if (deps.signal?.aborted) {
536
+ return { verdict: null, error: "aborted", cost };
537
+ }
538
+ // The backend now owns the timeout (YAG-562) and streams a terminal error
539
+ // frame when the consult fails; a result with a `stopReason: "error"` or an
540
+ // `errorMessage` is a real backend failure, NOT the silent-empty shape. Map
541
+ // it to a concrete error so the gate shows "review timed out" / "service
542
+ // unavailable" instead of the misleading "no response". A timeout-shaped
543
+ // message (stream_idle_timeout / "timed out") is distinguished so the gate
544
+ // can add its timeout-specific note.
545
+ if (!output) {
546
+ if (result.stopReason === "error" || result.errorMessage) {
547
+ const msg = (result.errorMessage ?? "").toLowerCase();
548
+ const isTimeout = /stream_idle_timeout|timed out|idle|wall|timeout/i.test(msg);
549
+ return { verdict: null, error: isTimeout ? "timeout" : "network", cost };
550
+ }
551
+ // The client's own deadline firing resolves the child with empty output
552
+ // (runStage SIGTERMs and resolves, it does not throw) — the previous
553
+ // classification mislabeled this last-resort abort as "no response";
554
+ // it is a timeout (observed live: every "empty" event at the derived
555
+ // 100s deadline).
556
+ if (controller.signal.aborted) {
557
+ return { verdict: null, error: "timeout", cost };
558
+ }
559
+ return { verdict: null, error: "empty", cost };
560
+ }
561
+ return classifyOutput(output, cost);
562
+ };
563
+ const useLegacy = !deps.transport || guardianLegacyTransportKnob();
564
+ if (useLegacy && deps.transport) {
565
+ // The knob forced the child path in a session that wired the direct
566
+ // transport — the one line that makes a forced rollback visible in the
567
+ // local trail (the two paths are otherwise indistinguishable in outcome
568
+ // telemetry). One-time, never per-call.
569
+ if (!guardianLegacyTransportWarned) {
570
+ guardianLegacyTransportWarned = true;
571
+ logEvent({
572
+ source: "guardian",
573
+ level: "warn",
574
+ event: "guardian_legacy_transport_used",
575
+ });
576
+ }
577
+ }
578
+ try {
579
+ return useLegacy ? await reviewViaChild() : await reviewViaDirect();
400
580
  }
401
581
  finally {
402
582
  clearTimeout(timer);
@@ -42,10 +42,12 @@ export async function directConsult(req, opts) {
42
42
  }
43
43
  const data = (await res.json());
44
44
  // A 200 with no/blank content is an EMPTY result, not a transport error:
45
- // the consult's error classifier treats `empty` as retryable-once (the
46
- // backend's own 502-shape body invites exactly that), and a thrown error
47
- // here would misclassify that shape as `network`. A genuinely malformed
48
- // (non-JSON) body still throws below.
45
+ // each consult's OWN classifier decides the retry policy for the shape
46
+ // (the prefix consult retries once — the backend's own 502-shape body
47
+ // invites it; the guardian consult does not — its non-stream pipeline
48
+ // already retried and failed over server-side). A thrown error here
49
+ // would misclassify the shape as `network` for every consult regardless
50
+ // of its policy. A genuinely malformed (non-JSON) body still throws below.
49
51
  const text = data.choices?.[0]?.message?.content ?? "";
50
52
  return { text, cost: typeof data.usage?.cost === "number" ? data.usage.cost : 0 };
51
53
  }
@@ -42,6 +42,8 @@ export interface McpCliModule {
42
42
  type: "http";
43
43
  url: string;
44
44
  tools: string[];
45
+ }, deps?: {
46
+ onAuthorizationUrl?: (url: string) => void;
45
47
  }): Promise<{
46
48
  result: "AUTHORIZED";
47
49
  }>;
@@ -64,6 +66,7 @@ export interface McpCliModule {
64
66
  };
65
67
  updateStoredOAuthEntry(serverName: string, config: unknown, mutate: (entry: {
66
68
  clientSecret?: string;
69
+ clientSecretSource?: "dcr" | "operator";
67
70
  }) => void): {
68
71
  errors: unknown[];
69
72
  };
@@ -412,7 +412,9 @@ async function connectWorkers(mod, parsed, io, env) {
412
412
  if (shadow)
413
413
  throw new Error("A project or local yagni-workers entry would override this connection. Remove that entry before connecting");
414
414
  io.stdout("Opening YAGNI in your browser. Choose your workspace and permissions; paid Worker requests use that workspace's balance.\n");
415
- await mod.authenticate("yagni-workers", config);
415
+ await mod.authenticate("yagni-workers", config, {
416
+ onAuthorizationUrl: (url) => io.stdout(`If your browser did not open, visit:\n${url}\n`),
417
+ });
416
418
  writeServerToScope(mod, "yagni-workers", config, "user", io.cwd);
417
419
  io.stdout("Workers connected. Start a new YAGNI Code session to use them; /mcp shows connection status.\n");
418
420
  return 0;
@@ -491,6 +493,8 @@ function persistAdd(mod, name, serverConfig, parsed, io) {
491
493
  if (secret) {
492
494
  mod.updateStoredOAuthEntry(name, serverConfig, (entry) => {
493
495
  entry.clientSecret = secret;
496
+ // The operator's secret survives a DCR client drop; a DCR-issued one does not.
497
+ entry.clientSecretSource = "operator";
494
498
  });
495
499
  }
496
500
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.6-staging.1445.1",
3
+ "version": "1.1.6-staging.1448.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "df1d49933fa6a71d3b18754a183b5e400a45053a"
61
+ "yagniSourceSha": "54c5688071d09fa6a6fee1dbea2b44066352678f"
62
62
  }