Skip to content
Open
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
1 change: 1 addition & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ The system uses Tailwind's 4px spacing scale. Existing values like `p-2`, `gap-2
### Chat Pane

- **Structure**: `ChatMessagesPane` owns scroll; `ChatComposer` is fixed at the bottom of the chat column.
- **Session tasks**: a collapsible, read-only task card sits above the transcript whenever the current session has a plan. Its progress stays visible when collapsed; the expanded list has a bounded, keyboard-focusable scroll area so long plans do not push the composer off screen. Tasks no longer occupy a workspace-panel tab.
- **Performance**: message rows use `contain`, `content-visibility: auto`, and intrinsic sizes to reduce long-transcript layout cost.
- **States**: loading, empty provider selection, older-message loaders, load-all overlay, grouped tool messages, new-message scroll button.
- **Layout**: message and composer width align at `max-w-[54.25rem]`.
Expand Down
26 changes: 25 additions & 1 deletion docs/BROWSER-CUA-VERIFICATION.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
# Browser and CUA verification

Last verified: 2026-08-21 on Apple Silicon macOS.
Packaged-app baseline: 2026-08-21 on Apple Silicon macOS. Unreleased source
checks are recorded separately below.

## Unreleased runtime fixes — 2026-09-07

- `browser.run` now evaluates page JavaScript with top-level `await` support.
The final expression is returned; ordinary Promise results are awaited too.
Code is evaluated only once, including when it throws a runtime SyntaxError.
Page-context execution does not expose Node.js or Puppeteer variables.
- The existing 64 KiB code, 256 KiB result and time limits remain. Temporary
remote objects are released, closing a session still interrupts pending
scripts, and the evaluator explicitly does not bypass page CSP.
- The adapter passes the run's validated project permission mode into the
browser/computer wrappers. `bypass` skips their additional origin/application
access questions after target validation, without creating session or
persistent grants. Later Ask runs therefore do not inherit bypass access.
- Ask/auto-edits prompts, Chromium's first-download consent, actual `ask`
questions, operating-system permissions and driver restrictions remain.
Cancelled tool calls fail before opening a bridge connection.
- Regression coverage includes the production adapter policy handoff, later
Ask runs, model-supplied policy spoofing, real Chromium top-level await,
Promise results, one-shot errors, CSP enforcement, size limits and interruption.

These are source/runtime checks, not new packaged-app acceptance. The installed
and published beta.10 app is unchanged until a new versioned build is released.

## Implemented surface

Expand Down
17 changes: 17 additions & 0 deletions docs/V2-SESSION-HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ not changed. The older session records below are historical.

## TL;DR

- **Unreleased follow-up: tasks above the conversation.** `ChatTasksPanel` now
shows the session's live todo list above the transcript with collapse,
progress and bounded scrolling. The right-hand Tasks tab is retired; its
persisted open state closes on upgrade. The todo projection hook now lives
under `src/components/chat/hooks/`. This source change is **not included in
the published/installed beta.10** and requires a new build/release to reach
that desktop installation.
- The same unreleased UI follow-up also hides routine `Auto-approved …`
information notices in the chat projection. Raw records, permission policy,
approval controls, warnings and errors are unchanged.
- Unreleased browser/runtime fixes additionally support top-level `await` in
page scripts and pass project bypass mode to browser/computer access checks.
Bypass does not save grants or answer real questions; Ask/auto-edits,
first-download consent and OS restrictions remain. See
`BROWSER-CUA-VERIFICATION.md` for the scope and regression evidence. These
fixes likewise require a new desktop build; beta.10 has not been overwritten.

