Skip to content

Commit cfdd2f8

Browse files
committed
feat: harden browser automation lifecycle
1 parent da8454c commit cfdd2f8

14 files changed

Lines changed: 938 additions & 129 deletions

docs/BROWSER-CUA-VERIFICATION.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Browser and CUA verification
2+
3+
Last verified: 2026-08-21 on Apple Silicon macOS.
4+
5+
## Implemented surface
6+
7+
- A Bun NDJSON sidecar owns the persistent Chrome-for-Testing profile, tabs,
8+
structured CDP actions, input, downloads policy, and screencast frames.
9+
- The Workspace Browser panel and GJC agent bridge share the same session tabs.
10+
- Browser HTTP automation is available at `/api/browser/:sessionId`; the first
11+
PoC `/api/automation/browser/:sessionId` routes remain as compatibility aliases.
12+
- `npm run browser:debug` is an HTTP-only client for the running server. It does
13+
not launch Chromium and accepts the desktop cookie through
14+
`GAJAE_BROWSER_DEBUG_COOKIE` when desktop authentication is enabled.
15+
- CUA Driver remains an external prerequisite. The status surface reports its
16+
version, daemon state, Accessibility permission, and Screen Recording
17+
permission. Agent native actions are limited to the reviewed allowlist.
18+
- Origin and application grants are session-scoped by default. Only an explicit
19+
“Always allow” persists; individual grants can be revoked in Settings.
20+
- Stop closes the browser/CUA session, aborts active work, and clears session
21+
grants. A crashed sidecar reaps its owned Chromium process, restarts, restores
22+
tab URLs and the active tab, then resumes screencasting.
23+
24+
The PoC intentionally excludes file upload, automatic download saving, browser
25+
extensions, existing Chrome profiles, force-quitting native apps, clipboard
26+
reading, file transfer, screen recording, and lock-screen control.
27+
28+
## Automated evidence
29+
30+
The following passed on 2026-08-21:
31+
32+
- `npm run verify`: dependency audit, TypeScript, Rust formatting/clippy/tests,
33+
all server and client tests, lint, identity checks, and production builds.
34+
- `npm run test:e2e:gjc`: seven driver/wire/browser integration scenarios.
35+
- `npm run test:e2e:browser`: real Chrome-for-Testing actions, popup and tab
36+
state, navigation, dialogs, screencast, and interruption of a non-settling
37+
page script.
38+
- Fake sidecar recovery integration: restart, orphan-browser reap, URL restore,
39+
active-tab restore, and screencast resubscription.
40+
- Fake CUA and HTTP route integration: computer call, persistent grant creation,
41+
individual revoke, and the public `/api/browser/:sessionId` routes.
42+
- `npm run smoke:packaged-server -- --tauri-app <app>` and strict `codesign`
43+
verification against the generated Tauri app bundle.
44+
45+
## Manual packaged-app evidence
46+
47+
The generated app at
48+
`src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Gajae Code App.app`
49+
passed these checks:
50+
51+
- Opened `http://100.78.133.28:8080/` in the Workspace Browser panel and showed
52+
the live todo-list Chromium frame.
53+
- Killed only the owned browser sidecar. The app reaped the orphaned Chromium,
54+
started a new sidecar and Chromium process, restored the same URL and tab, and
55+
resumed the live frame without restarting the desktop app.
56+
- Ran a Sol session using `computer.list_apps`; CUA Driver 0.21.0 returned that
57+
TextEdit was running and the app remained stable.
58+
- Requested a previously unapproved `https://example.org` origin, selected
59+
**Deny**, and observed a failed-closed tool result while the shared browser
60+
stayed on the existing todo page.
61+
- Started `browser.run` with a promise that never settles, pressed Stop, and
62+
observed cancellation and browser-session cleanup in 733 ms.
63+
64+
macOS Accessibility and Screen Recording were already granted for this test.
65+
They were inspected but not revoked because changing OS privacy permissions is
66+
outside normal app QA. Denied app-level origin access exercises the app's
67+
fail-closed approval path without modifying system security settings.
68+
69+
## Upstream SDK dependency
70+
71+
`@gajae-code/coding-agent@0.14.2` and its upstream `main` branch do not yet
72+
provide an external backend interface for the reserved `browser` and `computer`
73+
tools. The app therefore still supplies these two tools through the SDK's
74+
`customTools` option. The requested architecture—inject at built-in tool
75+
materialization time without a same-name custom-tool overwrite—requires an
76+
upstream release. It is tracked in
77+
[gajae-code issue #4809](https://github.com/Yeachan-Heo/gajae-code/issues/4809).
78+
79+
After upstream publishes that API, replace `customTools` in
80+
`server/gjc-bun-sdk-adapter.ts`, update `@gajae-code/coding-agent`, and rerun the
81+
same gates and packaged-app scenarios above. No direct `node_modules` patch is
82+
permitted.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"pretest": "npm run build:core:dev",
6666
"test:e2e:gjc": "TSX_TSCONFIG_PATH=server/tsconfig.json node --import tsx --test --test-concurrency=1 server/e2e/gjc-slice4.browser.e2e.ts server/e2e/gjc-slice4.wire.e2e.ts",
6767
"test:e2e:browser": "TSX_TSCONFIG_PATH=server/tsconfig.json node --import tsx --test --test-concurrency=1 server/e2e/browser-sidecar.e2e.ts",
68+
"browser:debug": "node scripts/browser-debug-client.mjs",
6869
"poc:opencodex:cua": "node scripts/check-opencodex-cua-poc.mjs",
6970
"lint": "eslint src/ server/ shared/ scripts/ vite.config.js tailwind.config.js",
7071
"lint:fix": "eslint src/ server/ shared/ scripts/ vite.config.js tailwind.config.js --fix",

scripts/browser-debug-client.mjs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env node
2+
3+
const HELP = `Usage:
4+
npm run browser:debug -- [options] status
5+
npm run browser:debug -- [options] open [url]
6+
npm run browser:debug -- [options] command '<json>'
7+
npm run browser:debug -- [options] input '<json>'
8+
npm run browser:debug -- [options] close
9+
10+
Options:
11+
--base-url <url> Gajae server URL (default: http://127.0.0.1:3001)
12+
--session <id> Browser session ID (default: browser-debug)
13+
--cookie <value> Desktop cookie value or full Cookie header
14+
--api-key <key> Self-hosted x-api-key value
15+
16+
Environment alternatives:
17+
GAJAE_BROWSER_DEBUG_BASE_URL, GAJAE_BROWSER_DEBUG_SESSION,
18+
GAJAE_BROWSER_DEBUG_COOKIE, API_KEY
19+
`;
20+
21+
function parseArguments(argv) {
22+
const options = {
23+
baseUrl: process.env.GAJAE_BROWSER_DEBUG_BASE_URL ?? 'http://127.0.0.1:3001',
24+
sessionId: process.env.GAJAE_BROWSER_DEBUG_SESSION ?? 'browser-debug',
25+
cookie: process.env.GAJAE_BROWSER_DEBUG_COOKIE,
26+
apiKey: process.env.API_KEY,
27+
};
28+
const positionals = [];
29+
for (let index = 0; index < argv.length; index += 1) {
30+
const value = argv[index];
31+
if (value === '--help' || value === '-h') return { ...options, command: 'help', values: [] };
32+
if (value === '--base-url' || value === '--session' || value === '--cookie' || value === '--api-key') {
33+
const next = argv[index + 1];
34+
if (!next) throw new Error(`${value} requires a value.`);
35+
if (value === '--base-url') options.baseUrl = next;
36+
if (value === '--session') options.sessionId = next;
37+
if (value === '--cookie') options.cookie = next;
38+
if (value === '--api-key') options.apiKey = next;
39+
index += 1;
40+
continue;
41+
}
42+
positionals.push(value);
43+
}
44+
return { ...options, command: positionals[0] ?? 'help', values: positionals.slice(1) };
45+
}
46+
47+
function requestFor({ command, values, sessionId }) {
48+
const session = encodeURIComponent(sessionId);
49+
switch (command) {
50+
case 'status':
51+
return { path: '/api/automation/status', init: {} };
52+
case 'open':
53+
return {
54+
path: `/api/browser/${session}/open`,
55+
init: { method: 'POST', body: JSON.stringify({ ...(values[0] ? { url: values[0] } : {}) }) },
56+
};
57+
case 'command':
58+
return {
59+
path: `/api/browser/${session}/command`,
60+
init: { method: 'POST', body: JSON.stringify({ command: parseJson(values[0], 'command') }) },
61+
};
62+
case 'input':
63+
return {
64+
path: `/api/browser/${session}/input`,
65+
init: { method: 'POST', body: JSON.stringify({ input: parseJson(values[0], 'input') }) },
66+
};
67+
case 'close':
68+
return { path: `/api/browser/${session}`, init: { method: 'DELETE' } };
69+
default:
70+
throw new Error(`Unknown command: ${command}`);
71+
}
72+
}
73+
74+
function parseJson(value, label) {
75+
if (!value) throw new Error(`${label} requires one JSON object argument.`);
76+
const parsed = JSON.parse(value);
77+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
78+
throw new Error(`${label} must be a JSON object.`);
79+
}
80+
return parsed;
81+
}
82+
83+
function cookieHeader(value) {
84+
if (!value) return undefined;
85+
return value.includes('=') ? value : `gajae_desktop_api_key=${value}`;
86+
}
87+
88+
export async function runBrowserDebugClient(argv, output = console.log) {
89+
const options = parseArguments(argv);
90+
if (options.command === 'help') {
91+
output(HELP.trimEnd());
92+
return;
93+
}
94+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(options.sessionId)) {
95+
throw new Error('Session ID contains unsupported characters.');
96+
}
97+
const { path, init } = requestFor(options);
98+
const headers = { accept: 'application/json' };
99+
if (init.body) headers['content-type'] = 'application/json';
100+
if (options.apiKey) headers['x-api-key'] = options.apiKey;
101+
const cookie = cookieHeader(options.cookie);
102+
if (cookie) headers.cookie = cookie;
103+
const response = await fetch(new URL(path, options.baseUrl), { ...init, headers });
104+
const text = await response.text();
105+
let result;
106+
try {
107+
result = text ? JSON.parse(text) : null;
108+
} catch {
109+
result = { response: text };
110+
}
111+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${JSON.stringify(result)}`);
112+
output(JSON.stringify(result, null, 2));
113+
}
114+
115+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
116+
runBrowserDebugClient(process.argv.slice(2)).catch((error) => {
117+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
118+
process.exitCode = 1;
119+
});
120+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import assert from 'node:assert/strict';
2+
import { once } from 'node:events';
3+
import { createServer } from 'node:http';
4+
import test from 'node:test';
5+
6+
import { runBrowserDebugClient } from './browser-debug-client.mjs';
7+
8+
test('browser debug client calls the long-running server instead of launching Chromium', async () => {
9+
const requests = [];
10+
const server = createServer((request, response) => {
11+
let body = '';
12+
request.on('data', (chunk) => { body += chunk; });
13+
request.on('end', () => {
14+
requests.push({ method: request.method, url: request.url, headers: request.headers, body });
15+
response.setHeader('content-type', 'application/json');
16+
response.end(JSON.stringify({ accepted: true }));
17+
});
18+
});
19+
server.listen(0, '127.0.0.1');
20+
await once(server, 'listening');
21+
const address = server.address();
22+
if (!address || typeof address === 'string') throw new Error('Debug client test server did not bind.');
23+
const output = [];
24+
try {
25+
await runBrowserDebugClient([
26+
'--base-url', `http://127.0.0.1:${address.port}`,
27+
'--session', 'debug-1',
28+
'--cookie', 'secret',
29+
'command', '{"action":"observe"}',
30+
], (value) => output.push(value));
31+
} finally {
32+
server.close();
33+
await once(server, 'close');
34+
}
35+
assert.equal(requests.length, 1);
36+
assert.equal(requests[0].method, 'POST');
37+
assert.equal(requests[0].url, '/api/browser/debug-1/command');
38+
assert.equal(requests[0].headers.cookie, 'gajae_desktop_api_key=secret');
39+
assert.deepEqual(JSON.parse(requests[0].body), { command: { action: 'observe' } });
40+
assert.deepEqual(output.map(JSON.parse), [{ accepted: true }]);
41+
});

