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
39 changes: 32 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ The pane falls back automatically when WebSocket support is unavailable, the
stream disconnects, or the selected renderer cannot display streamed JPEGs.
No feature flag is required.

If a stream connects but sends no usable image within five seconds, the pane
returns to screenshot polling and retries streaming after its cooldown. A
connected WebSocket alone does not establish that image delivery is working.

### Pane fitting

On attach and pane resize, herdr-browser preserves the session's current
Expand Down Expand Up @@ -337,7 +341,8 @@ name appears in the viewer header.
closed with it so the browser daemon is not leaked.
- The **Close** action always ends the workspace session and closes its browser
panes.
- Plugin-created browser daemons default to a 30-minute idle timeout.
- Daemon idle timeouts follow agent-browser's configuration. The viewer does
not inject a different timeout, which can restart an existing daemon.

To watch a differently named agent-browser session, write its name to the
plugin configuration directory:
Expand Down Expand Up @@ -414,7 +419,7 @@ Equivalent environment controls:
| `HERDR_BROWSER_LAUNCH` | Unset | `1` launches a Chromium on open instead of waiting for a session |
| `HERDR_BROWSER_OBSERVE` | Unset | `1` starts the pane observe-only |
| `HERDR_BROWSER_INTERVAL_MS` | `1000` | Polling interval; clamped to safe bounds |
| `AGENT_BROWSER_IDLE_TIMEOUT_MS` | `1800000` | Idle timeout for plugin-created browser daemons |
| `AGENT_BROWSER_IDLE_TIMEOUT_MS` | Engine default | Inherited unchanged; use the same value for the agent and pane to avoid daemon configuration changes |

Environment variables take precedence over config files.

Expand Down Expand Up @@ -458,17 +463,37 @@ needed.

## Development

Use Node 22+ for full browser support (Node 20 supports polling only), Python
3.11+ for manifest validation, and ShellCheck for launcher validation. The
plugin runs its source directly; there is no bundled browser or compilation
step.

```sh
git clone https://github.com/StructuPath/herdr-browser
cd herdr-browser
npm test
shellcheck scripts/*.sh
npm run doctor
npm run build
npm run validate
npm run test:integration
herdr plugin link .
```

`npm test` includes unit, launcher, security, rendering, input, recording, and
live-stream coverage. The real agent-browser integration test skips when its
engine is unavailable.
`npm run doctor` checks local prerequisites without starting a browser,
contacting an endpoint, or changing configuration. Optional tools are warnings;
missing prerequisites for the selected backend cause a nonzero exit. It does
not verify Herdr's version, engine downloads, or endpoint reachability.

`npm run build` checks every JavaScript and shell source file plus the plugin
manifest. `npm run validate` adds ShellCheck and the complete test suite.
`npm test` includes launcher, security, rendering, input, recording, and live
browser coverage; optional browser tests skip when their prerequisites are
unavailable. `npm run test:integration` requires both an installed Chrome or
Chromium and agent-browser with its engine installed, on Node 22+. It fails
instead of silently skipping either real-browser path. Tests use a local HTTP
fixture and isolated browser sessions.

For backend choices and the remaining readiness work, see the
[readiness assessment](docs/readiness.md).

## License

Expand Down
49 changes: 49 additions & 0 deletions bin/doctor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import { findChromium, truthyConfig } from "./renderer.mjs";