- **The v2 baseline is complete.** Server/backend/web MVP (Slices 0–4 + 6), the Tauri
desktop shell (Slice 5 C1–C6), **and the C7 interactive GUI smoke** are all
done and verified. Electron is removed (C9/wave1); the C8 rollback drill is
Expand Down
13 changes: 12 additions & 1 deletion server/GJC-LIVE-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,10 @@ method or frame changes; the policy travels inside existing payloads:
- A call the policy covers (`bypass`, a tool on `allowAlways`, or a file
mutation under `auto_edits`) is approved inside the worker and recorded once
per tool per run as a `system_notice` ("Auto-approved bash (always allow)").
Nothing crosses to the host, so the run is never reported as awaiting input.
The browser omits these routine info lines when projecting chat rows; raw
records and permission handling are unchanged. Other info notices, warnings,
errors, and actual approval requests remain visible.
No permission request crosses to the host, so the run is never reported as awaiting input.
- Any other gated call is an `ask.presented` event whose message is a
`permission_request` with `requestId` prefixed `sdk-permission:`, the
runtime's `toolName`, its `rawInput` as `input`, and a `context` naming the
Expand All @@ -216,6 +219,14 @@ method or frame changes; the policy travels inside existing payloads:
persists it to the project's allow-list before forwarding the reply.
- `ask` questions keep their `sdk-ask:` prefix and answer semantics.

The app-owned browser and computer tool wrappers receive the same validated
run permission mode as the SDK gate. In `bypass`, target/origin resolution still
runs, but the extra access question is omitted without adding grants to either
allow-list. Ask and auto-edits retain their existing access prompts. This does
not auto-answer `ask` questions, approve Chromium installation, or override OS
permissions or CUA driver restrictions. The mode is captured for the run; no
implicit grant survives into a later Ask run.

## Process and terminal lifecycle

- On POSIX (Linux and macOS), the application starts the Rust core as a detached
Expand Down
33 changes: 33 additions & 0 deletions server/e2e/browser-sidecar.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ test('real Chromium sidecar shares structured actions, tabs, and screencast stat
return;
}
response.setHeader('content-type', 'text/html; charset=utf-8');
if (request.url === '/csp') {
response.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; object-src 'none'");
response.end('<!doctype html><title>CSP fixture</title><p>restricted evaluation</p>');
return;
}
if (request.url === '/popup') {
response.end('<!doctype html><title>Popup fixture</title><p>popup</p>');
return;
Expand Down Expand Up @@ -238,6 +243,23 @@ test('real Chromium sidecar shares structured actions, tabs, and screencast stat
command: { action: 'run', code: '({ width: window.innerWidth, height: window.innerHeight })' },
}) as { value: { width: number; height: number } };
assert.deepEqual(resized.value, { width: 517, height: 742 });
const awaited = await sidecar.request('browser.command', 'browser-e2e', {
command: { action: 'run', code: 'await Promise.resolve({ title: document.title, ready: true })' },
}) as { value: { title: string; ready: boolean } };
assert.deepEqual(awaited.value, { title: 'Gajae browser fixture', ready: true });
const runScript = (code: string) => sidecar.request('browser.command', 'browser-e2e', {
command: { action: 'run', code },
});
assert.deepEqual(await runScript('Promise.resolve(42)'), { value: 42 });
assert.deepEqual(await runScript('const value = await Promise.resolve(21); value * 2'), { value: 42 });
assert.deepEqual(await runScript('const value = await Promise.resolve(7); value * 2'), { value: 14 });
await assert.rejects(runScript('await Promise.reject(new Error("async fixture failure"))'), /async fixture failure/);
await assert.rejects(runScript('globalThis.failedRunCount = (globalThis.failedRunCount || 0) + 1; throw new SyntaxError("runtime fixture failure")'), /runtime fixture failure/);
assert.deepEqual(await runScript('globalThis.failedRunCount'), { value: 1 }, 'runtime errors must never replay code');
await assert.rejects(runScript(`globalThis.oversizedRan = true; /*${'x'.repeat(64 * 1024)}*/`), /script is too large/i);
assert.deepEqual(await runScript('typeof globalThis.oversizedRan'), { value: 'undefined' });
const large = await runScript('"x".repeat(300000)') as { value: string };
assert.equal(large.value.length, 256 * 1024 + 1);
const resizedFrame = await sidecar.waitForEvent('frame', 5_000, resizedFrameStart);
assert.equal(resizedFrame.kind === 'event' && resizedFrame.payload.metadata && typeof resizedFrame.payload.metadata === 'object'
? (resizedFrame.payload.metadata as { deviceWidth?: number }).deviceWidth
Expand Down Expand Up @@ -341,6 +363,17 @@ test('real Chromium sidecar shares structured actions, tabs, and screencast stat
command: { action: 'extract', selector: '#result', format: 'text' },
}), { value: 'background preserved' }, 'closing one session preserves the other session');
await assert.rejects(sidecar.request('session.state', 'browser-e2e'), /Open the browser session first/u);
await sidecar.request('session.open', 'csp-e2e', { url: `${url}/csp`, allowDownload: false });
assert.deepEqual(await sidecar.request('browser.command', 'csp-e2e', {
command: { action: 'run', code: 'await Promise.resolve(document.title)' },
}), { value: 'CSP fixture' });
await assert.rejects(sidecar.request('browser.command', 'csp-e2e', {
command: { action: 'run', code: 'eval("1 + 1")' },
}), /unsafe-eval|Content Security Policy|Refused/i);
await assert.rejects(sidecar.request('browser.command', 'csp-e2e', {
command: { action: 'run', code: 'await new Promise(() => {})', timeoutMs: 50 },
}), /timed out/);
assert.deepEqual(await sidecar.request('session.close', 'csp-e2e'), { closed: true });
assert.deepEqual(await sidecar.request('session.close', 'browser-e2e'), { closed: false });
const reopened = await sidecar.request('session.open', 'browser-e2e', { url, allowDownload: false }) as { activeTabId: string; tabs: unknown[] };
assert.equal(reopened.tabs.length, 1);
Expand Down
111 changes: 111 additions & 0 deletions server/gjc-automation-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,114 @@ test('an unrelated open failure is not turned into a download prompt', async ()
await bridge.close();
}
});

