Skip to content
Merged
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
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,34 @@ jobs:
if: matrix.os == 'ubuntu-latest'
- run: pnpm run test:packed-cli
if: matrix.os == 'ubuntu-latest'

# Linux parity smoke for recoverable CDP disconnect classification (issue #326 / PR #327).
# No ChatGPT auth; exercises real Chromium DevTools against the same probe path as macOS proof.
cdp-disconnect-proof-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
- uses: pnpm/action-setup@v6
- name: Install Chromium
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq chromium-browser || sudo apt-get install -y -qq chromium
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- name: Run CDP disconnect classification proof
env:
CHROME_PATH: /usr/bin/chromium-browser
run: |
if [[ ! -x "${CHROME_PATH}" ]]; then
if [[ -x /usr/bin/chromium ]]; then
export CHROME_PATH=/usr/bin/chromium
else
echo "Chromium binary not found" >&2
exit 1
fi
fi
echo "CHROME_PATH=${CHROME_PATH}"
node scripts/cdp-disconnect-proof.mjs
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixed

- Browser: recover completed answers after a recoverable DevTools disconnect by confirming target liveness and attempting bounded reattachment, while preserving fail-closed handling for unavailable targets. Fixes #326. Thanks @piyushbag!
- CLI: avoid inheriting `browser.thinkingTime` from config when `--browser-model-strategy current` is explicit, while preserving an explicit `--browser-thinking-time` override. Thanks @jung0han!

## 0.16.0 — 2026-07-12
Expand Down
218 changes: 218 additions & 0 deletions scripts/cdp-disconnect-proof.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
#!/usr/bin/env node
/**
* Real-Chrome proof for recoverable vs closed-target CDP disconnects.
*
* Merge-readiness for PR #327:
* 1) Client disconnect while Chrome/target stay alive → recoverable
* 2) Chrome gone → non-recoverable closed fallback
*
* Does not require ChatGPT login.
*/
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath, pathToFileURL } from "node:url";

const require = createRequire(import.meta.url);
const { Launcher } = require("chrome-launcher");
const CDP = require("chrome-remote-interface");

const here = path.dirname(fileURLToPath(import.meta.url));
const distRoot = path.resolve(here, "..", "dist", "src");
const { probeChromeTargetLiveness, isRecoverableChromeDisconnect, connectionLostUserMessage } =
await import(pathToFileURL(path.join(distRoot, "browser", "cdpLiveness.js")).href);

function log(step, detail) {
console.log(`[${new Date().toISOString()}] ${step}${detail ? `: ${detail}` : ""}`);
}

/** Resolve Chrome/Chromium for macOS + Linux CI/Docker without hardcoding one OS. */
function resolveChromePath() {
if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) {
return process.env.CHROME_PATH;
}
const candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
try {
const installations = Launcher.getInstallations?.() ?? [];
if (installations[0] && existsSync(installations[0])) {
return installations[0];
}
} catch {
/* ignore */
}
throw new Error(
"Chrome/Chromium not found. Set CHROME_PATH to a browser binary (Linux: chromium or google-chrome).",
);
}

async function listTargets(port) {
const res = await fetch(`http://127.0.0.1:${port}/json/list`);
if (!res.ok) throw new Error(`/json/list failed: ${res.status}`);
return res.json();
}

