From 6b365dfbbe772762aae0daeaf3154c7e0a4555d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:30:31 -0700 Subject: [PATCH 1/2] structure VCS process errors Co-authored-by: codex --- apps/server/src/vcs/VcsProcess.test.ts | 47 ++++++++++++++++++++++++-- apps/server/src/vcs/VcsProcess.ts | 16 ++++----- packages/contracts/src/vcs.ts | 9 ++++- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index b58d64e435a..6ece78fb4fc 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -6,7 +6,11 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { TestClock } from "effect/testing"; -import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts"; +import { + VcsProcessExitError, + VcsProcessSpawnError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; import * as VcsProcess from "./VcsProcess.ts"; const run = (input: VcsProcess.VcsProcessInput) => @@ -61,14 +65,53 @@ describe("VcsProcess.run", () => { it.effect("fails with VcsProcessExitError for non-zero exits by default", () => Effect.gen(function* () { + const secretArgument = "--token=super-secret-token"; + const secretStderr = "remote rejected super-secret-token"; const error = yield* run({ operation: "test.exit", command: "node", - args: ["-e", "process.stderr.write('boom'); process.exit(2)"], + args: [ + "-e", + "process.stderr.write(process.argv[1]); process.exit(2)", + secretStderr, + secretArgument, + ], cwd: process.cwd(), }).pipe(Effect.flip); expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + operation: "test.exit", + command: "node", + argumentCount: 4, + exitCode: 2, + detail: "Process exited with a non-zero status.", + stderrLength: secretStderr.length, + stderrTruncated: false, + }); + expect(error.message).not.toContain(secretArgument); + expect(error.message).not.toContain(secretStderr); + }).pipe(provideLive), + ); + + it.effect("retains spawn causes without exposing process arguments in the error message", () => + Effect.gen(function* () { + const secretArgument = "--token=super-secret-token"; + const error = yield* run({ + operation: "test.spawn", + command: "definitely-not-a-t3code-executable", + args: [secretArgument], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessSpawnError); + expect(error).toMatchObject({ + operation: "test.spawn", + command: "definitely-not-a-t3code-executable", + argumentCount: 1, + }); + expect(error).toHaveProperty("cause"); + expect(error.message).not.toContain(secretArgument); }).pipe(provideLive), ); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 4470a1bfc53..52fe964964e 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -46,19 +46,15 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; -function commandLabel(command: string, args: ReadonlyArray): string { - return [command, ...args].join(" "); -} - export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { - const label = commandLabel(input.command, input.args); const baseError = { operation: input.operation, - command: label, + command: input.command, cwd: input.cwd, + argumentCount: input.args.length, }; const result = yield* processRunner @@ -98,11 +94,11 @@ export const make = Effect.gen(function* () { if (!input.allowNonZeroExit && result.code !== 0) { return yield* new VcsProcessExitError({ - operation: input.operation, - command: label, - cwd: input.cwd, + ...baseError, exitCode: result.code, - detail: result.stderr.trim() || `${label} exited with code ${result.code}.`, + detail: "Process exited with a non-zero status.", + stderrLength: result.stderr.length, + stderrTruncated: result.stderrTruncated, }); } diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 40deeb77da6..240645625d0 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); export type VcsDriverKind = typeof VcsDriverKind.Type; @@ -62,6 +62,7 @@ export interface VcsProcessErrorContext { readonly operation: string; readonly command: string; readonly cwd: string; + readonly argumentCount?: number; } export interface VcsProcessSpawnFailure { @@ -92,6 +93,7 @@ export class VcsProcessSpawnError extends Schema.TaggedErrorClass Date: Sat, 20 Jun 2026 11:22:37 -0700 Subject: [PATCH 2/2] fix(vcs): retain safe exit classifications Co-authored-by: codex --- apps/server/src/vcs/VcsProcess.test.ts | 26 +++++++++++++ apps/server/src/vcs/VcsProcess.ts | 53 ++++++++++++++++++++++---- packages/contracts/src/vcs.ts | 40 +++++++++++++++++++ 3 files changed, 112 insertions(+), 7 deletions(-) diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 6ece78fb4fc..e13120b1c57 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -86,6 +86,7 @@ describe("VcsProcess.run", () => { argumentCount: 4, exitCode: 2, detail: "Process exited with a non-zero status.", + failureKind: "command-failed", stderrLength: secretStderr.length, stderrTruncated: false, }); @@ -94,6 +95,31 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("classifies authentication failures without retaining stderr", () => + Effect.gen(function* () { + const secretStderr = "authentication failed for token super-secret-token"; + const error = yield* run({ + operation: "test.authentication", + command: "node", + args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + operation: "test.authentication", + command: "node", + exitCode: 1, + detail: "Authentication failed.", + failureKind: "authentication", + stderrLength: secretStderr.length, + stderrTruncated: false, + }); + expect(error.message).not.toContain(secretStderr); + expect(error.message).not.toContain("super-secret-token"); + }).pipe(provideLive), + ); + it.effect("retains spawn causes without exposing process arguments in the error message", () => Effect.gen(function* () { const secretArgument = "--token=super-secret-token"; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 52fe964964e..8103c7306a0 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -8,6 +8,7 @@ import { VcsOutputDecodeError, type VcsError, VcsProcessExitError, + type VcsProcessExitFailureKind, VcsProcessSpawnError, VcsProcessTimeoutError, } from "@t3tools/contracts"; @@ -46,6 +47,42 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; +const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { + const normalized = stderr.toLowerCase(); + + if ( + normalized.includes("authentication failed") || + normalized.includes("not logged in") || + normalized.includes("gh auth login") || + normalized.includes("glab auth login") || + normalized.includes("az devops login") || + normalized.includes("please run az login") || + normalized.includes("no oauth token") || + normalized.includes("unauthorized") + ) { + return "authentication"; + } + + if ( + (command === "gh" && + (normalized.includes("could not resolve to a pullrequest") || + normalized.includes("repository.pullrequest") || + normalized.includes("no pull requests found for branch") || + normalized.includes("pull request not found"))) || + (command === "glab" && + (normalized.includes("merge request not found") || + normalized.includes("not found") || + normalized.includes("404"))) || + (command === "az" && + normalized.includes("pull request") && + (normalized.includes("not found") || normalized.includes("does not exist"))) + ) { + return "not-found"; + } + + return "command-failed"; +}; + export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; @@ -93,13 +130,15 @@ export const make = Effect.gen(function* () { } if (!input.allowNonZeroExit && result.code !== 0) { - return yield* new VcsProcessExitError({ - ...baseError, - exitCode: result.code, - detail: "Process exited with a non-zero status.", - stderrLength: result.stderr.length, - stderrTruncated: result.stderrTruncated, - }); + return yield* VcsProcessExitError.fromProcessExit( + baseError, + { + exitCode: result.code, + stderr: result.stderr, + stderrTruncated: result.stderrTruncated, + }, + classifyNonZeroExit(input.command, result.stderr), + ); } return { diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 240645625d0..728cef3974f 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -87,6 +87,19 @@ export interface VcsProcessTimeoutFailure { readonly timeoutMs: number; } +export const VcsProcessExitFailureKind = Schema.Literals([ + "authentication", + "not-found", + "command-failed", +]); +export type VcsProcessExitFailureKind = typeof VcsProcessExitFailureKind.Type; + +export interface VcsProcessExitFailure { + readonly exitCode: number; + readonly stderr: string; + readonly stderrTruncated: boolean; +} + export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( "VcsProcessSpawnError", { @@ -118,6 +131,7 @@ export class VcsProcessExitError extends Schema.TaggedErrorClass()(