test('bypass authorizes browser access for this session without an extra permission question', async () => {
const bridge = await bridgeServer((request) => request.operation === 'authorize'
? { ok: true, result: { granted: (request.payload as Record<string, unknown>)?.scope === 'session', origin: 'https://example.com' } }
: { ok: true, result: { opened: true } });
let prompts = 0;
try {
const { browser } = createGjcAutomationTools('bypass-session', {
async select() { prompts += 1; return 'Deny'; },
}, { socketPath: bridge.socketPath, token: TEST_TOKEN }, 'bypass');
await browser!.execute('bypass-open', { action: 'open', url: 'https://example.com/page' }, undefined);
assert.equal(prompts, 0);
assert.deepEqual(bridge.requests.map((request) => request.operation), ['authorize', 'open']);
assert.ok(bridge.requests.every((request) => request.sessionId === 'bypass-session'));
assert.ok(bridge.requests.every((request) => !(request.payload as Record<string, unknown> | undefined)?.scope));
} finally { await bridge.close(); }
});

test('bypass covers computer access without creating session or persistent grants', async () => {
const bridge = await bridgeServer((request) => request.operation === 'authorize'
? { ok: true, result: { granted: false, application: 'com.apple.TextEdit', label: 'TextEdit' } }
: { ok: true, result: { controlled: true } });
try {
const { computer } = createGjcAutomationTools('bypass-computer', {
async select() { assert.fail('bypass must not ask again'); },
}, { socketPath: bridge.socketPath, token: TEST_TOKEN }, 'bypass');
await computer!.execute('click', { action: 'click', arguments: { pid: 42, x: 10, y: 20 } }, undefined);
assert.deepEqual(bridge.requests.map((request) => request.operation), ['authorize', undefined]);
assert.ok(bridge.requests.every((request) => request.sessionId === 'bypass-computer'));
assert.ok(bridge.requests.every((request) => !(request.payload as Record<string, unknown> | undefined)?.scope));
} finally { await bridge.close(); }
});

