@yagni-app/code-staging 0.3.0-staging.1061.1 → 0.3.0-staging.1064.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.
@@ -125,8 +125,6 @@ export function makeCrashReporter(opts) {
125
125
  if (crashReportsDisabled(env))
126
126
  return;
127
127
  const token = opts.getToken();
128
- if (!token)
129
- return;
130
128
  const sanitized = sanitizeCrashError(error, { env, repoRoot });
131
129
  const payload = {
132
130
  client: isDesktopSurface() ? "desktop" : "cli",
@@ -148,7 +146,7 @@ export function makeCrashReporter(opts) {
148
146
  method: "POST",
149
147
  headers: {
150
148
  "content-type": "application/json",
151
- authorization: `Bearer ${token}`,
149
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
152
150
  },
153
151
  body: JSON.stringify(payload),
154
152
  signal: controller.signal,
@@ -63,6 +63,12 @@ export interface RegisterYagniDeps {
63
63
  tokenProvider?: TokenProvider;
64
64
  /** The spool flush (R4 write half), injectable so tests never touch disk. */
65
65
  flushSpool?: (opts: SpoolClientOpts) => Promise<FlushOutcome>;
66
+ /**
67
+ * Non-fatal auth-event reporter (YAG-500 Fix E). Defaults to
68
+ * `makeCrashReporter` gated on `!evalMode`; inject a spy in tests to assert
69
+ * the report is fired with `context: "auth-failure"` and the refresh outcome.
70
+ */
71
+ authReporter?: (error: unknown, context?: string) => Promise<void>;
66
72
  env?: NodeJS.ProcessEnv;
67
73
  }
68
74
  /**
@@ -30,7 +30,7 @@ import { registerDecisionCommands } from "./decisions.js";
30
30
  import { makeDecisionCapture } from "./decisionCapture.js";
31
31
  import { registerAmbientRecall } from "./recall.js";
32
32
  import { resilientFetch } from "./resilientFetch.js";
33
- import { installUncaughtExceptionMonitor } from "./crashReport.js";
33
+ import { installUncaughtExceptionMonitor, makeCrashReporter } from "./crashReport.js";
34
34
  import { flushSpool as defaultFlushSpool } from "./spool.js";
35
35
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
36
36
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
@@ -113,6 +113,19 @@ export async function registerYagni(pi, deps = {}) {
113
113
  if (!evalMode) {
114
114
  installUncaughtExceptionMonitor({ baseUrl, getToken: getTokenFn, env: deps.env });
115
115
  }
116
+ // YAG-500 Fix E: non-fatal auth-event reporter for 401s on the model path.
117
+ // Reuses the crash endpoint (/api/yagni-code/crash) with a distinct context
118
+ // so auth failures are visible in Sentry/backend logs. Gated on !evalMode
119
+ // like every other external side effect; injectable for tests.
120
+ const authReporter = deps.authReporter ??
121
+ (!evalMode
122
+ ? makeCrashReporter({ baseUrl, getToken: getTokenFn, fetchImpl: deps.fetchImpl, env: deps.env })
123
+ : async () => { });
124
+ // YAG-500 Fix A+C: the model-path 401 recovery outcome, set by the
125
+ // message_end handler so it can produce the right user-facing message. The
126
+ // after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
127
+ // before onResponse is reached), so message_end is the only seam.
128
+ let lastAuthRecovery = null;
116
129
  const fullCatalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
117
130
  // Lock the interactive session to the `advanced` tier only. The backend
118
131
  // catalog returns all tiers, but only `advanced` is registered with the
@@ -435,7 +448,7 @@ export async function registerYagni(pi, deps = {}) {
435
448
  // renders. The message text deliberately matches neither pi's overflow nor
436
449
  // retryable-error patterns: a deterministic empty response should not burn
437
450
  // auto-retries or trigger compaction — the user decides what to do next.
438
- pi.on("message_end", (event, ctx) => {
451
+ pi.on("message_end", async (event, ctx) => {
439
452
  const msg = event.message;
440
453
  if (msg.role !== "assistant")
441
454
  return;
@@ -450,6 +463,71 @@ export async function registerYagni(pi, deps = {}) {
450
463
  // post-replacement message, and dropping the marker would defeat the very
451
464
  // recovery this error exists to trigger.
452
465
  if (msg.stopReason === "error" && msg.errorMessage) {
466
+ // YAG-500: a 401 from the model proxy means the session token expired
467
+ // (or was revoked). Unlike tool 401s (handled by makeAuthedFetch), the
468
+ // model completion path has no 401-retry seam — pi's retryProviderRequest
469
+ // treats 401 as non-retryable, and after_provider_response never fires
470
+ // (the SDK throws before onResponse is reached). So message_end is the
471
+ // only place to detect it and trigger recovery. The regex matches
472
+ // "yagni login" (the backend's auth-error message) but NOT
473
+ // "request_too_large" (YAG-460's overflow marker).
474
+ const isAuthError = /yagni login/i.test(msg.errorMessage)
475
+ && !/request_too_large|context_too_large/i.test(msg.errorMessage);
476
+ if (isAuthError) {
477
+ lastAuthRecovery = null;
478
+ let rotated = false;
479
+ try {
480
+ rotated = await tokenProvider.refresh();
481
+ }
482
+ catch {
483
+ rotated = false;
484
+ }
485
+ lastAuthRecovery = rotated ? "refreshed" : "failed";
486
+ const explanation = rotated
487
+ ? "Your session token expired but was refreshed automatically. Re-send your prompt to continue."
488
+ : "Your session token expired and could not be refreshed. Run `yagni login`, then re-send your prompt. If the issue persists, restart YAGNI Code.";
489
+ if (ctx.hasUI) {
490
+ try {
491
+ ctx.ui.notify(explanation, rotated ? "info" : "error");
492
+ }
493
+ catch {
494
+ // Surfacing the problem must never break the session itself.
495
+ }
496
+ }
497
+ // YAG-500 Fix E: fire a non-fatal crash report so auth failures are
498
+ // visible in Sentry/backend logs. Reuses the crash endpoint with a
499
+ // distinct context. Fire-and-forget, fail-soft. The crash endpoint is
500
+ // now public (no token required), so the report lands even when the
501
+ // session token is expired and refresh failed — the most critical
502
+ // failure signal is no longer silently dropped.
503
+ void authReporter(new Error(`auth_401 on model path; refresh=${rotated ? "succeeded" : "failed"}`), "auth-failure").catch(() => { });
504
+ // YAG-500 Fix F: local diagnostics log under YAGNI_DEBUG.
505
+ if (isDebug(env)) {
506
+ try {
507
+ const logPath = join(codeStateHome(null, env), "logs", "auth-events.log");
508
+ mkdirSync(dirname(logPath), { recursive: true });
509
+ appendFileSync(logPath, JSON.stringify({
510
+ ts: new Date().toISOString(),
511
+ status: 401,
512
+ refresh: rotated ? "succeeded" : "failed",
513
+ }) + "\n", "utf8");
514
+ }
515
+ catch {
516
+ // A diagnostic must never break the session.
517
+ }
518
+ }
519
+ return { message: { ...msg, errorMessage: explanation } };
520
+ }
521
+ // YAG-460: the backend proxy answers an oversized conversation with an
522
+ // OpenAI-format 413 whose type is `request_too_large`. pi's own overflow
523
+ // detection matches that marker and runs full recovery — compact, then
524
+ // auto-retry the failed turn — so this branch must NOT call ctx.compact()
525
+ // (it would race the built-in recovery and lose the retry). Its only job
526
+ // is UX: replace the raw `413: {"error":{...}}` JSON with a readable
527
+ // message and tell the user what is happening. The rewritten text KEEPS
528
+ // the `request_too_large` marker verbatim: pi's _checkCompaction reads the
529
+ // post-replacement message, and dropping the marker would defeat the very
530
+ // recovery this error exists to trigger.
453
531
  const isContextTooLarge = /request_too_large|context_too_large/i.test(msg.errorMessage);
454
532
  if (!isContextTooLarge)
455
533
  return;
@@ -130,7 +130,7 @@ export function makeTokenProvider(deps) {
130
130
  });
131
131
  }, delay);
132
132
  }
133
- function applyRotation(rotation) {
133
+ function applyRotation(rotation, skipPersist = false) {
134
134
  token = rotation.token;
135
135
  if (rotation.expiresAt)
136
136
  expiresAt = rotation.expiresAt;
@@ -139,13 +139,45 @@ export function makeTokenProvider(deps) {
139
139
  env.YAGNI_TOKEN = rotation.token;
140
140
  if (rotation.expiresAt)
141
141
  env.YAGNI_TOKEN_EXPIRES_AT = rotation.expiresAt;
142
+ if (!skipPersist) {
143
+ try {
144
+ persistProfile(rotation);
145
+ }
146
+ catch {
147
+ /* fail-soft */
148
+ }
149
+ }
150
+ armProactiveTimer();
151
+ }
152
+ /**
153
+ * Read the token from the launcher's profile file on disk. Used as a fallback
154
+ * when the server-side refresh fails (the old token is also invalid for
155
+ * /auth/refresh): an external `yagni login` writes a fresh token to the same
156
+ * file, so re-reading it can recover a session that the server refresh cannot.
157
+ * Returns null when the file is missing, unreadable, or carries the same token
158
+ * already in memory.
159
+ */
160
+ function readTokenFromDisk() {
161
+ const profilePath = env.YAGNI_PROFILE_PATH?.trim();
162
+ if (!profilePath)
163
+ return null;
142
164
  try {
143
- persistProfile(rotation);
165
+ const parsed = JSON.parse(readFileSync(profilePath, "utf8"));
166
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
167
+ return null;
168
+ const obj = parsed;
169
+ const diskToken = typeof obj.token === "string" ? obj.token : undefined;
170
+ if (!diskToken || diskToken === token)
171
+ return null;
172
+ return {
173
+ token: diskToken,
174
+ expiresAt: typeof obj.expiresAt === "string" ? obj.expiresAt : undefined,
175
+ workspaceId: typeof obj.workspaceId === "string" ? obj.workspaceId : undefined,
176
+ };
144
177
  }
145
178
  catch {
146
- /* fail-soft */
179
+ return null;
147
180
  }
148
- armProactiveTimer();
149
181
  }
