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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

## [0.4.1] - 2026-11-09

### Added

- Compatibility manifest entry for ChatGPT.app 26.1103.60933, verified end to end against Helium
- `run` explains unavailable-browser errors without treating them as proof of an incompatible ChatGPT.app release or replaying failed tool calls.

## [0.4.0] - 2026-11-04

### Added
Expand Down Expand Up @@ -91,7 +98,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Disabled-by-default Claude Code plugin with setup, doctor, and browser skills
- stderr secret redaction and strict stdout protocol purity

[Unreleased]: https://github.com/stickerdaniel/browserjack/compare/v0.4.0...HEAD
[Unreleased]: https://github.com/stickerdaniel/browserjack/compare/v0.4.1...HEAD
[0.4.1]: https://github.com/stickerdaniel/browserjack/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/stickerdaniel/browserjack/compare/v0.3.3...v0.4.0
[0.3.3]: https://github.com/stickerdaniel/browserjack/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/stickerdaniel/browserjack/compare/v0.3.1...v0.3.2
Expand Down
10 changes: 9 additions & 1 deletion compatibility/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"generatedAt": "2026-11-04",
"generatedAt": "2026-11-09",
"entries": [
{
"bundleId": "com.openai.codex",
Expand Down Expand Up @@ -33,6 +33,14 @@
"teamId": "2DC432GLL2",
"architectures": ["arm64"],
"browserClientSha256": "6704e0f3b0c94d3e041a22488550d4c29e6252a934163d0aa539a7d947d5d17b"
},
{
"bundleId": "com.openai.codex",
"appVersion": "26.1103.60933",
"pluginVersion": "26.1103.60933",
"teamId": "2DC432GLL2",
"architectures": ["arm64"],
"browserClientSha256": "becbcc75697f519cad57e002267f14581dd759d235021411ed61637b961ce469"
}
]
}
2 changes: 2 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ The installed shim pins the exact Node.js executable used at install time. A Hom

`doctor --live` verifies that the runtime starts and that `agent.browsers.list()` works; zero connected backends is not a runtime failure. Make sure the ChatGPT/Codex extension is installed and enabled in the browser profile you intend to use, and that ChatGPT.app is running.

`Browser is not available: chrome` means the runtime could not find the requested browser. `No browser is available` means discovery found no usable backend. A disconnected extension or an unavailable browser ID can cause these errors; they do not by themselves establish that ChatGPT.app is incompatible. `run` appends this distinction to failed tool results. It does not replay tool calls, which may already have performed actions before failing.

## The browser asks for site or automation permission