export function diagnose(env = process.env, {
probe = (command) => spawnSync("/bin/sh", ["-c", 'command -v -- "$1"', "sh", command], {
env, timeout: 5_000, stdio: "ignore",
}).status === 0,
readConfig = (name) => {
if (!env.HERDR_PLUGIN_CONFIG_DIR) return "";
try { return fs.readFileSync(path.join(env.HERDR_PLUGIN_CONFIG_DIR, name), "utf8").split("\n")[0].trim(); }
catch { return ""; }
},
nodeVersion = process.versions.node,
webSocket = typeof WebSocket === "function",
} = {}) {
const checks = [];
const add = (status, name, detail) => checks.push({ status, name, detail });
add(Number(nodeVersion.split(".")[0]) >= 20 ? "ok" : "error", "Node.js", `${nodeVersion}; Node 22+ recommended for streaming, attach, and launch`);
add(probe(env.HERDR_BIN_PATH || "herdr") ? "ok" : "error", "Herdr CLI", "requires Herdr >= 0.7.0; set HERDR_BIN_PATH if it is not on PATH");
const agent = probe("agent-browser");
const chromium = findChromium(env, readConfig("chromium"), probe);
const launchable = !!chromium && probe(chromium);
const endpoint = String(env.HERDR_BROWSER_CDP_URL || readConfig("cdp-url")).trim();
const launch = truthyConfig(env.HERDR_BROWSER_LAUNCH || readConfig("launch"));
if (endpoint) {
let valid = false;
try { valid = ["http:", "https:", "ws:", "wss:"].includes(new URL(endpoint).protocol); } catch {}
add(valid && webSocket ? "ok" : "error", "Selected backend: attach", valid ? "endpoint configured (not contacted); requires Node 22+" : "invalid endpoint; use http://host:port or a browser WebSocket URL");
} else if (launch) {
add(launchable && webSocket ? "ok" : "error", "Selected backend: launch", "requires Node 22+ and an executable Chrome/Chromium; set HERDR_BROWSER_CHROMIUM");
} else {
add(agent || (launchable && webSocket) ? "ok" : "error", "Browser engine", agent ? "agent-browser available; install its engine with agent-browser install" : launchable && webSocket ? "Chrome/Chromium available; press l or set HERDR_BROWSER_LAUNCH=1" : "install agent-browser and run agent-browser install, or configure Chrome/Chromium with Node 22+");
}
add(launchable ? "ok" : "warn", "Local Chromium", launchable ? "executable found; launch not attempted" : "not found; optional for shared agent sessions and external attach");
add(probe("chafa") ? "ok" : "warn", "Image rendering", "chafa enables ANSI images and Kitty JPEGs; macOS: brew install chafa");
add(probe("carbonyl") ? "ok" : "warn", "Interactive Browse", "Carbonyl is optional and uses a separate browser session");
return checks;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const checks = diagnose();
for (const { status, name, detail } of checks) console.log(`${status.toUpperCase()} ${name}: ${detail}`);
console.log("Prerequisite check only. No browsers launched, endpoints contacted, or configuration changed.");
process.exitCode = checks.some((c) => c.status === "error") ? 1 : 0;
}
54 changes: 30 additions & 24 deletions bin/renderer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -427,14 +427,8 @@ export function makeBrowser(session, bin = "agent-browser") {
// Console rings on noisy pages reach several MB — Node's 1 MiB default
// maxBuffer would throw on every tick and freeze the pane for good.
const maxBuffer = 16 * 1024 * 1024;
// If a call is the one that spawns the session daemon, the daemon
// self-reaps after idle instead of living forever. No-op for daemons an
// agent already owns (read at daemon spawn only).
const abEnv = () => ({
...process.env,
AGENT_BROWSER_IDLE_TIMEOUT_MS:
process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS || "1800000",
});
// Inherit the caller's configuration unchanged. Injecting an idle timeout
// makes agent-browser restart an existing daemon and lose its browser.
const parse = (stdout, what) => {
let parsed;
try {
Expand All @@ -452,7 +446,7 @@ export function makeBrowser(session, bin = "agent-browser") {
const { stdout } = await pExecFile(
bin,
["--session", session, "batch", "--bail", "--json", ...cmds],
{ timeout, maxBuffer, env: abEnv() },
{ timeout, maxBuffer },
);
const arr = parse(stdout, "batch output");
for (const r of arr) {
Expand All @@ -470,7 +464,6 @@ export function makeBrowser(session, bin = "agent-browser") {
{
timeout: 10_000,
maxBuffer,
env: abEnv(),
},
);
const parsed = parse(stdout, "output");
Expand Down Expand Up @@ -1937,11 +1930,27 @@ export class Renderer {
const port = Number(status?.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
let ws;
const initialFrameSeq = this.frameSeq;
try {
ws = new WebSocket(`ws://127.0.0.1:${port}`);
} catch {
return false; // malformed URL or constructor failure: just poll
}
// Chrome can send its only frame for a quiet page with the handshake.
// Install the listener before awaiting open so those messages survive.
ws.onmessage = (ev) => {
let m;
try {
m = JSON.parse(ev.data);
} catch {
return;
}
try {
this.onStreamMessage(m);
} catch {
this.paintErrors++;
}
};
const ok = await new Promise((resolve) => {
const timer = setTimeout(() => {
try {
Expand All @@ -1962,21 +1971,13 @@ export class Renderer {
});
if (!ok) return false;
this.live = { ws };
ws.onmessage = (ev) => {
let m;
try {
m = JSON.parse(ev.data);
} catch {
return;
const live = this.live;
live.firstFrameTimer = setTimeout(() => {
if (this.live === live && this.frameSeq === initialFrameSeq) {
this.dropLive("live stream sent no image — polling");
}
// A malformed message must never become an uncaughtException —
// those bypass cleanup() and leave the terminal wedged.
try {
this.onStreamMessage(m);
} catch {
this.paintErrors++;
}
};
}, 5_000);
live.firstFrameTimer.unref?.();
const drop = () => this.dropLive("live stream dropped — polling");
ws.onclose = drop;
ws.onerror = drop;
Expand All @@ -1992,6 +1993,10 @@ export class Renderer {
this.stopNetworkTimer();
const wasLive = !!this.live;
if (this.live) {
clearTimeout(this.live.firstFrameTimer);
this.live.ws.onmessage = null;
this.live.ws.onclose = null;
this.live.ws.onerror = null;
try {
this.live.ws.close();
} catch {
Expand Down Expand Up @@ -2193,6 +2198,7 @@ export class Renderer {
cleanup() {
if (this.mode === "kitty") process.stdout.write(KITTY_DELETE_ALL);
this.stopNetworkTimer();
clearTimeout(this.live?.firstFrameTimer);
try {
this.live?.ws.close();
} catch {
Expand Down
64 changes: 64 additions & 0 deletions docs/readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Browser readiness assessment

## Engine decision

Keep the existing Chromium integrations. Current main already supports a
locally launched Chrome/Chromium (`l`), external CDP attach (`a`), shared
agent-browser sessions, and an optional separate Carbonyl browser. Adding an
Electron shell or maintaining a Chromium fork would duplicate the engine and
add packaging and update work without fixing the session and streaming bugs.

- Use **agent-browser** to share a coding agent's session.
- Use **local Chromium launch** for standalone browsing in the pane. Set
`HERDR_BROWSER_LAUNCH=1` and, if discovery fails,
`HERDR_BROWSER_CHROMIUM=/absolute/path/to/chrome`.
- Use **CDP attach** to watch an existing automation browser. Use observe-only
mode when the pane should not forward user input.
- Use **Carbonyl** when full terminal-native interaction matters more than
sharing the agent's session.

Upstream installation references: [agent-browser](https://agent-browser.dev/installation)
and [Carbonyl](https://github.com/fathyb/carbonyl). Agent-browser installs its
own Chrome engine; a second bundled distribution is unnecessary.

## Changes in this PR

- Preserve daemon configuration: injecting a 30-minute idle timeout into an
existing agent-browser 0.33.2 session restarted its daemon and discarded its
browser during a read-only stream-status request. Both launcher navigation
and renderer commands now inherit the caller's settings unchanged.
- Register the frame listener before awaiting the WebSocket handshake.
- Recover to screenshot polling if a connected stream sends no image within
five seconds; remove stale socket listeners and cancel the watchdog on exit.
- Add build, validation, prerequisite diagnostics, and mandatory real-browser
checks. Real tests cover local Chromium lifecycle, streaming, navigation,
console delivery, reconnect, and screenshot fallback without public sites.
- Limit test discovery to this checkout's `tests/` directory; Node 20 otherwise
traverses hidden nested worktrees and runs unrelated, stale test copies.

## Next recommendations, in priority order

1. **Release compatibility matrix.** Exercise macOS and Linux, Node 22/24,
supported agent-browser versions, and Kitty/symbol rendering in Herdr.
Current CI has Node 20/22 but does not install agent-browser, so its passing
status alone cannot prove shared-session integration. Adopting the strict
integration command in hosted CI is a separate workflow change.
2. **CDP transport correctness.** HTTP discovery currently uses `node:http`
even for an HTTPS input and assumes the local debugging port. Implement
actual HTTPS discovery with transport tests before advertising secured
remote HTTP discovery; use a verified browser WebSocket endpoint meanwhile.
3. **Long-session reliability.** Add soak tests for daemon restart, stalled
streams after the first frame, rapid resize, multiple viewers, and reconnect
during navigation. The new watchdog covers initial image delivery, not all
possible later stalls.
4. **Interactive browser completeness.** Prioritize keyboard shortcuts,
downloads, file upload, dialogs, and explicit target selection. Define
behavior separately for owned and externally controlled browsers and prove
each against a local fixture before adding UI controls.
5. **Recording across backends.** Recording currently requires agent-browser.
Direct Chromium/CDP recording should clearly state its capture lifecycle
and avoid altering externally owned browser contexts.

These are follow-up milestones, not claims that this plugin is a full browser
replacement. The current PR addresses startup verification and the reproduced
shared-session failure while retaining the existing engine architecture.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"type": "module",
"engines": { "node": ">=20" },
"scripts": {
"test": "node --test"
"build": "node scripts/build.mjs",
"doctor": "node bin/doctor.mjs",
"test": "node --test tests/*.test.mjs",
"test:integration": "HERDR_BROWSER_REQUIRE_INTEGRATION=1 node --test --test-name-pattern='end to end|e2e:' tests/launch.integration.test.mjs tests/renderer.test.mjs",
"validate": "npm run build && shellcheck scripts/*.sh && npm test"
}
}
32 changes: 32 additions & 0 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env node
// This plugin ships source directly; building verifies the runnable package.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { validateRepository } from "./check-manifest.mjs";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
let count = 0;
for (const dir of ["bin", "scripts", "tests"]) {
for (const name of fs.readdirSync(path.join(root, dir)).sort()) {
const file = path.join(dir, name);
const command = name.endsWith(".mjs") ? process.execPath : name.endsWith(".sh") ? "bash" : null;
if (!command) continue;
const result = spawnSync(command, [command === "bash" ? "-n" : "--check", file], {
cwd: root, stdio: "inherit", timeout: 10_000,
});
if (result.error || result.status !== 0) {
console.error(`Build failed: ${file}${result.error ? `: ${result.error.message}` : ""}`);
process.exit(1);
}
count++;
}
}
const { errors, entrypointCount } = validateRepository(root);
if (errors.length) {
for (const error of errors) console.error(error);
process.exitCode = 1;
} else {
console.log(`Build verified: ${count} source files, ${entrypointCount} manifest entrypoints. No compilation required.`);
}
5 changes: 2 additions & 3 deletions scripts/open.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,8 @@ elif [ -n "$url" ]; then
echo "herdr-browser: refusing URL (must start with http:// or https://, no credentials): $url" >&2
exit 2
fi
# If this call spawns the session daemon, let it self-reap after 30 min
# idle instead of living forever (no-op for daemons agents already own).
export AGENT_BROWSER_IDLE_TIMEOUT_MS="${AGENT_BROWSER_IDLE_TIMEOUT_MS:-1800000}"
# Preserve the caller's daemon configuration. An injected timeout can
# restart an existing agent-owned daemon and discard its browser state.
if ! with_timeout 15 agent-browser --session "$session" open "$url" >/dev/null; then
echo "herdr-browser: agent-browser failed to open $url" >&2
exit 3
Expand Down
36 changes: 36 additions & 0 deletions tests/doctor.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import test from "node:test";
import assert from "node:assert/strict";
import { diagnose } from "../bin/doctor.mjs";

const check = (env = {}, available = [], options = {}) => diagnose(env, {
probe: (name) => available.includes(name), readConfig: () => "", nodeVersion: "22.0.0", webSocket: true, ...options,
});
const errors = (checks) => checks.filter((c) => c.status === "error");

test("doctor accepts shared sessions and treats rendering tools as optional", () => {
const checks = check({}, ["herdr", "agent-browser"]);
assert.deepEqual(errors(checks), []);
assert.ok(checks.some((c) => c.name === "Image rendering" && c.status === "warn"));
});

test("doctor requires a usable engine and Herdr", () => {
assert.equal(errors(check()).length, 2);
assert.equal(errors(check({}, ["herdr", "google-chrome"])).length, 0);
assert.equal(errors(check({}, ["herdr", "google-chrome"], { webSocket: false })).length, 1);
});

test("doctor honors selected backend and never prints endpoint credentials", () => {
const env = { HERDR_BROWSER_CDP_URL: "wss://user:secret@example.com/devtools/browser/private-token", HERDR_BROWSER_LAUNCH: "1" };
const checks = check(env, ["herdr"]);
assert.deepEqual(errors(checks), []);
assert.doesNotMatch(JSON.stringify(checks), /secret|private-token/);
assert.equal(errors(check(env, ["herdr"], { webSocket: false })).length, 1);
assert.equal(errors(check({ HERDR_BROWSER_CDP_URL: "bad" }, ["herdr"])).length, 1);
});

test("doctor checks explicit Chromium executability and configuration files", () => {
assert.equal(errors(check({ HERDR_BROWSER_LAUNCH: "1", HERDR_BROWSER_CHROMIUM: "/missing" }, ["herdr", "agent-browser"])).length, 1);
assert.deepEqual(errors(check({}, ["herdr", "/custom/chrome"], {
readConfig: (name) => ({ launch: "true", chromium: "/custom/chrome" })[name] || "",
})), []);
});
Loading
Loading