ArchiveBox/archivebox/tests/test_opencode_browser.py
Nick Sweeting a59742940b
Test immediate OpenCode events and real terminal ticket handshakes (#1871)
* Stop initializing Git repositories for OpenCode sessions

* Isolate optional AI failures behind a lazy plugin adapter

* Keep explicit plugin enable flags boolean after config resolution

* Use Django login redirect encoding at the optional agent boundary

* Cover cold-start failures and real browser storage isolation

* Verify persisted dismissal and native storage denial variants

* Keep headless browser interaction independent of display timing

* Install optional OpenCode clients through the plugin extra

* Authenticate and route optional OpenCode WebSockets with real browser coverage

* Apply control-plane policy and proxy authentication to agent sockets

* Require immediate delivery of the first real OpenCode event

* Exercise browser same-origin headers in event regression

* Exercise OpenCode terminal tickets in proxy and browser regressions

* Preserve primary terminal failure while verifying cleanup
2026-09-03 19:09:25 -07:00

372 lines
17 KiB
Python

"""Exercise the real agent wrapper with Chromium's native storage failures."""
import asyncio
import json
import os
import re
import subprocess
import pytest
import requests
from .conftest import get_free_port, run_archivebox_cmd, start_archivebox_server, stop_archivebox_process
from .test_opencode_agent import _set_archivebox_config
from .test_opencode_agent import installed_opencode as installed_opencode
from .test_opencode_agent import live_opencode as live_opencode
from .test_opencode_agent import opencode_archive_config as opencode_archive_config
from .test_server_security_browser import browser_runtime as browser_runtime
pytestmark = pytest.mark.django_db(transaction=True)
def test_agent_navigation_stays_inside_mount(agent_server, browser_runtime):
server_url, _, _ = agent_server
script = r"""
const assert = require('node:assert/strict');
const puppeteer = require('puppeteer');
const config = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));
(async () => {
const browser = await puppeteer.launch({executablePath: config.chrome, headless: true,
args: ['--no-sandbox', '--disable-frame-rate-limit']});
try {
const page = await browser.newPage();
await page.setViewport({width: 1440, height: 900});
await page.goto(config.url + '/admin/login/', {waitUntil: 'domcontentloaded'});
await page.locator('#login-form input[name="username"]').fill('agent-browser-test');
await page.locator('#login-form input[name="password"]').fill('test-password');
await Promise.all([page.waitForNavigation({waitUntil: 'domcontentloaded'}),
page.locator('#login-form input[type="submit"]').click()]);
assert.equal((await page.goto(config.url + '/admin/agent', {waitUntil: 'domcontentloaded'})).status(), 200);
await page.locator('#opencode-agent-welcome-dismiss').click();
const frame = await (await page.waitForSelector('iframe')).contentFrame();
await frame.waitForSelector('a[href*="/session"]');
const links = await frame.$$eval('a[href*="/session"]', nodes => nodes.map(node => node.getAttribute('href')));
assert.ok(links.length, 'OpenCode must expose session navigation');
for (const href of links) assert.ok(href.startsWith('/admin/agent/opencode/'), href);
await frame.locator('::-p-aria(New session[role="button"])').click();
await frame.waitForSelector('[contenteditable="true"]');
let navigation;
for (const link of await frame.$$('a[href*="/session"]')) {
if (await link.isVisible() && await link.evaluate(node => node.href) !== frame.url()) {
navigation = link;
break;
}
}
assert.ok(navigation, 'A visible link must navigate to another session route');
await navigation.click();
await frame.waitForSelector('[contenteditable="true"]');
assert.ok(new URL(frame.url()).pathname.startsWith('/admin/agent/opencode/'), frame.url());
const sessionUrl = frame.url();
assert.equal((await frame.goto(sessionUrl, {waitUntil: 'domcontentloaded'})).status(), 200);
await frame.waitForSelector('[contenteditable="true"]');
assert.ok(!(await frame.$eval('body', node => node.innerText)).includes('Something went wrong'));
// Exercise the actual public PTY API and native browser WebSocket. No
// intercepted traffic or replacement server: this runs a real shell.
const terminal = await frame.evaluate(async () => {
const base = location.origin + '/admin/agent/opencode';
const created = await fetch(base + '/pty', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({command: '/bin/sh', args: []}),
});
if (!created.ok) throw new Error('PTY create: ' + created.status);
const pty = await created.json();
try {
const authorized = await fetch(base + '/pty/' + pty.id + '/connect-token', {
method: 'POST', headers: {'x-opencode-ticket': '1'},
});
if (authorized.status !== 200) throw new Error('PTY ticket: ' + authorized.status);
const {ticket} = await authorized.json();
if (!ticket) throw new Error('PTY ticket missing');
return await new Promise((resolve, reject) => {
const url = new URL(base + '/pty/' + pty.id + '/connect');
url.searchParams.set('ticket', ticket);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(url);
const timer = setTimeout(() => { socket.close(); reject(new Error('PTY output timed out')); }, 10000);
let output = '';
socket.onopen = () => socket.send("printf 'ABX_%s\\n' TERMINAL_OK\n");
socket.onmessage = async event => {
output += typeof event.data === 'string' ? event.data : await event.data.text();
if (output.includes('ABX_TERMINAL_OK')) {
clearTimeout(timer); socket.close(); resolve(output);
}
};
socket.onerror = () => { clearTimeout(timer); reject(new Error('PTY WebSocket failed')); };
});
} finally { await fetch(base + '/pty/' + pty.id, {method: 'DELETE'}); }
});
assert.ok(terminal.includes('ABX_TERMINAL_OK'), terminal);
assert.equal((await page.goto(config.url + '/add/', {waitUntil: 'domcontentloaded'})).status(), 200);
console.log('AGENT_NAVIGATION_OK');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
"""
result = subprocess.run(
[str(browser_runtime["node_binary"]), "-e", script],
input=json.dumps({"chrome": str(browser_runtime["chrome_binary"]), "url": server_url}),
env={**os.environ, "NODE_PATH": browser_runtime["node_path"]},
text=True,
capture_output=True,
timeout=120,
)
assert result.returncode == 0, result.stderr or result.stdout
assert "AGENT_NAVIGATION_OK" in result.stdout
@pytest.fixture
def agent_server(installed_opencode, browser_runtime):
port = get_free_port()
url = f"http://localhost:{port}"
config = installed_opencode.config
_set_archivebox_config(
config.data_dir,
f"BASE_URL={url}",
"SERVER_SECURITY_MODE=safe-onedomain-nojsreplay",
)
user = run_archivebox_cmd(
[
"shell",
"-c",
"from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser(username='agent-browser-test', password='test-password'); User.objects.create_user(username='agent-regular-test', password='test-password', is_staff=True)",
],
cwd=config.data_dir,
env=config.env,
)
assert user.returncode == 0, user.stderr or user.stdout
process = start_archivebox_server(config.data_dir, port=port, env=config.env, log_name="agent-browser-server.log")
try:
yield url, config.data_dir, process
finally:
if process.poll() is None:
stop_archivebox_process(process)
def _login_cookie(server_url, username="agent-browser-test"):
session = requests.Session()
login_url = server_url + "/admin/login/"
page = session.get(login_url, timeout=10)
assert page.status_code == 200
token = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', page.text)
assert token is not None
response = session.post(
login_url,
data={"username": username, "password": "test-password", "csrfmiddlewaretoken": token[1]},
headers={"Referer": login_url},
allow_redirects=False,
timeout=10,
)
assert response.status_code == 302
return "; ".join(f"{key}={value}" for key, value in session.cookies.items())
def test_agent_websocket_rejects_unauthorized_access(agent_server):
from websockets.asyncio.client import connect
from websockets.exceptions import InvalidStatus
server_url, _, _ = agent_server
admin_cookie = _login_cookie(server_url)
regular_cookie = _login_cookie(server_url, "agent-regular-test")
async def check_denials():
for path, cookie, origin in (
("/admin/agent/opencode/pty/unknown/connect", "", server_url),
("/admin/agent/opencode/pty/unknown/connect", regular_cookie, server_url),
("/admin/agent/opencode/pty/unknown/connect", admin_cookie, "https://untrusted.example"),
("/admin/agent/opencode/pty/unknown/connect", admin_cookie, None),
("/health/", admin_cookie, server_url),
):
with pytest.raises(InvalidStatus) as error:
async with connect(
server_url.replace("http", "ws", 1) + path,
origin=origin,
additional_headers={"Cookie": cookie},
proxy=None,
):
pytest.fail("Unauthorized WebSocket was accepted")
assert error.value.response.status_code == 403
asyncio.run(check_denials())
assert requests.get(server_url + "/health/", timeout=10).status_code == 200
@pytest.mark.parametrize(
"mode,proxy_whitelist,use_cookie,http_status",
[
("unsafe-onedomain-noadmin", "", True, 403),
("safe-onedomain-nojsreplay", "127.0.0.1/32", False, 200),
("safe-onedomain-nojsreplay", "192.0.2.0/24", False, 302),
],
)
def test_agent_websocket_matches_http_security_policy(agent_server, live_opencode, mode, proxy_whitelist, use_cookie, http_status):
from urllib.parse import urlsplit
from websockets.asyncio.client import connect
from websockets.exceptions import InvalidStatus
server_url, data_dir, process = agent_server
cookie = _login_cookie(server_url)
created = requests.post(
server_url + "/admin/agent/opencode/pty",
headers={"Cookie": cookie, "Origin": server_url},
json={"command": "/bin/sh", "args": []},
timeout=10,
)
assert created.status_code == 200
pty_id = created.json()["id"]
stop_archivebox_process(process)
_set_archivebox_config(
data_dir,
f"SERVER_SECURITY_MODE={mode}",
f"REVERSE_PROXY_WHITELIST={proxy_whitelist}",
"REVERSE_PROXY_USER_HEADER=Remote-User",
)
restarted = start_archivebox_server(
data_dir,
port=urlsplit(server_url).port,
env=live_opencode.config.env,
log_name="agent-security-server.log",
)
headers = {"Cookie": cookie} if use_cookie else {"Remote-User": "agent-browser-test"}
try:
response = requests.get(server_url + "/admin/agent/opencode/global/health", headers=headers, allow_redirects=False, timeout=10)
assert response.status_code == http_status
async def check_socket():
connection = connect(
server_url.replace("http", "ws", 1) + f"/admin/agent/opencode/pty/{pty_id}/connect",
origin=server_url,
additional_headers=headers,
proxy=None,
)
if http_status != 200:
with pytest.raises(InvalidStatus) as error:
async with connection:
pytest.fail("Disabled control plane or untrusted proxy accepted a WebSocket")
assert error.value.response.status_code == 403
return
async with connection as socket, asyncio.timeout(10):
await socket.send("printf 'ABX_%s\\n' PROXY_OK\n")
output = ""
while "ABX_PROXY_OK" not in output:
chunk = await socket.recv()
output += chunk.decode() if isinstance(chunk, bytes) else chunk
assert "ABX_PROXY_OK" in output
asyncio.run(check_socket())
assert requests.get(server_url + "/health/", timeout=10).status_code == 200
finally:
deleted = requests.delete(live_opencode.settings["origin"] + f"/pty/{pty_id}", timeout=10)
assert deleted.status_code == 200
stop_archivebox_process(restarted)
def test_agent_preserves_projects_and_survives_storage_failure(agent_server, browser_runtime):
server_url, data_dir, _ = agent_server
script = r"""
const assert = require('node:assert/strict');
const puppeteer = require('puppeteer');
const config = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));
(async () => {
for (const disabled of [false, true]) {
const browser = await puppeteer.launch({
executablePath: config.chrome,
headless: true,
// Headless interaction must not depend on the desktop display clock.
args: ['--no-sandbox', '--disable-frame-rate-limit', ...(disabled ? ['--disable-local-storage'] : [])],
});
try {
const page = await browser.newPage();
await page.goto(new URL('/admin/login/', config.url).href, {waitUntil: 'domcontentloaded'});
await page.locator('#login-form input[name="username"]').fill('agent-browser-test');
await page.locator('#login-form input[name="password"]').fill('test-password');
assert.equal(await page.$eval('#login-form input[name="username"]', element => element.value), 'agent-browser-test');
assert.equal(await page.$eval('#login-form input[name="password"]', element => element.value), 'test-password');
await Promise.all([
page.waitForNavigation({waitUntil: 'domcontentloaded'}),
page.locator('#login-form input[type="submit"]').click(),
]);
assert.ok(!page.url().includes('/admin/login/'), page.url());
assert.equal((await page.goto(config.url, {waitUntil: 'domcontentloaded'})).status(), 200);
const welcome = '#opencode-agent-welcome';
await page.waitForSelector(welcome, {visible: true});
if (disabled) {
assert.equal(await page.evaluate(() => {
try { return localStorage === null; }
catch (error) { if (error.name !== 'SecurityError') throw error; return true; }
}), true);
} else {
await page.click('#opencode-agent-welcome-dismiss');
await page.waitForSelector(welcome, {hidden: true});
await page.reload({waitUntil: 'domcontentloaded'});
assert.equal(await page.$eval(welcome, element => element.hidden), true);
const existing = {
list: [{type: 'http', http: {url: 'http://other.example'}}],
projects: {
local: [{worktree: '/other-project', expanded: false}],
'http://other.example': [{worktree: '/remote-project', expanded: true}],
},
lastProject: {'http://other.example': '/remote-project'},
};
await page.evaluate(value => localStorage.setItem('opencode.global.dat:server', JSON.stringify(value)), existing);
await page.reload({waitUntil: 'domcontentloaded'});
await page.waitForFunction(workdir => {
const state = JSON.parse(localStorage.getItem('opencode.global.dat:server'));
return state.projects.local?.some(project => project.worktree === workdir);
}, {}, config.workdir);
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('opencode.global.dat:server')));
assert.deepEqual(saved.list, existing.list);
assert.deepEqual(saved.projects['http://other.example'], existing.projects['http://other.example']);
assert.equal(saved.lastProject['http://other.example'], '/remote-project');
const projects = saved.projects.local;
assert.ok(Array.isArray(projects), JSON.stringify({url: page.url(), saved, existing}));
assert.deepEqual(projects.find(project => project.worktree === '/other-project'), {worktree: '/other-project', expanded: false});
assert.equal(projects.filter(project => project.worktree === config.workdir).length, 1);
await page.reload({waitUntil: 'domcontentloaded'});
assert.equal(await page.evaluate(workdir => {
const state = JSON.parse(localStorage.getItem('opencode.global.dat:server'));
return state.projects.local.filter(project => project.worktree === workdir).length;
}, config.workdir), 1);
// Fill the actual browser quota; do not replace or intercept Storage methods.
const failure = await page.evaluate(() => {
localStorage.clear();
let low = 0, high = 8 * 1024 * 1024;
while (low < high) {
const size = Math.ceil((low + high) / 2);
try { localStorage.setItem('padding', 'x'.repeat(size)); low = size; }
catch (error) { if (error.name !== 'QuotaExceededError') throw error; high = size - 1; }
}
try { localStorage.setItem('extra', '1'); return null; }
catch (error) { return error.name; }
});
assert.equal(failure, 'QuotaExceededError');
await page.reload({waitUntil: 'domcontentloaded'});
await page.waitForSelector(welcome, {visible: true});
}
await page.click('#opencode-agent-welcome-dismiss');
await page.waitForSelector(welcome, {hidden: true});
assert.equal((await page.goto(new URL('/add/', config.url).href, {waitUntil: 'domcontentloaded'})).status(), 200);
} finally {
await browser.close();
}
}
console.log('AGENT_STORAGE_FAILURE_ISOLATED');
})().catch(error => { console.error(error); process.exitCode = 1; });
"""
result = subprocess.run(
[str(browser_runtime["node_binary"]), "-e", script],
input=json.dumps(
{
"chrome": str(browser_runtime["chrome_binary"]),
"url": f"{server_url}/admin/agent",
"workdir": str(data_dir.resolve()),
},
),
env={**os.environ, "NODE_PATH": browser_runtime["node_path"]},
text=True,
capture_output=True,
timeout=120,
)
assert result.returncode == 0, result.stderr or result.stdout
assert "AGENT_STORAGE_FAILURE_ISOLATED" in result.stdout