server/e2e/browser-sidecar.e2e.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,5 +194,19 @@ test('real Chromium sidecar shares structured actions, tabs, and screencast stat
194194
const state = await sidecar.request('session.state', 'browser-e2e') as { activeTabId: string | null; tabs: unknown[] };
195195
assert.equal(state.tabs.length, 1);
196196
assert.ok(state.activeTabId);
197-
assert.deepEqual(await sidecar.request('session.close', 'browser-e2e'), { closed: true });
197+
198+
const interrupted = assert.rejects(
199+
sidecar.request('browser.command', 'browser-e2e', {
200+
command: { action: 'run', code: 'new Promise(() => {})', timeoutMs: 30_000 },
201+
}),
202+
/closed|destroyed|Target|session|Protocol/iu,
203+
);
204+
await new Promise((resolve) => setTimeout(resolve, 100));
205+
const closeStarted = Date.now();
206+
assert.deepEqual(await Promise.race([
207+
sidecar.request('session.close', 'browser-e2e'),
208+
new Promise((_, reject) => setTimeout(() => reject(new Error('session.close was blocked behind browser.run')), 2_000)),
209+
]), { closed: true });
210+
assert.ok(Date.now() - closeStarted < 2_000, 'session.close must interrupt a long browser.run');
211+
await interrupted;
198212
});