OpenAI's extension has its own permission layer, independent of Claude Code's permission mode. When a site prompts, choose `Allow for this site` to persist an origin-specific entry. Avoid `Allow for all sites` or any global never-ask setting; Browserjack does not enable those and you should not either.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "browserjack",
"version": "0.4.0",
"version": "0.4.1",
"description": "A local macOS MCP bridge that lets Claude Code and other MCP clients reuse OpenAI's installed Codex browser runtime.",
"keywords": [
"browser-automation",
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "browserjack",
"displayName": "Browserjack",
"version": "0.4.0",
"version": "0.4.1",
"description": "Use OpenAI's locally installed Codex browser runtime from Claude Code (macOS)",
"author": {
"name": "Daniel Sticker"
Expand Down
2 changes: 1 addition & 1 deletion plugin/skills/setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Explain the trust boundary before installation: the bridge can control authentic
For a published release, install an exact reviewed version:

```bash
npx --yes browserjack@0.4.0 setup --client plugin --scope user
npx --yes browserjack@0.4.1 setup --client plugin --scope user
```

For a source checkout, run:
Expand Down
8 changes: 6 additions & 2 deletions src/runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { createRuntimeLaunch } from "./launch.js";
import { readLines } from "./lines.js";
import { createTurnState, injectTurnMetadata } from "./metadata.js";
import { RedactingTransform } from "./redact.js";
import { annotateToolError } from "./tool-errors.js";
import { annotateToolError, browserUnavailableNotice } from "./tool-errors.js";

const MAX_MCP_LINE_BYTES = 16 * 1024 * 1024;

Expand Down Expand Up @@ -70,10 +70,14 @@ async function forwardChildOutput(
browserClientUrl: string,
outdatedAppNotice: () => Promise<string | null>,
): Promise<void> {
const notices = async (errorText: string): Promise<string[]> =>
[browserUnavailableNotice(errorText), await outdatedAppNotice()].filter(
(notice) => notice !== null,
);
for await (const line of readLines(output, MAX_MCP_LINE_BYTES)) {
await writeWithBackpressure(
process.stdout,
(await annotateToolError(line, outdatedAppNotice)) ??
(await annotateToolError(line, notices)) ??
addBrowserRuntimeInstructions(line, browserClientUrl),
);
}
Expand Down
34 changes: 27 additions & 7 deletions src/runtime/tool-errors.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { isJsonObject } from "../lib/json.js";

// Appends a Browserjack notice to a failed tools/call result, so the agent sees
// the likely cause next to OpenAI's error text. Resolves to null for every other
// line and when there is no notice, and only then looks the notice up.
// OpenAI reports an unavailable browser when discovery cannot match its ID or
// family. The error alone says nothing about app/runtime compatibility.
const NO_AVAILABLE_BROWSER =
/^(?:Error: )?(?:Browser is not available: [^\r\n]+|No browser is available)$/m;

export function browserUnavailableNotice(errorText: string): string | null {
return NO_AVAILABLE_BROWSER.test(errorText)
? "The runtime could not find the requested browser. A disconnected extension or an unavailable browser ID can cause this error. This error alone does not establish that ChatGPT.app is incompatible."
: null;
}

// Appends Browserjack notices to a failed tools/call result, so the agent sees
// the likely cause next to OpenAI's error text. `notices` receives the result's
// joined text and runs only for failed results. Resolves to null for every
// other line and when there are no notices.
export async function annotateToolError(
line: string,
notice: () => Promise<string | null>,
notices: (errorText: string) => Promise<string[]>,
): Promise<string | null> {
let message: unknown;
try {
Expand All @@ -21,10 +33,18 @@ export async function annotateToolError(
) {
return null;
}
const text = await notice();
if (text === null) {
const errorText = message.result.content
.map((item) =>
isJsonObject(item) && item.type === "text" && typeof item.text === "string" ? item.text : "",
)
.join("\n");
const texts = await notices(errorText);
if (texts.length === 0) {
return null;
}
const content = [...message.result.content, { type: "text", text: `browserjack: ${text}` }];
const content = [
...message.result.content,
...texts.map((text) => ({ type: "text", text: `browserjack: ${text}` })),
];
return `${JSON.stringify({ ...message, result: { ...message.result, content } })}\n`;
}
85 changes: 74 additions & 11 deletions test/latest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import { createOutdatedAppCheck, isOlderVersion, parseNewestBuild } from "../dist/compat/latest.js";
import { annotateToolError } from "../dist/runtime/tool-errors.js";
import { annotateToolError, browserUnavailableNotice } from "../dist/runtime/tool-errors.js";

// Trimmed from OpenAI's appcast on 2026-11-04; the first item is cut down to
// its delta enclosure to exercise the full-zip requirement.
Expand All @@ -21,15 +21,16 @@ const appcast = `<rss><channel>
</item>
</channel></rss>`;

function failedResult(text) {
return JSON.stringify({
jsonrpc: "2.0",
id: 3,
result: { content: [{ type: "text", text }], isError: true },
});
}

// The js tool result a goto produced on ChatGPT.app 26.1021 with extension 1.26.1102.
const failedToolLine = JSON.stringify({
jsonrpc: "2.0",
id: 3,
result: {
content: [{ type: "text", text: "Cannot read properties of undefined (reading 'timeoutMs')" }],
isError: true,
},
});
const failedToolLine = failedResult("Cannot read properties of undefined (reading 'timeoutMs')");

test("takes the newest feed item that has a full arm64 zip", () => {
assert.deepEqual(parseNewestBuild(appcast), {
Expand Down Expand Up @@ -87,7 +88,7 @@ test("reads the feed once per day and retries a failure after ten minutes", asyn
});

test("appends the notice to a failed tool result", async () => {
const line = await annotateToolError(failedToolLine, async () => "Update ChatGPT.app.");
const line = await annotateToolError(failedToolLine, async () => ["Update ChatGPT.app."]);
const { result } = JSON.parse(line ?? "null");
assert.equal(result.isError, true);
assert.deepEqual(result.content.at(-1), {
Expand All @@ -107,5 +108,67 @@ test("leaves successful results alone without looking up a notice", async () =>
});
assert.equal(await annotateToolError(success, unexpectedNotice), null);
assert.equal(await annotateToolError("not json", unexpectedNotice), null);
assert.equal(await annotateToolError(failedToolLine, async () => null), null);
assert.equal(await annotateToolError(failedToolLine, async () => []), null);
});

test("annotates unavailable browsers without assuming an extension connection", async () => {
// The first result was captured from ChatGPT.app 26.1103 with an unavailable Edge backend.
for (const error of [
"Browser is not available: edge",
"Browser is not available: chrome",
"No browser is available",
"Browser is not available: dot",
"Browser is not available: 42",
]) {
const line = await annotateToolError(failedResult(error), async (errorText) =>
[browserUnavailableNotice(errorText)].filter((notice) => notice !== null),
);
const { result } = JSON.parse(line ?? "null");
assert.equal(result.isError, true);
assert.deepEqual(result.content[0], { type: "text", text: error });
assert.match(
result.content.at(-1).text,
/^browserjack: The runtime could not find the requested browser/,
);
}
assert.equal(browserUnavailableNotice("Cannot read properties of undefined"), null);
assert.equal(browserUnavailableNotice("A page says Browser is not available: chrome"), null);
});

test("reads only text content and preserves mixed content and metadata", async () => {
const message = {
jsonrpc: "2.0",
id: 3,
result: {
isError: true,
_meta: { "codex/telemetry": { error_code: "browser_error" } },
content: [
{ type: "text", text: "Browser is not available: edge" },
{ type: "text", text: 'Reported browsers: [{"family":"chrome","id":"1"}]' },
{ type: "image", mimeType: "image/png", data: "AA==" },
{ type: "resource", resource: { uri: "test:log", text: "resource text" } },
null,
],
},
};
const notices = ["Diagnostic", "Update notice"];
const output = JSON.parse(
await annotateToolError(JSON.stringify(message), async (text) => {
assert.equal(
text,
'Browser is not available: edge\nReported browsers: [{"family":"chrome","id":"1"}]\n\n\n',
);
return notices;
}),
);
assert.deepEqual(output, {
...message,
result: {
...message.result,
content: [
...message.result.content,
...notices.map((text) => ({ type: "text", text: `browserjack: ${text}` })),
],
},
});
});
Loading