Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/browser/sessionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,6 +40,7 @@ export interface BrowserExecutionResult {
warnings?: BrowserRunWarning[];
answerText: string;
artifacts?: SessionArtifact[];
savedFiles?: SavedBrowserFile[];
}

interface RunBrowserSessionArgs {
Expand Down Expand Up @@ -350,6 +351,7 @@ export async function runBrowserSessionExecution(
warnings,
answerText,
artifacts: savedArtifacts,
savedFiles: browserResult.savedFiles,
};
}

Expand Down
123 changes: 116 additions & 7 deletions src/cli/sessionRunner.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -7,6 +8,7 @@ import type {
BrowserSessionConfig,
BrowserRuntimeMetadata,
BrowserModelSelectionEvidence,
BrowserRunWarning,
SessionArtifact,
SessionModelRun,
} from "../sessionStore.js";
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<BrowserOutputArtifactCopyResult> {
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<string> {
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,
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 20 additions & 2 deletions tests/browser/sessionRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }),
Expand Down
120 changes: 120 additions & 0 deletions tests/cli/sessionRunner.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
Expand Down