server/gjc-automation-tools.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,53 @@ test('agent browser asks for origin access before opening and records allow once
8181
}
8282
});
8383

84+
test('agent browser denial fails closed without opening the requested origin', async () => {
85+
const directory = await mkdtemp(join(tmpdir(), 'gajae-automation-deny-'));
86+
const socketPath = join(directory, 'bridge.sock');
87+
const requests: Array<Record<string, unknown>> = [];
88+
const server = net.createServer((socket) => {
89+
let buffer = '';
90+
socket.setEncoding('utf8');
91+
socket.on('data', (chunk) => {
92+
buffer += chunk;
93+
const newline = buffer.indexOf('\n');
94+
if (newline < 0) return;
95+
const request = JSON.parse(buffer.slice(0, newline)) as Record<string, unknown>;
96+
requests.push(request);
97+
socket.end(`${JSON.stringify({
98+
id: request.id,
99+
ok: true,
100+
result: { granted: false, origin: 'https://denied.example' },
101+
})}\n`);
102+
});
103+
});
104+
await new Promise<void>((resolve, reject) => {
105+
server.once('error', reject);
106+
server.listen(socketPath, () => resolve());
107+
});
108+
109+
try {
110+
const [browser] = createGjcAutomationTools('app-session', {
111+
async select() { return 'Deny'; },
112+
}, { socketPath, token: TEST_TOKEN });
113+
assert.ok(browser);
114+
await assert.rejects(
115+
browser.execute(
116+
'tool-call-denied',
117+
{ action: 'open', url: 'https://denied.example/private' },
118+
undefined,
119+
{} as never,
120+
undefined,
121+
),
122+
/access .* was denied/iu,
123+
);
124+
assert.deepEqual(requests.map((request) => request.operation), ['authorize']);
125+
} finally {
126+
await new Promise<void>((resolve) => server.close(() => resolve()));
127+
await rm(directory, { recursive: true, force: true });
128+
}
129+
});
130+
84131
test('agent computer asks for application access before controlling it', async () => {
85132
const directory = await mkdtemp(join(tmpdir(), 'gajae-computer-tools-'));
86133
const socketPath = join(directory, 'bridge.sock');

server/index.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ import providerRoutes from './modules/providers/provider.routes.js';
5353
import voiceRoutes from './voice-proxy.js';
5454
import { assetsRoutes } from './modules/assets/index.js';
5555
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
56-
import { automationRoutes, automationService, handleBrowserConnection } from './modules/automation/index.js';
56+
import {
57+
automationRoutes,
58+
automationService,
59+
createBrowserAutomationRouter,
60+
handleBrowserConnection,
61+
} from './modules/automation/index.js';
5762
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
5863
import { c } from './utils/colors.js';
5964
import { evaluateExposure } from './utils/exposure-guard.js';
@@ -194,6 +199,7 @@ app.use('/api/providers', authenticateToken, providerRoutes);
194199
// Chromium/CDP and CUA Driver automation. The app factory's desktop guard and
195200
// the normal owner authentication both run before these routes.
196201
app.use('/api/automation', authenticateToken, automationRoutes);
202+
app.use('/api/browser', authenticateToken, createBrowserAutomationRouter());
197203

198204
// Agent API Routes (uses API key authentication)
199205

0 commit comments

Comments
 (0)