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
45 changes: 45 additions & 0 deletions .github/workflows/windows-host.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Windows Host

on:
pull_request:
paths:
- ".github/workflows/windows-host.yml"
- "package/ego-windows-host/**"
push:
branches:
- dev
- main
paths:
- ".github/workflows/windows-host.yml"
- "package/ego-windows-host/**"

jobs:
test:
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22

# --ignore-scripts: installing the file:../ego-browser dependency would
# otherwise run ego-browser's prepare script (lefthook install), which is
# unnecessary in CI and not yet Windows-safe (see #148).
- name: Install dependencies
working-directory: package/ego-windows-host
run: npm ci --ignore-scripts

- name: Build ego-browser dependency
working-directory: package/ego-browser
run: npm ci --ignore-scripts && npm run build

- name: Run tests
working-directory: package/ego-windows-host
run: npm test
2 changes: 2 additions & 0 deletions package/ego-windows-host/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
105 changes: 105 additions & 0 deletions package/ego-windows-host/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# ego-windows-host

Run the `ego-browser` agent runtime against stock Microsoft Edge or Google
Chrome on Windows — a preview for Windows users while native ego lite Windows
support (#203) is under evaluation.

The [ego lite app](https://lite.ego.app/) is macOS-only today. This package
implements the same `globalThis.ego` contract the app's native bridge provides,
backed by any CDP-capable Chromium already installed on the machine, and then
delegates execution to the unmodified `ego-browser` runtime. Everything the
runtime offers — `page`, `page.locator(...)`, `browser`, `taskSpaces`,
snapshots, screenshots, waits — runs as-is.

The host model follows the Linux host prior art in #134 / #202 (shared profile,
task spaces as tracked tab sets with ownership), rebuilt for Windows: Edge
detection, `%LOCALAPPDATA%` state, no POSIX daemon — the detached browser
itself is the persistent process, so there is nothing extra to manage.

```
agent script ──> ego-browser runtime (unmodified)
│ globalThis.ego
ego-windows-host bridge
│ two CDP websockets (loopback)
stock Edge / Chrome (detached, dedicated profile)
```

## Quick start

```powershell
cd package/ego-browser; npm ci; npm run build
cd ../ego-windows-host; npm ci; npm run build

node bin/ego-windows-host.mjs -e "
const task = await taskSpaces.useOrCreate('demo')
await browser.openOrReuseTab('https://example.com', { wait: true })
console.log(await page.snapshot())
"
```

The first call launches the browser detached with a dedicated profile; it stays
running, so later calls (and later agent heredocs) reattach to the same tabs and
task spaces. Input forms: a script file (`ego-windows-host task.js`), inline
`-e <code>`, or stdin. A leading `nodejs` argument is accepted so agent
instructions written for `ego-browser nodejs` carry over.

`--doctor` reports the detected browser, endpoint state, and task spaces.

## Environment

| Variable | Purpose |
| ----------------------- | --------------------------------------------------------------------------- |
| `EGO_HOST_BROWSER_PATH` | Full path to `msedge.exe` / `chrome.exe` (default: auto-detect, Edge first) |
| `EGO_HOST_DEBUG_PORT` | CDP port for the hosted browser (default `9522`) |
| `EGO_HOST_STATE_DIR` | State root (default `%LOCALAPPDATA%\ego-windows-host`) |
| `EGO_HOST_HEADLESS` | `1` to launch the browser headless |

## How task spaces are emulated

A task space is a named, persisted set of tabs plus an ownership state
(`agent` / `agentDelegatedToUser` / `user`), exactly the surface the runtime's
`taskSpaces` helpers expect:

- `useOrCreate` / `switch` / `claim` select a space; a fresh space opens one
blank tab so the runtime always has a session target.
- `handOff` pauses agent commands: every CDP send and snapshot fails with the
stable `EGO_TASK_SPACE_USER_IN_CONTROL` code until `takeOver`, so the
runtime's hard-stop guidance and `waitForAgentControl` behave like they do
against the real app.
- `complete(..., { keep: true })` leaves the tabs open and hands the space to
the user; `{ keep: false }` closes the space's tabs and forgets it.
- Tabs created through raw CDP (`Target.createTarget`) are sniffed off the
passthrough channel and tracked into the current space, so bookkeeping stays
consistent however the runtime opens tabs.

State lives in `spaces.json` under the state dir, written atomically.

## Honest limitations vs the real app

- **Snapshot quality.** `ego.snapshot()` here is a plain projection of
Chromium's accessibility tree with `[@backendNodeId]` refs. It is good enough
for semantic locators and `@ref` actions on ordinary DOM pages, but it is not
the app's kernel-level snapshot (deeply nested iframes and canvas-heavy
surfaces will be weaker).
- **Profile.** The hosted browser uses its own persistent profile. Logins
accumulate there (log in once via `taskSpaces.handOff`), but it does not
import your daily browser's cookies the way ego lite's Chrome migration does.
This host never touches your daily browser profile.
- **No Spaces UI.** Ownership is enforced at the bridge, but there is no
browser chrome showing which space an agent holds.
- **Security.** The browser exposes CDP on a loopback port; any local process
can connect to it. Do not point `EGO_HOST_DEBUG_PORT` at a non-loopback
interface, and prefer a dedicated profile (the default) over a copy of a
profile holding sensitive sessions.

