diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index b58d64e435a..e13120b1c57 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,79 @@ 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.", + failureKind: "command-failed", + stderrLength: secretStderr.length, + stderrTruncated: false, + }); + expect(error.message).not.toContain(secretArgument); + expect(error.message).not.toContain(secretStderr); + }).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"; + 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..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,19 +47,51 @@ 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(" "); -} +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; 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 @@ -97,13 +130,15 @@ export const make = Effect.gen(function* () { } if (!input.allowNonZeroExit && result.code !== 0) { - return yield* new VcsProcessExitError({ - operation: input.operation, - command: label, - cwd: input.cwd, - exitCode: result.code, - detail: result.stderr.trim() || `${label} exited with code ${result.code}.`, - }); + 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 40deeb77da6..728cef3974f 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 { @@ -86,12 +87,26 @@ 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", { operation: Schema.String, command: Schema.String, cwd: Schema.String, + argumentCount: Schema.optional(NonNegativeInt), cause: Schema.Defect(), }, ) { @@ -113,13 +128,43 @@ export class VcsProcessExitError extends Schema.TaggedErrorClass()( @@ -128,6 +173,7 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass