diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be35b325..f0fcf87ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ ### Fixed +- Browser: copy authenticated ChatGPT file artifacts beside `--write-output` answers with collision-safe names and recorded hashes, while retaining the canonical session copies. - Browser: detect a disabled ChatGPT effort tier (e.g. an exhausted Pro allotment) before clicking it, and report the account's own reset notice instead of a misleading "selection unverified" failure. Thanks @enieuwy! ## 0.17.3 — 2026-08-13 diff --git a/src/browser/sessionRunner.ts b/src/browser/sessionRunner.ts index 26e317c3a..fd6602215 100644 --- a/src/browser/sessionRunner.ts +++ b/src/browser/sessionRunner.ts @@ -14,7 +14,7 @@ import type { BrowserRunResult } from "../browserMode.js"; import { DEFAULT_BROWSER_CONFIG } from "./config.js"; import { assembleBrowserPrompt } from "./prompt.js"; import { BrowserAutomationError } from "../oracle/errors.js"; -import type { BrowserArchiveResult, BrowserLogger } from "./types.js"; +import type { BrowserArchiveResult, BrowserLogger, SavedBrowserFile } from "./types.js"; import { appendArtifacts, saveBrowserTranscriptArtifact, @@ -40,6 +40,7 @@ export interface BrowserExecutionResult { warnings?: BrowserRunWarning[]; answerText: string; artifacts?: SessionArtifact[]; + savedFiles?: SavedBrowserFile[]; } interface RunBrowserSessionArgs { @@ -350,6 +351,7 @@ export async function runBrowserSessionExecution( warnings, answerText, artifacts: savedArtifacts, + savedFiles: browserResult.savedFiles, }; } diff --git a/src/cli/sessionRunner.ts b/src/cli/sessionRunner.ts index 49074d875..985b1c950 100644 --- a/src/cli/sessionRunner.ts +++ b/src/cli/sessionRunner.ts @@ -1,4 +1,5 @@ import kleur from "kleur"; +import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { @@ -7,6 +8,7 @@ import type { BrowserSessionConfig, BrowserRuntimeMetadata, BrowserModelSelectionEvidence, + BrowserRunWarning, SessionArtifact, SessionModelRun, } from "../sessionStore.js"; @@ -49,7 +51,8 @@ import { cwd as getCwd } from "node:process"; import { resumeBrowserSession } from "../browser/reattach.js"; import { hasRecoverableChatGptConversation } from "../browser/reattachability.js"; import { estimateTokenCount } from "../browser/utils.js"; -import type { BrowserLogger } from "../browser/types.js"; +import type { BrowserLogger, SavedBrowserFile } from "../browser/types.js"; +import { computeFileSha256, sanitizeArtifactFilename } from "../browser/artifacts.js"; import { formatElapsed } from "../oracle/format.js"; import { formatBrowserReattachGuidance } from "./reattachGuidance.js"; @@ -140,7 +143,17 @@ export async function performSessionRun({ }, runnerDeps, ); - await writeAssistantOutput(runOptions.writeOutputPath, result.answerText ?? "", log); + const writtenOutputPath = await writeAssistantOutput( + runOptions.writeOutputPath, + result.answerText ?? "", + log, + ); + const outputArtifacts = await copyBrowserOutputArtifacts({ + outputPath: writtenOutputPath, + savedFiles: result.savedFiles, + log, + }); + const browserWarnings = [...(result.warnings ?? []), ...outputArtifacts.warnings]; await sendSessionNotification( { sessionId: sessionMeta.id, @@ -172,9 +185,12 @@ export async function performSessionRun({ runtime: result.runtime, archive: result.archive, modelSelection: result.modelSelection, - warnings: result.warnings, + warnings: browserWarnings.length > 0 ? browserWarnings : undefined, }, - artifacts: mergeArtifacts(sessionMeta.artifacts, result.artifacts), + artifacts: mergeArtifacts( + sessionMeta.artifacts, + mergeArtifacts(result.artifacts, outputArtifacts.artifacts), + ), response: undefined, transport: undefined, error: undefined, @@ -1081,6 +1097,97 @@ function resolveSessionPath(sessionDir: string | null, targetPath: string): stri return path.join(sessionDir, targetPath); } +interface BrowserOutputArtifactCopyResult { + artifacts: SessionArtifact[]; + warnings: BrowserRunWarning[]; +} + +async function copyBrowserOutputArtifacts(params: { + outputPath?: string; + savedFiles?: SavedBrowserFile[]; + log: (message: string) => void; +}): Promise { + const artifacts: SessionArtifact[] = []; + const warnings: BrowserRunWarning[] = []; + if (!params.outputPath || !params.savedFiles?.length) { + return { artifacts, warnings }; + } + + const outputDir = path.dirname(params.outputPath); + for (const savedFile of params.savedFiles) { + const filename = sanitizeArtifactFilename( + savedFile.filename ?? path.basename(savedFile.path), + "artifact.bin", + ); + let copied: string | undefined; + try { + copied = await copyFileWithoutOverwrite(savedFile.path, path.join(outputDir, filename)); + const [stat, sha256] = await Promise.all([fs.stat(copied), computeFileSha256(copied)]); + if (savedFile.sizeBytes != null && stat.size !== savedFile.sizeBytes) { + throw new Error(`size mismatch (expected ${savedFile.sizeBytes}, received ${stat.size})`); + } + if (savedFile.sha256 && sha256 !== savedFile.sha256) { + throw new Error(`sha256 mismatch (expected ${savedFile.sha256}, received ${sha256})`); + } + const artifact: SessionArtifact = { + kind: "file", + path: copied, + label: `${savedFile.label ?? filename} (write-output)`, + mimeType: savedFile.mimeType, + sizeBytes: stat.size, + sourceUrl: savedFile.sourceUrl, + sha256, + validation: savedFile.validation, + transfer: { status: "not-needed" }, + origin: { mode: "local" }, + }; + artifacts.push(artifact); + params.log(dim(`[browser] Saved write-output file artifact to ${copied} sha256=${sha256}`)); + } catch (error) { + if (copied) { + await fs.unlink(copied).catch(() => undefined); + } + const reason = error instanceof Error ? error.message : String(error); + const message = `Failed to copy saved file artifact ${savedFile.path} beside ${params.outputPath}: ${reason}`; + params.log(dim(`[browser] ${message}`)); + warnings.push({ + code: "browser-output-artifact-copy-failed", + severity: "warning", + message, + details: { + sourcePath: savedFile.path, + outputPath: params.outputPath, + sha256: savedFile.sha256, + }, + }); + } + } + return { artifacts, warnings }; +} + +async function copyFileWithoutOverwrite( + sourcePath: string, + baseTargetPath: string, +): Promise { + const ext = path.extname(baseTargetPath); + const stem = ext ? path.basename(baseTargetPath, ext) : path.basename(baseTargetPath); + const dir = path.dirname(baseTargetPath); + let suffix = 1; + for (;;) { + const targetPath = suffix === 1 ? baseTargetPath : path.join(dir, `${stem}-${suffix}${ext}`); + try { + await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL); + return targetPath; + } catch (error) { + if (isErrorCode(error, "EEXIST")) { + suffix += 1; + continue; + } + throw error; + } + } +} + async function writeAssistantOutput( targetPath: string | undefined, content: string, @@ -1344,10 +1451,12 @@ export function deriveModelOutputPath( return path.join(dir, suffix); } +function isErrorCode(error: unknown, expected: string): boolean { + return error instanceof Error && (error as { code?: string }).code === expected; +} + function isPermissionError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - const code = (error as { code?: string }).code; - return code === "EACCES" || code === "EPERM"; + return isErrorCode(error, "EACCES") || isErrorCode(error, "EPERM"); } function buildFallbackPath(original: string): string | null { diff --git a/tests/browser/sessionRunner.test.ts b/tests/browser/sessionRunner.test.ts index cd0864d39..6ea9c118a 100644 --- a/tests/browser/sessionRunner.test.ts +++ b/tests/browser/sessionRunner.test.ts @@ -100,7 +100,19 @@ describe("runBrowserSessionExecution", () => { return { answerText: "ok", answerMarkdown: "ok", - artifacts: [{ kind: "transcript" as const, path: "/tmp/transcript.md" }], + artifacts: [ + { kind: "file" as const, path: "/tmp/report.md", sha256: "a".repeat(64) }, + { kind: "transcript" as const, path: "/tmp/transcript.md" }, + ], + savedFiles: [ + { + kind: "file" as const, + path: "/tmp/report.md", + sha256: "a".repeat(64), + url: "https://chatgpt.com/backend-api/files/report", + filename: "report.md", + }, + ], tookMs: 1000, answerTokens: 12, answerChars: 20, @@ -137,7 +149,13 @@ describe("runBrowserSessionExecution", () => { totalTokens: 54, }); expect(result.runtime).toMatchObject({ chromePid: undefined, conversationId: "foo" }); - expect(result.artifacts).toEqual([{ kind: "transcript", path: "/tmp/transcript.md" }]); + expect(result.artifacts).toEqual([ + { kind: "file", path: "/tmp/report.md", sha256: "a".repeat(64) }, + { kind: "transcript", path: "/tmp/transcript.md" }, + ]); + expect(result.savedFiles).toEqual([ + expect.objectContaining({ path: "/tmp/report.md", sha256: "a".repeat(64) }), + ]); expect(persistRuntimeHint).toHaveBeenCalledWith( expect.objectContaining({ chromePort: 9999, chromeHost: "127.0.0.1", chromeTargetId: "t-1" }), expect.objectContaining({ resolvedLabel: "Pro", verified: true }), diff --git a/tests/cli/sessionRunner.test.ts b/tests/cli/sessionRunner.test.ts index aed9873cb..e87d02a2a 100644 --- a/tests/cli/sessionRunner.test.ts +++ b/tests/cli/sessionRunner.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import fs from "node:fs"; import fsPromises from "node:fs/promises"; import os from "node:os"; @@ -157,6 +158,7 @@ beforeEach(() => { sessionStoreMock.sessionsDir.mockReturnValue("/tmp/.oracle/sessions"); vi.spyOn(fsPromises, "mkdir").mockResolvedValue(undefined); vi.spyOn(fsPromises, "writeFile").mockResolvedValue(undefined); + vi.spyOn(fsPromises, "copyFile").mockResolvedValue(undefined); }); describe("performSessionRun", () => { @@ -1124,6 +1126,124 @@ describe("performSessionRun", () => { ]); }); + test("preserves browser saved files beside write-output without overwriting collisions", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-browser-output-")); + const canonicalDir = path.join(tmpDir, "session", "artifacts"); + const outputDir = path.join(tmpDir, "output"); + const failureOutputDir = path.join(tmpDir, "failed-output"); + fs.mkdirSync(canonicalDir, { recursive: true }); + fs.mkdirSync(outputDir, { recursive: true }); + const canonicalPath = path.join(canonicalDir, "report.md"); + const canonicalBytes = Buffer.from("# Saved report\n\nAuthenticated artifact bytes.\n", "utf8"); + const sha256 = createHash("sha256").update(canonicalBytes).digest("hex"); + fs.writeFileSync(canonicalPath, canonicalBytes); + const collidingPath = path.join(outputDir, "report.md"); + fs.writeFileSync(collidingPath, "existing file must survive\n", "utf8"); + + const savedFile = { + kind: "file" as const, + path: canonicalPath, + label: "report.md", + mimeType: "text/markdown", + sizeBytes: canonicalBytes.length, + sourceUrl: "sandbox:/mnt/data/report.md", + sha256, + validation: { type: "generic" as const, ok: true }, + transfer: { status: "not-needed" as const }, + origin: { mode: "local" as const }, + url: "https://chatgpt.com/backend-api/files/report", + sandboxUrl: "sandbox:/mnt/data/report.md", + filename: "report.md", + }; + vi.mocked(runBrowserSessionExecution).mockResolvedValue({ + usage: { inputTokens: 10, outputTokens: 5, reasoningTokens: 0, totalTokens: 15 }, + elapsedMs: 500, + runtime: { chromePid: 1, chromePort: 9222, userDataDir: "/tmp/chrome" }, + answerText: "sandbox:/mnt/data/report.md", + artifacts: [savedFile], + savedFiles: [savedFile], + }); + vi.mocked(fsPromises.mkdir).mockImplementation(async (target, options) => { + fs.mkdirSync(target, options); + return undefined; + }); + vi.mocked(fsPromises.writeFile).mockImplementation(async (target, data) => { + fs.writeFileSync(target as fs.PathLike, data as string, "utf8"); + }); + vi.mocked(fsPromises.copyFile).mockImplementation(async (source, destination, mode) => { + fs.copyFileSync(source, destination, mode); + }); + + try { + const answerPath = path.join(outputDir, "answer.md"); + await performSessionRun({ + sessionMeta: baseSessionMeta, + runOptions: { ...baseRunOptions, writeOutputPath: answerPath }, + mode: "browser", + browserConfig: { chromePath: null }, + cwd: tmpDir, + log, + write, + version: cliVersion, + }); + + const adjacentPath = path.join(outputDir, "report-2.md"); + expect(fs.readFileSync(answerPath, "utf8")).toBe("sandbox:/mnt/data/report.md\n"); + expect(fs.readFileSync(canonicalPath)).toEqual(canonicalBytes); + expect(fs.readFileSync(collidingPath, "utf8")).toBe("existing file must survive\n"); + expect(fs.readFileSync(adjacentPath)).toEqual(canonicalBytes); + expect(createHash("sha256").update(fs.readFileSync(adjacentPath)).digest("hex")).toBe(sha256); + + const successUpdate = sessionStoreMock.updateSession.mock.calls.at(-1)?.[1]; + expect(successUpdate).toMatchObject({ + status: "completed", + artifacts: expect.arrayContaining([ + expect.objectContaining({ path: canonicalPath, sha256 }), + expect.objectContaining({ path: adjacentPath, sha256, sizeBytes: canonicalBytes.length }), + ]), + }); + expect(log.mock.calls.map((call) => call[0]).join("\n")).toContain( + `Saved write-output file artifact to ${adjacentPath} sha256=${sha256}`, + ); + + const copyError = Object.assign(new Error("simulated copy failure"), { code: "EIO" }); + vi.mocked(fsPromises.copyFile).mockRejectedValueOnce(copyError); + const failureAnswerPath = path.join(failureOutputDir, "answer.md"); + await performSessionRun({ + sessionMeta: baseSessionMeta, + runOptions: { ...baseRunOptions, writeOutputPath: failureAnswerPath }, + mode: "browser", + browserConfig: { chromePath: null }, + cwd: tmpDir, + log, + write, + version: cliVersion, + }); + + expect(fs.readFileSync(failureAnswerPath, "utf8")).toBe("sandbox:/mnt/data/report.md\n"); + expect(fs.existsSync(path.join(failureOutputDir, "report.md"))).toBe(false); + expect(fs.readFileSync(canonicalPath)).toEqual(canonicalBytes); + const failureUpdate = sessionStoreMock.updateSession.mock.calls.at(-1)?.[1]; + expect(failureUpdate).toMatchObject({ + status: "completed", + browser: { + warnings: [ + expect.objectContaining({ + code: "browser-output-artifact-copy-failed", + message: expect.stringContaining("simulated copy failure"), + }), + ], + }, + artifacts: [expect.objectContaining({ path: canonicalPath, sha256 })], + }); + expect(log.mock.calls.map((call) => call[0]).join("\n")).toContain( + `Failed to copy saved file artifact ${canonicalPath}`, + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + test("write-output failures warn but keep session successful", async () => { const liveResult: RunOracleResult = { mode: "live",