## Tests

```powershell
npm test # build + typecheck + node --test, no real browser required
```

Unit tests stub the browser side entirely. The real-browser path is exercised
manually (see the PR that introduced this package for a full transcript against
Edge on Windows 11).
9 changes: 9 additions & 0 deletions package/ego-windows-host/bin/ego-windows-host.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env node
import { main } from "../dist/src/cli.js";

try {
process.exitCode = await main();
} catch (error) {
console.error(error?.stack || error?.message || String(error));
process.exitCode = 1;
}
103 changes: 103 additions & 0 deletions package/ego-windows-host/package-lock.json

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

26 changes: 26 additions & 0 deletions package/ego-windows-host/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "ego-windows-host",
"version": "0.1.0",
"description": "Run the ego-browser agent runtime against stock Microsoft Edge or Google Chrome on Windows.",
"type": "module",
"bin": {
"ego-windows-host": "./bin/ego-windows-host.mjs"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json",
"test": "npm run build && npm run typecheck && node --test \"src/**/*.test.mjs\""
},
"engines": {
"node": ">=22"
},
"license": "MIT",
"dependencies": {
"ego-browser-v2": "file:../ego-browser"
},
"devDependencies": {
"@types/node": "^22.15.29",
"prettier": "^3.8.4",
"typescript": "^5.8.3"
}
}
101 changes: 101 additions & 0 deletions package/ego-windows-host/src/ax-snapshot.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import test from "node:test";
import assert from "node:assert/strict";

import { renderAxTree } from "../dist/src/ax-snapshot.js";

function node(nodeId, role, name, extra = {}) {
return {
nodeId,
role: { value: role },
name: { value: name },
...extra,
};
}

const PAGE = [
node("1", "RootWebArea", "Example Domain", {
backendDOMNodeId: 10,
childIds: ["2", "3", "6"],
}),
node("2", "heading", "Example Domain", {
backendDOMNodeId: 11,
childIds: ["4"],
}),
node("3", "link", "More information...", {
backendDOMNodeId: 12,
childIds: ["5"],
}),
node("4", "StaticText", "Example Domain", { childIds: [] }),
node("5", "StaticText", "More information...", { childIds: [] }),
node("6", "generic", "", { backendDOMNodeId: 13, childIds: ["7"] }),
node("7", "button", "Accept", { backendDOMNodeId: 14, childIds: [] }),
];

test("renders roles, names, and @backendNodeId marks", () => {
const { content, refs } = renderAxTree(PAGE);
assert.match(content, /RootWebArea "Example Domain"/);
assert.match(content, /heading "Example Domain" \[@11\]/);
assert.match(content, /link "More information\.\.\." \[@12\]/);
assert.match(content, /button "Accept" \[@14\]/);
assert.deepEqual(refs, [
{ backendNodeId: 11, role: "heading", name: "Example Domain" },
{ backendNodeId: 12, role: "link", name: "More information..." },
{ backendNodeId: 14, role: "button", name: "Accept" },
]);
});

test("skips generic wrappers but keeps their children", () => {
const { content } = renderAxTree(PAGE);
assert.doesNotMatch(content, /generic/);
assert.match(content, /button "Accept"/);
});

test("drops StaticText that repeats its ancestor's name", () => {
const { content } = renderAxTree(PAGE);
const textLines = content
.split("\n")
.filter((line) => line.includes("- text:"));
assert.equal(textLines.length, 0, "both texts repeat their parents' names");
});

test("keeps StaticText that adds information", () => {
const { content } = renderAxTree([
node("1", "RootWebArea", "Page", { backendDOMNodeId: 1, childIds: ["2"] }),
node("2", "paragraph", "", { backendDOMNodeId: 2, childIds: ["3"] }),
node("3", "StaticText", "This domain is for use in examples.", {
childIds: [],
}),
]);
assert.match(content, /- text: "This domain is for use in examples\."/);
});

test("promotes children of ignored nodes", () => {
const { content, refs } = renderAxTree([
node("1", "RootWebArea", "Page", { backendDOMNodeId: 1, childIds: ["2"] }),
{ nodeId: "2", ignored: true, childIds: ["3"] },
node("3", "button", "Buried", { backendDOMNodeId: 3, childIds: [] }),
]);
assert.match(content, /button "Buried" \[@3\]/);
assert.equal(refs.length, 1);
});

test("indentation follows rendered depth, not raw tree depth", () => {
const { content } = renderAxTree(PAGE);
const lines = content.split("\n");
assert.match(lines[0], /^- RootWebArea/);
assert.match(lines[1], /^ {2}- heading/);
const button = lines.find((line) => line.includes("button"));
assert.match(button, /^ {2}- button/, "generic wrapper adds no depth");
});

test("maxResultLength truncates the content but never the refs", () => {
const { content, refs } = renderAxTree(PAGE, { maxResultLength: 1 });
assert.equal(content.length, 1);
assert.equal(refs.length, 3);
});

test("an empty tree renders empty content", () => {
const { content, refs } = renderAxTree([]);
assert.equal(content, "");
assert.deepEqual(refs, []);
});
Loading
Loading