test('bypass leaves later Ask runs and other sessions ungranted', async () => {
let granted = false;
const bridge = await bridgeServer((request) => {
if ((request.payload as Record<string, unknown> | undefined)?.scope) granted = true;
return { ok: true, result: request.operation === 'authorize' ? { granted, origin: 'https://example.com' } : { opened: true } };
});
let prompts = 0;
const ui = { async select() { prompts += 1; return 'Deny'; } };
try {
const transport = { socketPath: bridge.socketPath, token: TEST_TOKEN };
const bypass = createGjcAutomationTools('same-session', ui, transport, 'bypass');
await bypass.browser!.execute('first', { action: 'open', url: 'https://example.com' }, undefined);
await bypass.browser!.execute('again', { action: 'act', actions: [{ verb: 'observe' }] }, undefined);
assert.equal(prompts, 0);
assert.equal(granted, false);
for (const sessionId of ['same-session', 'other-session']) {
const ask = createGjcAutomationTools(sessionId, ui, transport, 'ask');
await assert.rejects(ask.browser!.execute('ask', { action: 'open', url: 'https://example.com' }, undefined), /was denied/);
}
assert.equal(prompts, 2);
assert.equal(granted, false);
} finally { await bridge.close(); }
});

test('default and auto-edits modes still ask, and tool parameters cannot enable bypass', async () => {
for (const mode of [undefined, 'ask', 'auto_edits'] as const) {
const bridge = await bridgeServer(() => ({ ok: true, result: { granted: false, origin: 'https://example.com' } }));
let prompts = 0;
try {
const { browser } = createGjcAutomationTools('ask-session', {
async select() { prompts += 1; return 'Deny'; },
}, { socketPath: bridge.socketPath, token: TEST_TOKEN }, mode);
await assert.rejects(browser!.execute('untrusted-params', {
action: 'open', url: 'https://example.com', permissionMode: 'bypass', permissions: { mode: 'bypass' },
}, undefined), /was denied/);
assert.equal(prompts, 1);
assert.deepEqual(bridge.requests.map((request) => request.operation), ['authorize']);
} finally { await bridge.close(); }
}
});

test('bypass preserves first-download consent and backend authorization failures', async () => {
const bridge = await bridgeServer((request) => request.operation === 'authorize'
? { ok: true, result: { granted: false, origin: 'https://example.com' } }
: { ok: false, error: 'browser_download_required: Chromium is not installed.' });
const prompts: string[] = [];
try {
const { browser } = createGjcAutomationTools('download-session', {
async select(title) { prompts.push(title); return 'Not now'; },
}, { socketPath: bridge.socketPath, token: TEST_TOKEN }, 'bypass');
await assert.rejects(browser!.execute('download', { action: 'open', url: 'https://example.com' }, undefined), /declined the download/);
assert.equal(prompts.length, 1);
assert.match(prompts[0]!, /needs Chromium/);
assert.ok(bridge.requests.every((request) => (request.payload as Record<string, unknown> | undefined)?.allowDownload !== true));
} finally { await bridge.close(); }

const rejected = await bridgeServer(() => ({ ok: false, error: 'Computer action requires a resolvable application identity.' }));
try {
const { computer } = createGjcAutomationTools('invalid-target', {
async select() { assert.fail('must preserve the backend rejection'); },
}, { socketPath: rejected.socketPath, token: TEST_TOKEN }, 'bypass');
await assert.rejects(computer!.execute('bad-target', { action: 'click', arguments: { pid: 42 } }, undefined), /resolvable application/);
assert.equal(rejected.requests.length, 1);
} finally { await rejected.close(); }
});