150
182
  async function doRefresh() {
151
183
  const current = token;
@@ -161,8 +193,17 @@ export function makeTokenProvider(deps) {
161
193
  body: "{}",
162
194
  signal: AbortSignal.timeout(REFRESH_REQUEST_TIMEOUT_MS),
163
195
  });
164
- if (!res.ok)
196
+ if (!res.ok) {
197
+ // Server refresh failed (the old token is also invalid for /auth/refresh).
198
+ // Fall back to the profile file on disk: an external `yagni login` may
199
+ // have written a fresh token there that this running session hasn't seen.
200
+ const disk = readTokenFromDisk();
201
+ if (disk) {
202
+ applyRotation(disk, true);
203
+ return true;
204
+ }
165
205
  return false;
206
+ }
166
207
  const data = (await res.json());
167
208
  if (!data || typeof data.token !== "string" || data.token.length === 0)
168
209
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1061.1",
3
+ "version": "0.3.0-staging.1064.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)",
@@ -38,5 +38,5 @@
38
38
  "@earendil-works/pi-tui": "0.84.1",
39
39
  "typebox": "^1.3.11"
40
40
  },
41
- "yagniSourceSha": "44711af698e076c2e718fdd813f4e8ab4b875a0a"
41
+ "yagniSourceSha": "e38a99de02a31e45c61d006943113caf8817e0bd"
42
42
  }