async function run() {
const userDataDir = await mkdtemp(path.join(os.tmpdir(), "oracle-cdp-proof-"));
let chrome;
let client;
let port;
const chromePath = resolveChromePath();

try {
log("platform", `${process.platform}/${process.arch} chromePath=${chromePath}`);
log("launch", "Chrome/Chromium headless with remote debugging");
// Keep the launcher handle before awaiting readiness. If DevTools startup
// times out, the rejected promise otherwise loses the spawned Chrome pid
// and leaves the proof process behind.
chrome = new Launcher({
chromePath,
chromeFlags: [
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
// Required for Chromium in many Linux/Docker/CI sandboxes.
"--no-sandbox",
"--disable-dev-shm-usage",
],
userDataDir,
handleSIGINT: false,
});
await chrome.launch();
port = chrome.port;
log("chrome-up", `port=${port} pid=${chrome.pid}`);

client = await CDP({ host: "127.0.0.1", port });
const { Page, Runtime } = client;
await Page.enable();
await Page.navigate({
url: "data:text/html,<title>oracle-cdp-proof</title><h1>alive</h1>",
});
await Page.loadEventFired();
const { result: titleResult } = await Runtime.evaluate({
expression: "document.title",
returnByValue: true,
});
log("page-ready", `title=${titleResult.value}`);

const targetsBefore = await listTargets(port);
const pageTarget =
targetsBefore.find(
(t) => t.type === "page" && String(t.url || "").includes("oracle-cdp-proof"),
) || targetsBefore.find((t) => t.type === "page");
if (!pageTarget?.id) throw new Error("no page target found");
const targetId = pageTarget.id;
log("target", `id=${targetId.slice(0, 8)}…`);

log("case-1", "CDP client disconnect; Chrome+target remain");
await client.close();
client = null;
await new Promise((r) => setTimeout(r, 300));

const alive = await probeChromeTargetLiveness({
host: "127.0.0.1",
port,
targetId,
});
const recoverable = isRecoverableChromeDisconnect(alive);
log(
"case-1-result",
JSON.stringify({
endpointReachable: alive.endpointReachable,
targetFound: alive.targetFound,
recoverable,
userMessage: connectionLostUserMessage({ recoverable }),
}),
);
if (!recoverable) {
throw new Error("case-1 failed: expected recoverable while target alive");
}
log("case-1-pass", "recoverableDisconnect path; sessionRunner would auto-reattach");

log("case-2", "close target + kill Chrome");
client = await CDP({ host: "127.0.0.1", port });
try {
await client.Target.closeTarget({ targetId });
} catch (err) {
log("close-target", String(err?.message || err));
}
await client.close();
client = null;
chrome.kill();
chrome = null;

let endpointDead = false;
for (let i = 0; i < 50; i++) {
try {
await fetch(`http://127.0.0.1:${port}/json/version`, {
signal: AbortSignal.timeout(300),
});
} catch {
endpointDead = true;
break;
}
await new Promise((r) => setTimeout(r, 100));
}
if (!endpointDead) {
throw new Error("chrome endpoint still reachable after kill");
}

const dead = await probeChromeTargetLiveness({
host: "127.0.0.1",
port,
targetId,
});
const recoverableDead = isRecoverableChromeDisconnect(dead);
log(
"case-2-result",
JSON.stringify({
endpointReachable: dead.endpointReachable,
targetFound: dead.targetFound,
recoverable: recoverableDead,
userMessage: connectionLostUserMessage({ recoverable: recoverableDead }),
error: dead.error,
}),
);
if (recoverableDead) {
throw new Error("case-2 failed: expected non-recoverable after Chrome closed");
}
log("case-2-pass", "closed-window message; no stale auto-reattach loop");

console.log("\nPROOF_OK both disconnect outcomes demonstrated against real Chrome CDP");
} catch (error) {
console.error("\nPROOF_FAILED", error);
process.exitCode = 1;
} finally {
try {
await client?.close?.();
} catch {
/* ignore */
}
try {
chrome?.kill?.();
} catch {
/* ignore */
}
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
}
}

await run();
// chrome-launcher can retain an internal log descriptor after a failed startup
// with a caller-owned profile. Cleanup above is complete, so terminate with the
// recorded proof status instead of leaving CI waiting on that descriptor.
process.exit(process.exitCode ?? 0);
40 changes: 40 additions & 0 deletions scripts/export-chatgpt-cookies.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { writeOwnerOnlyFile } from "./write-owner-only-file.mjs";
import { getCookies } from "@steipete/sweet-cookie";

const out = process.argv[2];
if (!out) {
console.error("usage: export-chatgpt-cookies.mjs <outfile.json>");
process.exit(2);
}

const { cookies } = await getCookies({
url: "https://chatgpt.com",
origins: ["https://chatgpt.com", "https://chat.openai.com", "https://atlas.openai.com"],
browsers: ["chrome"],
mode: "merge",
chromeProfile: "Default",
timeoutMs: 10_000,
});

// CDP CookieParam shape only; drop sweet-cookie metadata that Oracle ignores.
const payload = cookies.map(
({ name, value, domain, path, secure, httpOnly, sameSite, expires }) => {
const cookie = {
name,
value,
domain,
path: path ?? "/",
secure: secure ?? true,
httpOnly: Boolean(httpOnly),
};
if (sameSite === "Lax" || sameSite === "Strict" || sameSite === "None")
cookie.sameSite = sameSite;
if (typeof expires === "number" && expires > 0) cookie.expires = expires;
return cookie;
},
);

const hasSession = payload.some((c) => c.name.startsWith("__Secure-next-auth.session-token"));
writeOwnerOnlyFile(out, JSON.stringify(payload));
console.log(`wrote ${payload.length} cookies to ${out}; session=${hasSession}`);
process.exit(hasSession ? 0 : 1);
Loading