test('already-cancelled bypass tools do not connect or authorize any action', async () => {
const bridge = await bridgeServer(() => ({ ok: true, result: { granted: true } }));
try {
const tools = createGjcAutomationTools('cancelled-session', {
async select() { assert.fail('cancelled calls must not ask'); },
}, { socketPath: bridge.socketPath, token: TEST_TOKEN }, 'bypass');
const signal = AbortSignal.abort();
await assert.rejects(tools.browser!.execute('cancelled-browser', { action: 'open', url: 'https://example.com' }, signal), /cancelled/);
await assert.rejects(tools.computer!.execute('cancelled-computer', { action: 'click', arguments: { pid: 42 } }, signal), /cancelled/);
assert.deepEqual(bridge.requests, []);
} finally { await bridge.close(); }
});
11 changes: 10 additions & 1 deletion server/gjc-automation-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { AutomationTools } from '@gajae-code/coding-agent/sdk/session';
import type { ExtensionUIContext } from '@gajae-code/coding-agent/extensibility/extensions/types';
import * as z from 'zod/v4';

import type { GjcPermissionMode } from './gjc-permission-policy.js';

const browserActionSchema = z.object({
verb: z.enum(['navigate', 'back', 'forward', 'reload', 'click', 'type', 'fill', 'select', 'press', 'scroll', 'wait', 'observe', 'extract', 'screenshot']),
ref: z.number().int().positive().optional(),
Expand All @@ -28,7 +30,7 @@ const browserSchema = z.object({
action: z.enum(['open', 'close', 'act', 'run']),
url: z.string().optional(),
actions: z.array(browserActionSchema).max(25).optional(),
code: z.string().max(64 * 1024).optional(),
code: z.string().max(64 * 1024).describe('JavaScript in the current page context, not Node.js or Puppeteer. Top-level await is supported; the last expression is returned.').optional(),
timeout: z.number().int().min(1).max(300_000).optional(),
});

Expand Down Expand Up @@ -78,6 +80,7 @@ function bridgeRequest(
signal?: AbortSignal,
timeoutMs = 310_000,
): Promise<unknown> {
if (signal?.aborted) return Promise.reject(new Error('Automation request was cancelled.'));
if (!transport) return Promise.reject(new Error('App automation bridge is unavailable.'));
const id = `tool-${randomUUID()}`;
return new Promise((resolve, reject) => {
Expand All @@ -96,6 +99,7 @@ function bridgeRequest(
signal?.addEventListener('abort', abort, { once: true });
socket.setTimeout(timeoutMs, () => finish(new Error('Automation request timed out.')));
socket.on('connect', () => {
if (settled) return;
socket.write(`${JSON.stringify({ ...request, id, token: transport.token })}\n`);
});
socket.on('data', (chunk) => {
Expand Down Expand Up @@ -214,6 +218,7 @@ export function createGjcAutomationTools(
appSessionId: string,
ui: Pick<ExtensionUIContext, 'select'>,
transport?: GjcAutomationBridgeTransport,
permissionMode: GjcPermissionMode = 'ask',
): AutomationTools {
const ensureBrowserAccess = async (url: string | undefined, signal?: AbortSignal): Promise<void> => {
const check = await bridgeRequest(transport, {
Expand All @@ -224,6 +229,9 @@ export function createGjcAutomationTools(
}, signal) as BrowserAuthorization;
if (check.granted || !check.origin) return;

// The trusted run policy covers this prompt, but must not create grants
// that survive a later run switching back to Ask (even in this session).
if (permissionMode === 'bypass') return;
const choice = await ui.select(
`Allow the agent to use ${check.origin}?`,
[ALLOW_ONCE, ALLOW_ALWAYS, DENY],
Expand Down Expand Up @@ -317,6 +325,7 @@ export function createGjcAutomationTools(
}, signal) as ComputerAuthorization;
if (check.granted || !check.application) return;

if (permissionMode === 'bypass') return;
const choice = await ui.select(
`Allow the agent to control ${check.label ?? check.application}?`,
[ALLOW_ONCE, ALLOW_ALWAYS, DENY],
Expand Down
1 change: 1 addition & 0 deletions server/gjc-bun-sdk-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime {
config.appSessionId,
askController.uiContext,
this.options.automationBridge,
config.permissions?.mode,
)),
} : {}),
};
Expand Down
Loading