Compare commits

..

1 Commits

Author SHA1 Message Date
shivammittal274
2f6eabcf0e feat: add OpenClaw PoC test scripts (#623)
Two scripts validating OpenClaw integration for BrowserOS:
- test-openclaw-multi.ts: multi-agent, multi-session isolation via gateway call
- test-openclaw-ws-stream.ts: WebSocket streaming with Ed25519 device auth
2026-03-31 23:38:39 +05:30
49 changed files with 738 additions and 1536 deletions

2
.gitattributes vendored
View File

@@ -9,6 +9,4 @@ packages/browseros/chromium_patches/**/*.py linguist-generated
scripts/*.py linguist-generated
# Mark build directories as generated
build/* linguist-generated
# Mark eval/test framework as vendored so it's excluded from language stats
packages/browseros-agent/apps/eval/** linguist-vendored
docs/videos/** filter=lfs diff=lfs merge=lfs -text

View File

@@ -1,147 +0,0 @@
name: Release BrowserOS Server
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.0.80)"
required: true
type: string
concurrency:
group: release-server
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: release-core
permissions:
contents: write
defaults:
run:
working-directory: packages/browseros-agent
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.6"
- name: Install dependencies
run: bun ci
- name: Prepare production env file
run: cp apps/server/.env.production.example apps/server/.env.production
- name: Validate version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version }}
run: |
PACKAGE_VERSION=$(node -p "require('./apps/server/package.json').version")
echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
if [ "$PACKAGE_VERSION" != "$REQUESTED_VERSION" ]; then
echo "Requested version $REQUESTED_VERSION does not match apps/server/package.json ($PACKAGE_VERSION)"
exit 1
fi
- name: Build release artifacts
run: bun run build:server:ci
- name: Verify release artifacts
run: |
mapfile -t ZIP_FILES < <(find dist/prod/server -maxdepth 1 -type f -name 'browseros-server-resources-*.zip' | sort)
if [ "${#ZIP_FILES[@]}" -eq 0 ]; then
echo "No server release zip files were produced"
exit 1
fi
printf 'Found release artifacts:\n%s\n' "${ZIP_FILES[@]}"
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PACKAGE_VERSION: ${{ steps.version.outputs.package_version }}
run: |
SERVER_APP_PATH="packages/browseros-agent/apps/server"
SERVER_BUILD_DIR="packages/browseros-agent/scripts/build/server"
SERVER_BUILD_ENTRY="packages/browseros-agent/scripts/build/server.ts"
SERVER_RESOURCE_MANIFEST="packages/browseros-agent/scripts/build/config/server-prod-resources.json"
SERVER_WORKSPACE_PKG="packages/browseros-agent/package.json"
CURRENT_TAG="browseros-server-v$PACKAGE_VERSION"
PREV_TAG=$(git tag -l "browseros-server-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "Initial release of browseros-server." > /tmp/release-notes.md
else
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- \
"$SERVER_APP_PATH" \
"$SERVER_BUILD_DIR" \
"$SERVER_BUILD_ENTRY" \
"$SERVER_RESOURCE_MANIFEST" \
"$SERVER_WORKSPACE_PKG")
if [ -z "$COMMITS" ]; then
echo "No notable changes." > /tmp/release-notes.md
else
echo "## What's Changed" > /tmp/release-notes.md
echo "" >> /tmp/release-notes.md
while IFS= read -r SHA; do
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
echo "- ${SUBJECT} (#${PR_NUM})" >> /tmp/release-notes.md
else
echo "- ${SUBJECT}" >> /tmp/release-notes.md
fi
done <<< "$COMMITS"
fi
fi
working-directory: ${{ github.workspace }}
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PACKAGE_VERSION: ${{ steps.version.outputs.package_version }}
RELEASE_SHA: ${{ steps.version.outputs.release_sha }}
run: |
TAG="browseros-server-v$PACKAGE_VERSION"
TITLE="BrowserOS Server - v$PACKAGE_VERSION"
mapfile -t ZIP_FILES < <(find packages/browseros-agent/dist/prod/server -maxdepth 1 -type f -name 'browseros-server-resources-*.zip' | sort)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists, skipping tag creation"
else
git tag -a "$TAG" -m "browseros-server v$PACKAGE_VERSION" "$RELEASE_SHA"
fi
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag $TAG already on remote, skipping push"
else
git push origin "$TAG"
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists, updating"
gh release edit "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
gh release upload "$TAG" "${ZIP_FILES[@]}" --clobber
else
gh release create "$TAG" \
--title "$TITLE" \
--notes-file /tmp/release-notes.md \
"${ZIP_FILES[@]}"
fi
working-directory: ${{ github.workspace }}

View File

@@ -192,7 +192,7 @@ We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRI
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
Copyright &copy; 2026 Felafax, Inc.
Copyright &copy; 2025 Felafax, Inc.
## Stargazers

View File

@@ -2,17 +2,13 @@ BINARY := browseros-cli
SOURCES := $(shell find . -name '*.go')
VERSION ?= dev
POSTHOG_API_KEY ?=
DIST := dist
LDFLAGS := -X main.version=$(VERSION) -X browseros-cli/analytics.posthogAPIKey=$(POSTHOG_API_KEY)
HOST_OS := $(shell go env GOOS)
HOST_ARCH := $(shell go env GOARCH)
HOST_EXT := $(if $(filter windows,$(HOST_OS)),.exe,)
HOST_BINARY = $(DIST)/$(BINARY)_$(HOST_OS)_$(HOST_ARCH)$(HOST_EXT)
$(BINARY): $(SOURCES)
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
DIST := dist
.PHONY: install clean vet test release
@@ -49,11 +45,6 @@ release:
fi; \
mv "$(DIST)/$(BINARY)$$EXT" "$(DIST)/$(BINARY)_$${OS}_$${ARCH}$$EXT"; \
done
@ACTUAL_VERSION=$$($(HOST_BINARY) --version | awk '{print $$3}'); \
if [ "$$ACTUAL_VERSION" != "$(VERSION)" ]; then \
echo "Error: expected $(HOST_BINARY) to report version $(VERSION), got $$ACTUAL_VERSION" >&2; \
exit 1; \
fi
@cd $(DIST) && (command -v sha256sum >/dev/null 2>&1 && sha256sum *.tar.gz *.zip || shasum -a 256 *.tar.gz *.zip) > checksums.txt
@echo "=== Built artifacts ==="
@ls -lh $(DIST)

View File

@@ -25,17 +25,13 @@ func init() {
Long: `Set up the CLI by providing the MCP server URL from BrowserOS.
Open BrowserOS → Settings → BrowserOS MCP to find your Server URL.
The URL looks like: http://127.0.0.1:9000/mcp
The URL looks like: http://127.0.0.1:9004/mcp
The port varies per installation, so this step is required on first use.
Run again if your port changes.
You can provide the full URL or just the port number:
browseros-cli init http://127.0.0.1:9000/mcp
browseros-cli init 9000
Three modes:
browseros-cli init <url> Non-interactive (full URL or port number)
browseros-cli init <url> Non-interactive, use the provided URL
browseros-cli init --auto Auto-discover from ~/.browseros/server.json
browseros-cli init Interactive prompt`,
Annotations: map[string]string{"group": "Setup:"},
@@ -69,14 +65,13 @@ Three modes:
bold.Println("BrowserOS CLI Setup")
fmt.Println()
fmt.Println("Open BrowserOS → Settings → BrowserOS MCP")
fmt.Println("Copy the Server URL or port number shown there.")
fmt.Println("Copy the Server URL shown there.")
fmt.Println()
dim.Println("Examples: http://127.0.0.1:9000/mcp")
dim.Println(" 9000")
dim.Println("It looks like: http://127.0.0.1:9004/mcp")
fmt.Println()
reader := bufio.NewReader(os.Stdin)
fmt.Print("Server URL or port: ")
fmt.Print("Server URL: ")
line, err := reader.ReadString('\n')
if err != nil {
output.Error("failed to read input", 1)

View File

@@ -34,7 +34,6 @@ const automaticUpdateDrainTimeout = 150 * time.Millisecond
func SetVersion(v string) {
version = v
rootCmd.Version = v
}
var (
@@ -339,27 +338,10 @@ func loadBrowserosServerURL() string {
func normalizeServerURL(raw string) string {
normalized := strings.TrimSpace(raw)
if isPortOnly(normalized) {
normalized = "http://127.0.0.1:" + normalized
}
normalized = strings.TrimSuffix(normalized, "/mcp")
return strings.TrimSuffix(normalized, "/")
}
func isPortOnly(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
func validateServerURL(raw string) (string, error) {
baseURL := normalizeServerURL(raw)
if baseURL != "" {

View File

@@ -5,24 +5,6 @@ import (
"time"
)
func TestSetVersionUpdatesRootCommand(t *testing.T) {
originalVersion := version
originalRootVersion := rootCmd.Version
t.Cleanup(func() {
version = originalVersion
rootCmd.Version = originalRootVersion
})
SetVersion("1.2.3")
if version != "1.2.3" {
t.Fatalf("version = %q, want %q", version, "1.2.3")
}
if rootCmd.Version != "1.2.3" {
t.Fatalf("rootCmd.Version = %q, want %q", rootCmd.Version, "1.2.3")
}
}
func TestCommandName(t *testing.T) {
tests := []struct {
name string

View File

@@ -1,6 +1,6 @@
{
"name": "@browseros/server",
"version": "0.0.81",
"version": "0.0.80",
"description": "BrowserOS server",
"type": "module",
"main": "./src/index.ts",

View File

@@ -0,0 +1,247 @@
/**
* OpenClaw PoC: Multiple Agents + Multiple Sessions
*
* Uses `openclaw gateway call agent` via Bun.spawn.
* The gateway handles auth, session routing, and persistence.
*
* Run: bun run poc/test-openclaw-multi.ts
*/
import { randomUUID } from 'node:crypto'
// ─── Types ───────────────────────────────────────────────
interface AgentResult {
payloads: Array<{ text: string; mediaUrl: string | null }>
meta: {
durationMs: number
agentMeta: {
sessionId: string
provider: string
model: string
usage: { input: number; output: number; total: number }
}
}
}
interface AgentResponse {
text: string
sessionId: string
model: string
durationMs: number
tokens: number
}
// ─── OpenClaw Client ─────────────────────────────────────
class OpenClawClient {
/**
* Send a message to an OpenClaw agent session via the gateway.
*
* @param agentId - Agent name ("main", "code-helper", etc.)
* @param sessionTag - Unique conversation identifier
* @param message - User message text
*/
async chat(
agentId: string,
sessionTag: string,
message: string,
): Promise<AgentResponse> {
const sessionKey = `agent:${agentId}:browseros-${sessionTag}`
const params = JSON.stringify({
message,
sessionKey,
idempotencyKey: randomUUID(),
})
const proc = Bun.spawn(
[
'openclaw',
'gateway',
'call',
'agent',
'--params',
params,
'--expect-final',
'--json',
'--timeout',
'120000',
],
{ stdout: 'pipe', stderr: 'pipe' },
)
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
if (exitCode !== 0) {
throw new Error(`openclaw failed (${exitCode}): ${stderr}`)
}
// Parse JSON — skip log lines before the JSON object
const jsonStart = stdout.indexOf('{')
if (jsonStart === -1) throw new Error('No JSON in output')
const data = JSON.parse(stdout.slice(jsonStart))
const result: AgentResult = data.result ?? data
const meta = result.meta?.agentMeta
return {
text: result.payloads.map((p) => p.text).join('\n'),
sessionId: meta?.sessionId ?? 'unknown',
model: meta?.model ?? 'unknown',
durationMs: result.meta?.durationMs ?? 0,
tokens: meta?.usage?.total ?? 0,
}
}
}
// ─── Helpers ──────────────────────────────────────────────
function print(label: string, res: AgentResponse) {
console.log(` [${label}]`)
console.log(
` Model: ${res.model} | Session: ${res.sessionId.slice(0, 8)}...`,
)
console.log(` Duration: ${res.durationMs}ms | Tokens: ${res.tokens}`)
console.log(` Response: ${res.text.slice(0, 200)}`)
console.log()
}
// ─── Tests ────────────────────────────────────────────────
async function testMultipleSessionsSameAgent(client: OpenClawClient) {
console.log('═══════════════════════════════════════════════')
console.log(' TEST 1: Multiple Sessions, Same Agent')
console.log('═══════════════════════════════════════════════\n')
const convA = randomUUID()
const convB = randomUUID()
console.log('-- Turn 1: Start two conversations --\n')
const [a1, b1] = await Promise.all([
client.chat(
'main',
convA,
'Top 3 places in Tokyo. One line each, no extras.',
),
client.chat(
'main',
convB,
'Top 3 places in Berlin. One line each, no extras.',
),
])
print('Conv A - Tokyo', a1)
print('Conv B - Berlin', b1)
console.log('-- Turn 2: Follow-ups (session memory) --\n')
const [a2, b2] = await Promise.all([
client.chat('main', convA, 'Which of those 3 has the best nightlife?'),
client.chat('main', convB, 'Which of those 3 has the best museums?'),
])
print('Conv A - Tokyo nightlife', a2)
print('Conv B - Berlin museums', b2)
console.log('-- Turn 3: Isolation check --\n')
const [a3, b3] = await Promise.all([
client.chat('main', convA, 'What city are we discussing? Just the name.'),
client.chat('main', convB, 'What city are we discussing? Just the name.'),
])
print('Conv A - should say Tokyo', a3)
print('Conv B - should say Berlin', b3)
}
async function testMultipleAgents(client: OpenClawClient) {
console.log('═══════════════════════════════════════════════')
console.log(' TEST 2: Multiple Agents, Different Models')
console.log('═══════════════════════════════════════════════\n')
const convId = randomUUID()
const question = 'Write a one-line JS function that reverses a string.'
const [main, helper] = await Promise.all([
client.chat('main', convId, question),
client.chat('code-helper', convId, question),
])
print('Agent "main" (opus)', main)
print('Agent "code-helper" (sonnet)', helper)
}
async function testSessionFactory(client: OpenClawClient) {
console.log('═══════════════════════════════════════════════')
console.log(' TEST 3: BrowserOS Session Factory Pattern')
console.log('═══════════════════════════════════════════════\n')
const users = [
{ id: randomUUID(), name: 'Alice', q: 'What is TypeScript? One sentence.' },
{ id: randomUUID(), name: 'Bob', q: 'What is Rust? One sentence.' },
{ id: randomUUID(), name: 'Carol', q: 'What is Go? One sentence.' },
]
console.log('-- 3 parallel user messages --\n')
const results = await Promise.all(
users.map((u) => client.chat('main', u.id, u.q)),
)
for (let i = 0; i < users.length; i++) {
print(users[i].name, results[i])
}
console.log('-- Alice follow-up (others unaffected) --\n')
const followUp = await client.chat(
'main',
users[0].id,
'Give me one code example.',
)
print('Alice follow-up', followUp)
}
async function testToolCalls(client: OpenClawClient) {
console.log('═══════════════════════════════════════════════')
console.log(' TEST 4: Tool Calls (Web Search + Multi-Step)')
console.log('═══════════════════════════════════════════════\n')
const convId = randomUUID()
// This triggers a web search tool call — OpenClaw will use its web_search tool
const res = await client.chat(
'main',
convId,
'Search the web for the current population of Japan. Then calculate how many times larger it is than Iceland (population ~380,000). Show your math.',
)
console.log(
` Payloads received: ${res.text.split('\n').length > 1 ? 'multiple' : '1'}`,
)
console.log(` Duration: ${res.durationMs}ms | Tokens: ${res.tokens}`)
console.log()
// Show each payload separately to see intermediate steps
const lines = res.text.split('\n')
lines.forEach((line, i) => {
if (line.trim()) {
console.log(` [Payload ${i + 1}] ${line.slice(0, 300)}`)
console.log()
}
})
}
// ─── Main ─────────────────────────────────────────────────
const client = new OpenClawClient()
console.log('\n OpenClaw Multi-Agent Multi-Session PoC\n')
try {
await testMultipleSessionsSameAgent(client)
await testMultipleAgents(client)
await testSessionFactory(client)
await testToolCalls(client)
console.log('═══════════════════════════════════════════════')
console.log(' ALL TESTS COMPLETE')
console.log('═══════════════════════════════════════════════')
} catch (err) {
console.error('Test failed:', err)
}

View File

@@ -0,0 +1,276 @@
/**
* OpenClaw PoC: WebSocket Streaming with Device Auth
*
* Connects to the gateway using the proper Ed25519 device identity handshake.
* Subscribes to session events to see tool calls and reasoning in real-time.
*
* Run: bun run poc/test-openclaw-ws-stream.ts
*/
import crypto, { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import WebSocket from 'ws'
// ─── Load Config & Device Identity ───────────────────────
const HOME = homedir()
const config = JSON.parse(
readFileSync(join(HOME, '.openclaw', 'openclaw.json'), 'utf-8'),
)
const gatewayToken = config.gateway?.auth?.token ?? ''
const devicePath = join(HOME, '.openclaw', 'identity', 'device.json')
const device = JSON.parse(readFileSync(devicePath, 'utf-8'))
const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL ?? 'ws://127.0.0.1:18789'
// ─── Crypto Helpers ──────────────────────────────────────
function rawPublicKeyFromPem(pem: string): Buffer {
const der = Buffer.from(
pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''),
'base64',
)
// SPKI for Ed25519: fixed 12-byte prefix (30 2a 30 05 06 03 2b 65 70 03 21 00) + 32 raw bytes
return der.subarray(12)
}
function base64url(buf: Buffer): string {
return buf.toString('base64url')
}
function signPayload(privateKeyPem: string, payload: string): string {
const privateKey = crypto.createPrivateKey(privateKeyPem)
const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), privateKey)
return base64url(sig)
}
// ─── WebSocket Client ────────────────────────────────────
class OpenClawWSClient {
private ws: WebSocket | null = null
// biome-ignore lint/suspicious/noExplicitAny: PoC script — untyped gateway protocol
private pendingRequests = new Map<
string,
{ resolve: (v: any) => void; reject: (e: any) => void }
>()
// biome-ignore lint/suspicious/noExplicitAny: PoC script
private eventHandlers: Array<(event: string, payload: any) => void> = []
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(GATEWAY_URL)
this.ws.on('message', (data: Buffer) => {
const frame = JSON.parse(data.toString())
// Step 1: Gateway sends challenge
if (frame.type === 'event' && frame.event === 'connect.challenge') {
const nonce = frame.payload.nonce
const signedAt = Date.now()
const rawPubKey = rawPublicKeyFromPem(device.publicKeyPem)
const deviceId = device.deviceId
const role = 'operator'
const scopes =
'operator.admin,operator.read,operator.write,operator.approvals,operator.pairing'
const clientId = 'cli'
const clientMode = 'cli'
const platform = process.platform
// v3 signature payload
const payload = `v3|${deviceId}|${clientId}|${clientMode}|${role}|${scopes}|${signedAt}|${gatewayToken}|${nonce}|${platform}|`
const signature = signPayload(device.privateKeyPem, payload)
// Step 2: Send connect with device identity
this.ws?.send(
JSON.stringify({
type: 'req',
id: `connect-${randomUUID().slice(0, 8)}`,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: clientId,
version: '1.0.0',
platform,
mode: clientMode,
},
caps: [],
commands: [],
permissions: {},
auth: { token: gatewayToken },
role,
scopes: scopes.split(','),
device: {
id: deviceId,
publicKey: base64url(rawPubKey),
signature,
signedAt,
nonce,
},
},
}),
)
return
}
// Step 3: hello-ok
if (
frame.type === 'res' &&
frame.ok === true &&
frame.payload?.type === 'hello-ok'
) {
resolve()
return
}
// Auth failure
if (
frame.type === 'res' &&
frame.ok === false &&
!this.pendingRequests.has(frame.id)
) {
reject(new Error(`Connect failed: ${JSON.stringify(frame.error)}`))
return
}
// RPC responses
if (frame.type === 'res' && this.pendingRequests.has(frame.id)) {
const pending = this.pendingRequests.get(frame.id)
if (!pending) return
this.pendingRequests.delete(frame.id)
if (frame.ok === false) {
pending.reject(
new Error(frame.error?.message ?? JSON.stringify(frame.error)),
)
} else {
pending.resolve(frame.payload)
}
return
}
// Events (tool calls, text deltas, agent steps)
if (frame.type === 'event') {
for (const handler of this.eventHandlers) {
handler(frame.event, frame.payload)
}
}
})
this.ws.on('error', (err) => reject(err))
setTimeout(() => reject(new Error('Connection timeout')), 15_000)
})
}
// biome-ignore lint/suspicious/noExplicitAny: PoC script
onEvent(handler: (event: string, payload: any) => void) {
this.eventHandlers.push(handler)
}
// biome-ignore lint/suspicious/noExplicitAny: PoC script — untyped gateway protocol
private call(method: string, params: Record<string, any>): Promise<any> {
return new Promise((resolve, reject) => {
const id = randomUUID()
this.pendingRequests.set(id, { resolve, reject })
this.ws?.send(JSON.stringify({ type: 'req', id, method, params }))
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id)
reject(new Error('Request timeout'))
}
}, 120_000)
})
}
/**
* Send a message and wait for the full response via events.
* Returns a promise that resolves when the agent run completes.
*/
// biome-ignore lint/suspicious/noExplicitAny: PoC script
async chatAndWait(
agentId: string,
sessionTag: string,
message: string,
): Promise<any> {
const idempotencyKey = randomUUID()
// Set up event listener before sending
// biome-ignore lint/suspicious/noExplicitAny: PoC script
const waitForFinal = new Promise<any>((resolve) => {
// biome-ignore lint/suspicious/noExplicitAny: PoC script
const handler = (event: string, payload: any) => {
if (event === 'chat' && payload?.state === 'final') {
const idx = this.eventHandlers.indexOf(handler)
if (idx >= 0) this.eventHandlers.splice(idx, 1)
resolve(payload)
}
}
this.eventHandlers.push(handler)
// Timeout after 2 minutes
setTimeout(() => {
const idx = this.eventHandlers.indexOf(handler)
if (idx >= 0) this.eventHandlers.splice(idx, 1)
resolve({ timeout: true })
}, 120_000)
})
// Send the request (returns "accepted" immediately)
this.call('agent', {
message,
sessionKey: `agent:${agentId}:browseros-${sessionTag}`,
idempotencyKey,
}).catch(() => {})
return waitForFinal
}
disconnect() {
this.ws?.close()
}
}
// ─── Main ─────────────────────────────────────────────────
console.log('\n OpenClaw WebSocket Streaming PoC\n')
const client = new OpenClawWSClient()
try {
console.log('Connecting with device identity...')
await client.connect()
console.log('Connected!\n')
// Listen for ALL events to see what comes through
client.onEvent((event, payload) => {
const ts = new Date().toISOString().slice(11, 23)
const summary = JSON.stringify(payload).slice(0, 150)
console.log(` [${ts}] EVENT: ${event} ${summary}`)
})
// Test 1: Simple message — watch the events stream
console.log('--- Test 1: Simple message ---\n')
const res1 = await client.chatAndWait(
'main',
'ws-stream-test',
'What is 2 + 2? One word answer.',
)
console.log(`\n Final result:`, JSON.stringify(res1).slice(0, 200), '\n')
// Test 2: Tool call (web search) — should show intermediate tool events
console.log('--- Test 2: Web search (watch for tool events) ---\n')
const res2 = await client.chatAndWait(
'main',
'ws-stream-test',
'Search the web for the current temperature in London right now.',
)
console.log(`\n Final result:`, JSON.stringify(res2).slice(0, 200), '\n')
} catch (err) {
console.error('Failed:', err)
} finally {
client.disconnect()
}

View File

@@ -209,7 +209,7 @@ export class AiSdkAgent {
userSystemPrompt: config.resolvedConfig.userSystemPrompt,
exclude: excludeSections,
isScheduledTask: config.resolvedConfig.isScheduledTask,
scheduledTaskPageId: config.browserContext?.activeTab?.pageId,
scheduledTaskWindowId: config.browserContext?.windowId,
workspaceDir: config.resolvedConfig.workingDir,
soulContent,
isSoulBootstrap: isBootstrap,

View File

@@ -49,7 +49,7 @@ You do not have a filesystem workspace in this session. Return all results direc
// Mode-aware framing
if (options?.isScheduledTask) {
role +=
'\n\nYou are running as a scheduled background task on a system-managed hidden page. Complete the task autonomously and report results.'
'\n\nYou are running as a scheduled background task in a dedicated hidden browser window. Complete the task autonomously and report results.'
} else if (options?.chatMode) {
role +=
'\n\nYou are in read-only chat mode. You can observe pages but cannot interact with them, modify files, or store memories.'
@@ -238,7 +238,7 @@ When a task requires working on multiple pages simultaneously:
7. **Never force-switch the user's active tab.** If you need user interaction on a background tab (e.g., login, CAPTCHA), tell the user which tab needs attention and let them switch manually.
8. **Never navigate the user's current tab** during a multi-tab task. The current tab is the user's anchor — use it only for reading (snapshots, content extraction). All navigation should happen on background tabs.
**Do NOT use \`create_hidden_window\` or \`new_hidden_page\` for user-requested tasks.** Hidden pages are invisible to the user and do not appear in the user's tab strip. Use \`new_page\` (background mode) instead — tabs appear in the user's tab strip and can be inspected. Reserve hidden pages for automated/scheduled runs only.`
**Do NOT use \`create_hidden_window\` or \`new_hidden_page\` for user-requested tasks.** Hidden windows are invisible to the user and cannot be screenshotted. Use \`new_page\` (background mode) instead — tabs appear in the user's tab strip and can be inspected. Reserve hidden windows for automated/scheduled runs only.`
if (!isNewTab) {
executionContent += `
@@ -661,24 +661,22 @@ function getUserContext(
if (options?.isScheduledTask) {
pageCtx +=
'\nYou are running as a **scheduled background task** on a system-managed hidden page.'
'\nYou are running as a **scheduled background task** in a dedicated hidden browser window.'
}
pageCtx +=
'\n\n**CRITICAL RULES:**\n1. **Do NOT call `get_active_page` or `list_pages` to find your starting page.** Use the **page ID from the Browser Context** directly.'
if (options?.isScheduledTask) {
const pageRef = options.scheduledTaskPageId
? `\`${options.scheduledTaskPageId}\``
: 'the page ID from the Browser Context'
pageCtx += `\n2. **Use starting page ID ${pageRef} directly.** For additional browsing, prefer \`new_hidden_page\` so the work stays invisible to the user.`
const windowRef = options.scheduledTaskWindowId
? `\`windowId: ${options.scheduledTaskWindowId}\``
: 'the `windowId` from the Browser Context'
pageCtx += `\n2. **Always pass ${windowRef}** when calling \`new_page\` or \`new_hidden_page\`. Never omit the \`windowId\` parameter.`
pageCtx +=
'\n3. **Do NOT close your starting hidden page** (via `close_page` on that page ID). It is managed by the system and will be cleaned up automatically.'
'\n3. **Do NOT close your dedicated hidden window** (via `close_window`). It is managed by the system and will be cleaned up automatically.'
pageCtx +=
'\n4. **Do NOT create new windows** (via `create_window` or `create_hidden_window`). Use hidden pages instead.'
pageCtx +=
'\n5. **Close extra hidden pages when you are done with them** unless you explicitly reveal them with `show_page`.'
pageCtx += '\n6. Complete the task end-to-end and report results.'
'\n4. **Do NOT create new windows** (via `create_window` or `create_hidden_window`). Use your existing hidden window for all pages.'
pageCtx += '\n5. Complete the task end-to-end and report results.'
}
pageCtx += '\n</page_context>'
@@ -739,7 +737,7 @@ export interface BuildSystemPromptOptions {
userSystemPrompt?: string
exclude?: string[]
isScheduledTask?: boolean
scheduledTaskPageId?: number
scheduledTaskWindowId?: number
workspaceDir?: string
soulContent?: string
isSoulBootstrap?: boolean

View File

@@ -4,8 +4,8 @@ import type { AiSdkAgent } from './ai-sdk-agent'
export interface AgentSession {
agent: AiSdkAgent
hiddenPageId?: number
/** Browser context scoped to the scheduled hidden page. */
hiddenWindowId?: number
/** Browser context scoped to the hidden window (scheduled tasks only) */
browserContext?: BrowserContext
/** MCP server names used when the session was created, for change detection. */
mcpServerKey?: string

View File

@@ -146,51 +146,34 @@ export class ChatService {
if (!session) {
isNewSession = true
let hiddenPageId: number | undefined
let hiddenWindowId: number | undefined
let browserContext = await this.resolvePageIds(request.browserContext)
if (request.isScheduledTask) {
try {
hiddenPageId = await this.deps.browser.newPage('about:blank', {
hidden: true,
background: true,
const win = await this.deps.browser.createWindow({ hidden: true })
hiddenWindowId = win.windowId
const pageId = await this.deps.browser.newPage('about:blank', {
windowId: hiddenWindowId,
})
let hiddenWindowId: number | undefined
try {
const hiddenPage = (await this.deps.browser.listPages()).find(
(page) => page.pageId === hiddenPageId,
)
hiddenWindowId = hiddenPage?.windowId
} catch (error) {
logger.warn('Failed to look up hidden page metadata', {
conversationId: request.conversationId,
pageId: hiddenPageId,
error: error instanceof Error ? error.message : String(error),
})
}
browserContext = {
...browserContext,
windowId: hiddenWindowId,
selectedTabs: undefined,
tabs: undefined,
activeTab: {
id: hiddenPageId,
pageId: hiddenPageId,
id: pageId,
pageId,
url: 'about:blank',
title: 'Scheduled Task',
},
}
logger.info('Created hidden page for scheduled task', {
logger.info('Created hidden window for scheduled task', {
conversationId: request.conversationId,
pageId: hiddenPageId,
windowId: hiddenWindowId,
pageId,
})
} catch (error) {
logger.warn(
'Failed to create hidden page, using default browser context',
{
error: error instanceof Error ? error.message : String(error),
},
)
logger.warn('Failed to create hidden window, using default', {
error: error instanceof Error ? error.message : String(error),
})
}
}
@@ -205,7 +188,7 @@ export class ChatService {
})
session = {
agent,
hiddenPageId,
hiddenWindowId,
browserContext,
mcpServerKey,
workingDir: request.userWorkingDir,
@@ -262,10 +245,10 @@ export class ChatService {
totalMessages: messages.length,
})
if (session?.hiddenPageId) {
const pageId = session.hiddenPageId
session.hiddenPageId = undefined
this.closeHiddenPage(pageId, request.conversationId)
if (session?.hiddenWindowId) {
const windowId = session.hiddenWindowId
session.hiddenWindowId = undefined
this.closeHiddenWindow(windowId, request.conversationId)
}
},
})
@@ -275,10 +258,10 @@ export class ChatService {
conversationId: string,
): Promise<{ deleted: boolean; sessionCount: number }> {
const session = this.deps.sessionStore.get(conversationId)
if (session?.hiddenPageId) {
const pageId = session.hiddenPageId
session.hiddenPageId = undefined
this.closeHiddenPage(pageId, conversationId)
if (session?.hiddenWindowId) {
const windowId = session.hiddenWindowId
session.hiddenWindowId = undefined
this.closeHiddenWindow(windowId, conversationId)
}
const deleted = await this.deps.sessionStore.delete(conversationId)
return { deleted, sessionCount: this.deps.sessionStore.count() }
@@ -326,10 +309,10 @@ export class ChatService {
}
}
private closeHiddenPage(pageId: number, conversationId: string): void {
this.deps.browser.closePage(pageId).catch((error) => {
logger.warn('Failed to close hidden page', {
pageId,
private closeHiddenWindow(windowId: number, conversationId: string): void {
this.deps.browser.closeWindow(windowId).catch((error) => {
logger.warn('Failed to close hidden window', {
windowId,
conversationId,
error: error instanceof Error ? error.message : String(error),
})
@@ -346,10 +329,7 @@ export class ChatService {
await session.agent.dispose()
this.deps.sessionStore.remove(request.conversationId)
const browserContext = agentConfig.isScheduledTask
? (session.browserContext ??
(await this.resolvePageIds(request.browserContext)))
: await this.resolvePageIds(request.browserContext)
const browserContext = await this.resolvePageIds(request.browserContext)
const agent = await AiSdkAgent.create({
resolvedConfig: agentConfig,
browser: this.deps.browser,
@@ -361,7 +341,6 @@ export class ChatService {
})
const newSession: AgentSession = {
agent,
hiddenPageId: session.hiddenPageId,
browserContext,
mcpServerKey,
workingDir: request.userWorkingDir,

View File

@@ -173,7 +173,7 @@ export const new_page = defineTool({
export const new_hidden_page = defineTool({
name: 'new_hidden_page',
description:
'Open a new hidden page (tab) and navigate to a URL. Hidden pages are not visible to the user and useful for background data fetching or automation.',
'Open a new hidden page (tab) and navigate to a URL. Hidden pages are not visible to the user and useful for background data fetching or automation. Note: take_screenshot is not supported on hidden tabs — use show_page first to make it visible.',
input: z.object({
url: z.string().describe('URL to open'),
windowId: z.number().optional().describe('Window ID to create tab in'),
@@ -206,7 +206,7 @@ export const new_hidden_page = defineTool({
export const show_page = defineTool({
name: 'show_page',
description:
'Restore a hidden page back into a visible browser window. Use after new_hidden_page when you want the user to inspect or interact with it. Errors if the page is already visible.',
'Restore a hidden page back into a visible browser window. Use after new_hidden_page when you need to make the page visible (e.g. for screenshots). Errors if the page is already visible.',
input: z.object({
page: pageParam,
windowId: z

View File

@@ -79,7 +79,7 @@ export const create_window = defineTool({
export const create_hidden_window = defineTool({
name: 'create_hidden_window',
description:
'Create a new hidden browser window. Hidden windows are not visible to the user and useful for background automation.',
'Create a new hidden browser window. Hidden windows are not visible to the user and useful for background automation. Note: take_screenshot is not supported on hidden windows.',
input: z.object({}),
output: z.object({
window: windowInfoSchema,

View File

@@ -86,7 +86,7 @@ function buildScheduled(overrides?: Partial<BuildSystemPromptOptions>): string {
return buildSystemPrompt({
isScheduledTask: true,
workspaceDir: '/tmp/scheduled',
scheduledTaskPageId: 42,
scheduledTaskWindowId: 42,
exclude: ['nudges'],
...overrides,
})
@@ -258,7 +258,7 @@ describe('workspace gating (P11)', () => {
// from subtle cues (missing sections, restricted tools), which is unreliable.
//
// - Regular: no extra framing (default behavior)
// - Scheduled: must know it's autonomous, on a hidden page, no user interaction
// - Scheduled: must know it's autonomous, in a hidden window, no user interaction
// - Chat: must know it's read-only, cannot click/fill/write
//
// If mode framing breaks, scheduled tasks may try to ask the user questions,
@@ -310,21 +310,20 @@ describe('mode-aware framing', () => {
expect(prompt).not.toContain('<page_context>')
})
it('scheduled task includes starting pageId in page context', () => {
const prompt = buildScheduled({ scheduledTaskPageId: 99 })
expect(prompt).toContain('starting page ID `99`')
it('scheduled task includes windowId in page context', () => {
const prompt = buildScheduled({ scheduledTaskWindowId: 99 })
expect(prompt).toContain('windowId: 99')
})
it('scheduled task without pageId uses Browser Context reference', () => {
const prompt = buildScheduled({ scheduledTaskPageId: undefined })
expect(prompt).toContain('the page ID from the Browser Context')
it('scheduled task without windowId uses Browser Context reference', () => {
const prompt = buildScheduled({ scheduledTaskWindowId: undefined })
expect(prompt).toContain('the `windowId` from the Browser Context')
})
it('scheduled task includes hidden page management rules', () => {
it('scheduled task includes hidden window management rules', () => {
const prompt = buildScheduled()
expect(prompt).toContain('Do NOT close your starting hidden page')
expect(prompt).toContain('Do NOT close your dedicated hidden window')
expect(prompt).toContain('Do NOT create new windows')
expect(prompt).toContain('Close extra hidden pages')
})
})
@@ -1061,12 +1060,11 @@ describe('execution section', () => {
it('prohibits hidden windows for user tasks', () => {
// Why: Run 2 used create_hidden_window instead of background tabs.
// Hidden pages are invisible to users, so user-requested work must stay on visible tabs.
// Hidden windows are invisible to users and can't be screenshotted.
const prompt = buildRegular()
expect(prompt).toContain('Do NOT use')
expect(prompt).toContain('create_hidden_window')
expect(prompt).toContain('new_hidden_page')
expect(prompt).not.toContain('cannot be screenshotted')
})
it('includes tab retry discipline', () => {

View File

@@ -1,291 +0,0 @@
import { describe, expect, it, mock } from 'bun:test'
interface MockMessage {
id: string
role: 'user' | 'assistant'
parts: Array<{ type: 'text'; text: string }>
}
interface MockAgent {
toolLoopAgent: object
toolNames: Set<string>
messages: MockMessage[]
appendUserMessage(text: string): void
dispose(): Promise<void>
}
interface StoredSession {
agent: MockAgent
hiddenPageId?: number
}
interface StreamResponseOptions {
onFinish(args: { messages: MockMessage[] }): Promise<void>
}
let agentToReturn: MockAgent | undefined
let streamResponseHandler:
| ((options: StreamResponseOptions) => Promise<Response>)
| undefined
const createAgentSpy = mock(async (config: unknown) => {
if (!agentToReturn) {
throw new Error(`No mock agent configured for ${JSON.stringify(config)}`)
}
return agentToReturn
})
const createAgentUIStreamResponseSpy = mock(
async (options: StreamResponseOptions) => {
if (!streamResponseHandler) {
throw new Error('No stream response handler configured')
}
return await streamResponseHandler(options)
},
)
const resolveLLMConfigSpy = mock(async () => ({
provider: 'openai',
model: 'gpt-5',
apiKey: 'test-key',
}))
mock.module('ai', () => ({
createAgentUIStreamResponse: createAgentUIStreamResponseSpy,
}))
mock.module('../../../src/agent/ai-sdk-agent', () => ({
AiSdkAgent: {
create: createAgentSpy,
},
}))
mock.module('../../../src/lib/clients/llm/config', () => ({
resolveLLMConfig: resolveLLMConfigSpy,
}))
mock.module('../../../src/lib/logger', () => ({
logger: {
info: mock(() => {}),
warn: mock(() => {}),
debug: mock(() => {}),
},
}))
const { ChatService } = await import('../../../src/api/services/chat-service')
function createSessionStore() {
const sessions = new Map<string, StoredSession>()
return {
get(conversationId: string) {
return sessions.get(conversationId)
},
set(conversationId: string, session: StoredSession) {
sessions.set(conversationId, session)
},
remove(conversationId: string) {
return sessions.delete(conversationId)
},
async delete(conversationId: string) {
const session = sessions.get(conversationId)
if (!session) return false
await session.agent.dispose()
sessions.delete(conversationId)
return true
},
count() {
return sessions.size
},
}
}
function createFakeAgent() {
const messages: MockMessage[] = []
return {
toolLoopAgent: {},
toolNames: new Set<string>(),
messages,
appendUserMessage(text: string) {
messages.push({
id: 'user-1',
role: 'user',
parts: [{ type: 'text', text }],
})
},
dispose: mock(async () => {}),
}
}
describe('ChatService scheduled task hidden page lifecycle', () => {
it('creates and cleans up a hidden page without creating a hidden window', async () => {
const fakeAgent = createFakeAgent()
agentToReturn = fakeAgent
streamResponseHandler = async ({ onFinish }) => {
await onFinish({ messages: fakeAgent.messages })
return new Response('ok')
}
const browser = {
newPage: mock(async () => 77),
listPages: mock(async () => [
{
pageId: 77,
windowId: 11,
},
]),
closePage: mock(async () => {}),
createWindow: mock(async () => ({ windowId: 11 })),
closeWindow: mock(async () => {}),
resolveTabIds: mock(async () => new Map<number, number>()),
}
const sessionStore = createSessionStore()
const service = new ChatService({
sessionStore: sessionStore as never,
klavisClient: {} as never,
browser: browser as never,
registry: {} as never,
})
await service.processMessage(
{
conversationId: crypto.randomUUID(),
message: 'Run the scheduled task',
isScheduledTask: true,
mode: 'agent',
origin: 'sidepanel',
browserContext: {
windowId: 9,
activeTab: {
id: 3,
url: 'https://example.com',
title: 'Example',
},
selectedTabs: [{ id: 4 }],
enabledMcpServers: ['slack'],
},
} as never,
new AbortController().signal,
)
expect(browser.newPage).toHaveBeenCalledWith('about:blank', {
hidden: true,
background: true,
})
expect(browser.createWindow).not.toHaveBeenCalled()
expect(browser.closePage).toHaveBeenCalledWith(77)
expect(browser.closeWindow).not.toHaveBeenCalled()
const createArgs = createAgentSpy.mock.calls.at(-1)?.[0] as {
browserContext?: {
windowId?: number
selectedTabs?: unknown[]
activeTab?: {
id: number
pageId: number
url: string
title: string
}
enabledMcpServers?: string[]
}
}
expect(createArgs.browserContext?.windowId).toBe(11)
expect(createArgs.browserContext?.selectedTabs).toBeUndefined()
expect(createArgs.browserContext?.activeTab).toEqual({
id: 77,
pageId: 77,
url: 'about:blank',
title: 'Scheduled Task',
})
expect(createArgs.browserContext?.enabledMcpServers).toEqual(['slack'])
})
it('deleteSession closes the tracked hidden page', async () => {
const fakeAgent = createFakeAgent()
const sessionStore = createSessionStore()
const browser = {
closePage: mock(async () => {}),
}
const conversationId = crypto.randomUUID()
sessionStore.set(conversationId, {
agent: fakeAgent,
hiddenPageId: 33,
})
const service = new ChatService({
sessionStore: sessionStore as never,
klavisClient: {} as never,
browser: browser as never,
registry: {} as never,
})
const result = await service.deleteSession(conversationId)
expect(result).toEqual({ deleted: true, sessionCount: 0 })
expect(browser.closePage).toHaveBeenCalledWith(33)
expect(fakeAgent.dispose).toHaveBeenCalledTimes(1)
})
it('keeps the scheduled hidden page context when metadata lookup fails', async () => {
const fakeAgent = createFakeAgent()
agentToReturn = fakeAgent
streamResponseHandler = async ({ onFinish }) => {
await onFinish({ messages: fakeAgent.messages })
return new Response('ok')
}
const browser = {
newPage: mock(async () => 88),
listPages: mock(async () => {
throw new Error('CDP lookup failed')
}),
closePage: mock(async () => {}),
resolveTabIds: mock(async () => new Map<number, number>()),
}
const sessionStore = createSessionStore()
const service = new ChatService({
sessionStore: sessionStore as never,
klavisClient: {} as never,
browser: browser as never,
registry: {} as never,
})
await service.processMessage(
{
conversationId: crypto.randomUUID(),
message: 'Run the scheduled task',
isScheduledTask: true,
mode: 'agent',
origin: 'sidepanel',
browserContext: {
activeTab: {
id: 3,
url: 'https://example.com',
title: 'Example',
},
},
} as never,
new AbortController().signal,
)
const createArgs = createAgentSpy.mock.calls.at(-1)?.[0] as {
browserContext?: {
windowId?: number
activeTab?: {
id: number
pageId: number
url: string
title: string
}
}
}
expect(createArgs.browserContext?.windowId).toBeUndefined()
expect(createArgs.browserContext?.activeTab).toEqual({
id: 88,
pageId: 88,
url: 'about:blank',
title: 'Scheduled Task',
})
expect(browser.closePage).toHaveBeenCalledWith(88)
})
})

View File

@@ -8,16 +8,11 @@
import { afterAll, describe, it } from 'bun:test'
import assert from 'node:assert'
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
// Derive the build target from the current platform so the test is portable
function getNativeTarget(): { id: string; ext: string } {
const os =
process.platform === 'darwin'
@@ -29,30 +24,12 @@ function getNativeTarget(): { id: string; ext: string } {
return { id: `${os}-${cpu}`, ext: process.platform === 'win32' ? '.exe' : '' }
}
const REQUIRED_INLINE_ENV_KEYS = [
'BROWSEROS_CONFIG_URL',
'CODEGEN_SERVICE_URL',
'POSTHOG_API_KEY',
'SENTRY_DSN',
] as const
const R2_ENV_KEYS = [
'R2_ACCOUNT_ID',
'R2_ACCESS_KEY_ID',
'R2_SECRET_ACCESS_KEY',
'R2_BUCKET',
] as const
const PROD_SECRET_KEYS = [...REQUIRED_INLINE_ENV_KEYS, ...R2_ENV_KEYS]
const INLINE_ENV_STUBS: Record<string, string> = {
// Stub values so the build config validation passes without real secrets
const BUILD_ENV_STUBS: Record<string, string> = {
BROWSEROS_CONFIG_URL: 'https://stub.test/config',
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
POSTHOG_API_KEY: 'phc_test_stub',
SENTRY_DSN: 'https://stub@sentry.test/0',
}
const R2_ENV_STUBS: Record<string, string> = {
R2_ACCOUNT_ID: 'test',
R2_ACCESS_KEY_ID: 'test',
R2_SECRET_ACCESS_KEY: 'test',
@@ -62,58 +39,23 @@ const R2_ENV_STUBS: Record<string, string> = {
describe('server build', () => {
const rootDir = resolve(import.meta.dir, '../../..')
const serverPkgPath = resolve(rootDir, 'apps/server/package.json')
const prodEnvPath = resolve(rootDir, 'apps/server/.env.production')
const prodEnvTemplatePath = resolve(
rootDir,
'apps/server/.env.production.example',
)
const originalProdEnv = existsSync(prodEnvPath)
? readFileSync(prodEnvPath, 'utf-8')
: null
const prodEnvTemplate = readFileSync(prodEnvTemplatePath, 'utf-8')
const buildScript = resolve(rootDir, 'scripts/build/server.ts')
const target = getNativeTarget()
const binaryPath = resolve(
rootDir,
`dist/prod/server/.tmp/binaries/browseros-server-${target.id}${target.ext}`,
)
const zipPath = resolve(
rootDir,
`dist/prod/server/browseros-server-resources-${target.id}.zip`,
)
// Empty manifest so the build skips R2 resource downloads
const tempDir = mkdtempSync(join(tmpdir(), 'browseros-build-test-'))
const emptyManifestPath = join(tempDir, 'empty-manifest.json')
writeFileSync(emptyManifestPath, JSON.stringify({ resources: [] }))
function buildEnv(
extraEnv: Record<string, string>,
omitKeys: readonly string[] = [],
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
...extraEnv,
}
for (const key of omitKeys) {
delete env[key]
}
return env
}
function resetProdEnvToTemplate(): void {
writeFileSync(prodEnvPath, prodEnvTemplate)
}
afterAll(() => {
rmSync(tempDir, { recursive: true, force: true })
if (originalProdEnv === null) {
rmSync(prodEnvPath, { force: true })
return
}
writeFileSync(prodEnvPath, originalProdEnv)
})
it('compiles and --version outputs correct version', async () => {
resetProdEnvToTemplate()
const pkg = await Bun.file(serverPkgPath).json()
const expectedVersion: string = pkg.version
@@ -129,7 +71,7 @@ describe('server build', () => {
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: buildEnv({ ...INLINE_ENV_STUBS, ...R2_ENV_STUBS }),
env: { ...process.env, ...BUILD_ENV_STUBS },
},
)
const buildExit = await build.exited
@@ -155,26 +97,4 @@ describe('server build', () => {
)
assert.strictEqual(versionOutput.trim(), expectedVersion)
}, 300_000)
it('archives CI builds without R2 config or production env secrets', async () => {
resetProdEnvToTemplate()
rmSync(zipPath, { force: true })
const build = Bun.spawn(
['bun', buildScript, `--target=${target.id}`, '--ci'],
{
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: buildEnv({}, PROD_SECRET_KEYS),
},
)
const buildExit = await build.exited
if (buildExit !== 0) {
const stderr = await new Response(build.stderr).text()
assert.fail(`CI build failed (exit ${buildExit}):\n${stderr}`)
}
assert.ok(existsSync(zipPath), `Expected archive at ${zipPath}`)
}, 300_000)
})

View File

@@ -29,13 +29,6 @@ function structuredOf<T>(result: { structuredContent?: unknown }): T {
}
describe('navigation tools', () => {
it('hidden-page tool descriptions do not claim screenshots are unsupported', () => {
assert.ok(
!new_hidden_page.description.includes('take_screenshot is not supported'),
)
assert.ok(!show_page.description.includes('for screenshots'))
})
it('list_pages returns at least one page', async () => {
await withBrowser(async ({ execute }) => {
const result = await execute(list_pages, {})

View File

@@ -24,14 +24,6 @@ function structuredOf<T>(result: { structuredContent?: unknown }): T {
}
describe('window tools', () => {
it('create_hidden_window description does not claim screenshots are unsupported', () => {
assert.ok(
!create_hidden_window.description.includes(
'take_screenshot is not supported',
),
)
})
it('list_windows returns at least one window', async () => {
await withBrowser(async ({ execute }) => {
const result = await execute(list_windows, {})

View File

@@ -152,7 +152,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.81",
"version": "0.0.80",
"bin": {
"browseros-server": "./src/index.ts",
},

View File

@@ -19,7 +19,7 @@
"start:agent": "bun run --filter @browseros/agent dev",
"build": "bun run build:server && bun run build:agent",
"build:server": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all",
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --ci",
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --compile-only",
"build:server:test": "FORCE_COLOR=1 bun scripts/build/server.ts --target=darwin-arm64 --no-upload",
"upload:cli-installers": "bun scripts/build/cli.ts",
"start:server:test": "bun run build:server:test && set -a && . apps/server/.env.development && set +a && dist/prod/server/.tmp/binaries/browseros-server-darwin-arm64",

View File

@@ -36,39 +36,29 @@ export async function archiveAndUploadArtifacts(
client: S3Client,
r2: R2Config,
upload: boolean,
): Promise<UploadResult[]> {
const results = await archiveArtifacts(artifacts)
if (!upload) {
return results
}
const uploadedResults: UploadResult[] = []
for (const result of results) {
const fileName = basename(result.zipPath)
const latestR2Key = joinObjectKey(r2.uploadPrefix, 'latest', fileName)
const versionR2Key = joinObjectKey(r2.uploadPrefix, version, fileName)
await uploadFileToObject(client, r2, latestR2Key, result.zipPath)
await uploadFileToObject(client, r2, versionR2Key, result.zipPath)
uploadedResults.push({
targetId: result.targetId,
zipPath: result.zipPath,
latestR2Key,
versionR2Key,
})
}
return uploadedResults
}
export async function archiveArtifacts(
artifacts: StagedArtifact[],
): Promise<UploadResult[]> {
const results: UploadResult[] = []
for (const artifact of artifacts) {
const zipPath = zipPathForArtifact(artifact)
await zipArtifactRoot(artifact.rootDir, zipPath)
results.push({ targetId: artifact.target.id, zipPath })
if (!upload) {
results.push({ targetId: artifact.target.id, zipPath })
continue
}
const fileName = basename(zipPath)
const latestR2Key = joinObjectKey(r2.uploadPrefix, 'latest', fileName)
const versionR2Key = joinObjectKey(r2.uploadPrefix, version, fileName)
await uploadFileToObject(client, r2, latestR2Key, zipPath)
await uploadFileToObject(client, r2, versionR2Key, zipPath)
results.push({
targetId: artifact.target.id,
zipPath,
latestR2Key,
versionR2Key,
})
}
return results

View File

@@ -22,26 +22,23 @@ export function parseBuildArgs(argv: string[]): BuildArgs {
.option('--upload', 'Upload artifact zips to R2')
.option('--no-upload', 'Skip zip upload to R2')
.option(
'--ci',
'Build local release zip artifacts for CI without R2 and without requiring production env secrets',
'--compile-only',
'Compile binaries only (skip R2 staging and upload)',
)
program.parse(argv, { from: 'user' })
const options = program.opts<{
target: string
manifest: string
upload: boolean
ci: boolean
compileOnly: boolean
}>()
const ci = options.ci ?? false
if (ci && options.upload) {
throw new Error('--ci cannot be combined with --upload')
}
const compileOnly = options.compileOnly ?? false
return {
targets: resolveTargets(options.target),
manifestPath: options.manifest,
upload: ci ? false : (options.upload ?? true),
ci,
upload: compileOnly ? false : (options.upload ?? true),
compileOnly,
}
}

View File

@@ -1,7 +1,6 @@
import { mkdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { log } from '../log'
import { wasmBinaryPlugin } from '../plugins/wasm-binary'
import { runCommand } from './command'
import type { BuildTarget, CompiledServerBinary } from './types'
@@ -53,7 +52,6 @@ async function bundleServer(
async function compileTarget(
target: BuildTarget,
env: NodeJS.ProcessEnv,
ci: boolean,
): Promise<string> {
const binaryPath = compiledBinaryPath(target)
const args = [
@@ -68,15 +66,11 @@ async function compileTarget(
await runCommand('bun', args, env)
if (target.os === 'windows') {
if (ci) {
log.warn('Skipping Windows exe metadata patching in CI mode')
} else {
await runCommand(
'bun',
['scripts/patch-windows-exe.ts', binaryPath],
process.env,
)
}
await runCommand(
'bun',
['scripts/patch-windows-exe.ts', binaryPath],
process.env,
)
}
return binaryPath
@@ -87,16 +81,14 @@ export async function compileServerBinaries(
envVars: Record<string, string>,
processEnv: NodeJS.ProcessEnv,
version: string,
options?: { ci?: boolean },
): Promise<CompiledServerBinary[]> {
const ci = options?.ci ?? false
rmSync(TMP_ROOT, { recursive: true, force: true })
mkdirSync(BINARIES_DIR, { recursive: true })
await bundleServer(envVars, version)
const compiled: CompiledServerBinary[] = []
for (const target of targets) {
const binaryPath = await compileTarget(target, processEnv, ci)
const binaryPath = await compileTarget(target, processEnv)
compiled.push({ target, binaryPath })
}

View File

@@ -75,7 +75,7 @@ function validateProductionEnv(envVars: Record<string, string>): void {
}
export interface LoadBuildConfigOptions {
ci?: boolean
compileOnly?: boolean
}
export function loadBuildConfig(
@@ -84,9 +84,7 @@ export function loadBuildConfig(
): BuildConfig {
const fileEnv = loadProdEnv(rootDir)
const envVars = buildInlineEnv(fileEnv)
if (!options.ci) {
validateProductionEnv(envVars)
}
validateProductionEnv(envVars)
const processEnv: NodeJS.ProcessEnv = {
PATH: process.env.PATH ?? '',
@@ -94,7 +92,7 @@ export function loadBuildConfig(
...process.env,
}
if (options.ci) {
if (options.compileOnly) {
return { version: readServerVersion(rootDir), envVars, processEnv }
}

View File

@@ -2,17 +2,13 @@ import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { log } from '../log'
import { archiveAndUploadArtifacts, archiveArtifacts } from './archive'
import { archiveAndUploadArtifacts } from './archive'
import { parseBuildArgs } from './cli'
import { compileServerBinaries, getDistProdRoot } from './compile'
import { loadBuildConfig } from './config'
import { getTargetRules, loadManifest } from './manifest'
import { createR2Client } from './r2'
import { stageCompiledArtifact, stageTargetArtifact } from './stage'
function buildModeLabel(ci: boolean): string {
return ci ? 'ci' : 'full'
}
import { stageTargetArtifact } from './stage'
export async function runProdResourceBuild(argv: string[]): Promise<void> {
const rootDir = resolve(import.meta.dir, '../../..')
@@ -20,40 +16,25 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
const args = parseBuildArgs(argv)
const buildConfig = loadBuildConfig(rootDir, { ci: args.ci })
const buildConfig = loadBuildConfig(rootDir, {
compileOnly: args.compileOnly,
})
log.header(`Building BrowserOS server artifacts v${buildConfig.version}`)
log.info(`Targets: ${args.targets.map((target) => target.id).join(', ')}`)
log.info(`Mode: ${buildModeLabel(args.ci)}`)
log.info(`Mode: ${args.compileOnly ? 'compile-only' : 'full'}`)
const compiled = await compileServerBinaries(
args.targets,
buildConfig.envVars,
buildConfig.processEnv,
buildConfig.version,
{ ci: args.ci },
)
if (args.ci) {
const distRoot = getDistProdRoot()
const localArtifacts = []
if (args.compileOnly) {
log.done('Compile-only build completed')
for (const binary of compiled) {
log.step(`Packaging ${binary.target.name}`)
const staged = await stageCompiledArtifact(
distRoot,
binary.binaryPath,
binary.target,
buildConfig.version,
)
localArtifacts.push(staged)
log.success(`Packaged ${binary.target.id}`)
}
const archiveResults = await archiveArtifacts(localArtifacts)
log.done('CI build completed')
for (const result of archiveResults) {
log.info(`${result.targetId}: ${result.zipPath}`)
log.info(`${binary.target.id}: ${binary.binaryPath}`)
}
return
}

View File

@@ -32,36 +32,6 @@ async function copyServerBinary(
}
}
async function createArtifactRoot(
distRoot: string,
compiledBinaryPath: string,
target: BuildTarget,
): Promise<string> {
const rootDir = artifactRoot(distRoot, target)
await rm(rootDir, { recursive: true, force: true })
await mkdir(rootDir, { recursive: true })
await copyServerBinary(
compiledBinaryPath,
serverDestinationPath(rootDir, target),
target,
)
return rootDir
}
async function finalizeArtifact(
rootDir: string,
target: BuildTarget,
version: string,
): Promise<StagedArtifact> {
const metadataPath = await writeArtifactMetadata(rootDir, target, version)
return {
target,
rootDir,
resourcesDir: join(rootDir, 'resources'),
metadataPath,
}
}
function resolveDestination(rootDir: string, destination: string): string {
const outputPath = join(rootDir, destination)
const relativePath = relative(rootDir, outputPath)
@@ -97,21 +67,25 @@ export async function stageTargetArtifact(
r2: R2Config,
version: string,
): Promise<StagedArtifact> {
const rootDir = await createArtifactRoot(distRoot, compiledBinaryPath, target)
const rootDir = artifactRoot(distRoot, target)
await rm(rootDir, { recursive: true, force: true })
await mkdir(rootDir, { recursive: true })
await copyServerBinary(
compiledBinaryPath,
serverDestinationPath(rootDir, target),
target,
)
for (const rule of rules) {
await stageRule(rootDir, rule, target, client, r2)
}
return finalizeArtifact(rootDir, target, version)
}
export async function stageCompiledArtifact(
distRoot: string,
compiledBinaryPath: string,
target: BuildTarget,
version: string,
): Promise<StagedArtifact> {
const rootDir = await createArtifactRoot(distRoot, compiledBinaryPath, target)
return finalizeArtifact(rootDir, target, version)
const metadataPath = await writeArtifactMetadata(rootDir, target, version)
return {
target,
rootDir,
resourcesDir: join(rootDir, 'resources'),
metadataPath,
}
}

View File

@@ -21,7 +21,7 @@ export interface BuildArgs {
targets: BuildTarget[]
manifestPath: string
upload: boolean
ci: boolean
compileOnly: boolean
}
export interface R2Config {

View File

@@ -402,11 +402,9 @@ def main(
"upload": upload,
}
# Resolve build context (CONFIG mode or DIRECT mode).
# Returns one Context per architecture — single-element for normal
# builds, multi-element when YAML declares `architecture: [x64, arm64]`.
# Resolve build context (CONFIG mode or DIRECT mode)
try:
arch_ctxs = resolve_config(cli_args, config_data)
ctx = resolve_config(cli_args, config_data)
except ValueError as e:
log_error(str(e))
raise typer.Exit(1)
@@ -461,40 +459,20 @@ def main(
os.environ["DEPOT_TOOLS_WIN_TOOLCHAIN"] = "0"
log_info("Set DEPOT_TOOLS_WIN_TOOLCHAIN=0 for Windows build")
# Print build summary using the first context — versions and paths
# are identical across per-arch contexts. Architecture is logged again
# inside the loop below for multi-arch runs.
summary_ctx = arch_ctxs[0]
log_info(f"📍 Root: {root_dir}")
log_info(f"📍 Chromium: {summary_ctx.chromium_src}")
if len(arch_ctxs) > 1:
log_info(
f"📍 Architectures: {[c.architecture for c in arch_ctxs]} (multi-arch loop)"
)
else:
log_info(f"📍 Architecture: {summary_ctx.architecture}")
log_info(f"📍 Build type: {summary_ctx.build_type}")
log_info(f"📍 Semantic version: {summary_ctx.semantic_version}")
log_info(f"📍 Chromium version: {summary_ctx.chromium_version}")
log_info(f"📍 Build offset: {summary_ctx.browseros_build_offset}")
log_info(f"📍 Chromium: {ctx.chromium_src}")
log_info(f"📍 Architecture: {ctx.architecture}")
log_info(f"📍 Build type: {ctx.build_type}")
log_info(f"📍 Output: {ctx.out_dir}")
log_info(f"📍 Semantic version: {ctx.semantic_version}")
log_info(f"📍 Chromium version: {ctx.chromium_version}")
log_info(f"📍 Build offset: {ctx.browseros_build_offset}")
log_info(f"📍 Pipeline: {''.join(pipeline)}")
log_info("=" * 70)
# Set notification context for OS and architecture
os_name = "macOS" if IS_MACOS() else "Windows" if IS_WINDOWS() else "Linux"
set_build_context(os_name, ctx.architecture)
# Execute the pipeline once per architecture. Modules see a normal
# single-arch ctx; the runner is the only thing that knows about the
# multi-arch loop.
for i, arch_ctx in enumerate(arch_ctxs, start=1):
if len(arch_ctxs) > 1:
log_info("\n" + "#" * 70)
log_info(
f"# Architecture {i}/{len(arch_ctxs)}: {arch_ctx.architecture}"
)
log_info(f"# Output: {arch_ctx.out_dir}")
log_info("#" * 70)
set_build_context(os_name, arch_ctx.architecture)
execute_pipeline(
arch_ctx, pipeline, AVAILABLE_MODULES, pipeline_name="build"
)
# Execute pipeline
execute_pipeline(ctx, pipeline, AVAILABLE_MODULES, pipeline_name="build")

View File

@@ -26,13 +26,11 @@ from .context import Context
from .env import EnvConfig
from .utils import get_platform_arch, log_info
VALID_ARCHITECTURES = {"x64", "arm64", "universal"}
def resolve_config(
cli_args: Dict[str, Any],
yaml_config: Optional[Dict[str, Any]] = None,
) -> List[Context]:
) -> Context:
"""Resolve build configuration - single entry point.
Args:
@@ -40,9 +38,7 @@ def resolve_config(
yaml_config: Optional YAML configuration (triggers CONFIG mode)
Returns:
List of fully resolved Context objects. Single-element for the
common single-arch case; multi-element when YAML declares
`architecture: [x64, arm64]` (Linux multi-arch).
Fully resolved Context object
Raises:
ValueError: If required fields missing or invalid
@@ -63,7 +59,7 @@ def resolve_config(
def _resolve_config_mode(
yaml_config: Dict[str, Any], cli_args: Dict[str, Any]
) -> List[Context]:
) -> Context:
"""CONFIG MODE: YAML is base, CLI can override.
Args:
@@ -71,7 +67,7 @@ def _resolve_config_mode(
cli_args: CLI arguments (can override YAML values)
Returns:
List of Contexts. One per architecture when YAML provides a list.
Context with values from YAML, optionally overridden by CLI
Raises:
ValueError: If required fields missing from both YAML and CLI
@@ -98,66 +94,41 @@ def _resolve_config_mode(
f"Expected directory with Chromium source code"
)
# architecture: CLI override > YAML > platform default.
# YAML may be a string OR a list (e.g. [x64, arm64]) — list form runs
# the entire pipeline once per arch.
cli_arch = cli_args.get("arch")
yaml_arch = build_section.get("architecture") or build_section.get("arch")
if cli_arch:
architectures = [cli_arch]
arch_source = "cli"
elif yaml_arch is not None:
architectures = yaml_arch if isinstance(yaml_arch, list) else [yaml_arch]
arch_source = "yaml"
else:
architectures = [get_platform_arch()]
# architecture: CLI override > YAML > platform default
architecture = (
cli_args.get("arch")
or build_section.get("architecture")
or build_section.get("arch")
)
arch_source = "cli" if cli_args.get("arch") else "yaml"
if not architecture:
architecture = get_platform_arch()
arch_source = "default"
log_info(
f"CONFIG MODE: Using platform default architecture: {architectures[0]}"
)
for arch in architectures:
if arch not in VALID_ARCHITECTURES:
raise ValueError(
f"CONFIG MODE: invalid architecture '{arch}'. "
f"Valid: {sorted(VALID_ARCHITECTURES)}"
)
log_info(f"CONFIG MODE: Using platform default architecture: {architecture}")
# build_type: CLI override > YAML > debug
build_type = cli_args.get("build_type") or build_section.get("type", "debug")
build_type_source = "cli" if cli_args.get("build_type") else "yaml"
log_info(f"✓ CONFIG MODE: chromium_src={chromium_src} ({chromium_src_source})")
if len(architectures) > 1:
log_info(
f"✓ CONFIG MODE: architectures={architectures} ({arch_source}, multi-arch loop)"
)
else:
log_info(
f"✓ CONFIG MODE: architecture={architectures[0]} ({arch_source})"
)
log_info(f"✓ CONFIG MODE: architecture={architecture} ({arch_source})")
log_info(f"✓ CONFIG MODE: build_type={build_type} ({build_type_source})")
return [
Context(
chromium_src=chromium_src,
architecture=arch,
build_type=build_type,
)
for arch in architectures
]
return Context(
chromium_src=chromium_src,
architecture=architecture,
build_type=build_type,
)
def _resolve_direct_mode(cli_args: Dict[str, Any]) -> List[Context]:
def _resolve_direct_mode(cli_args: Dict[str, Any]) -> Context:
"""DIRECT MODE: CLI > Env > Defaults.
Args:
cli_args: CLI arguments (None if not provided by user)
Returns:
Single-element list with the resolved Context. DIRECT mode is
always single-arch (CLI --arch is a scalar).
Context with resolved values
Raises:
ValueError: If chromium_src not provided
@@ -189,12 +160,6 @@ def _resolve_direct_mode(cli_args: Dict[str, Any]) -> List[Context]:
architecture = get_platform_arch()
log_info(f"DIRECT MODE: Using platform default architecture: {architecture}")
if architecture not in VALID_ARCHITECTURES:
raise ValueError(
f"DIRECT MODE: invalid architecture '{architecture}'. "
f"Valid: {sorted(VALID_ARCHITECTURES)}"
)
# build_type: CLI > Default
build_type = cli_args.get("build_type") or "debug"
@@ -202,13 +167,11 @@ def _resolve_direct_mode(cli_args: Dict[str, Any]) -> List[Context]:
log_info(f"✓ DIRECT MODE: architecture={architecture} (cli/env/default)")
log_info(f"✓ DIRECT MODE: build_type={build_type} (cli/default)")
return [
Context(
chromium_src=chromium_src,
architecture=architecture,
build_type=build_type,
)
]
return Context(
chromium_src=chromium_src,
architecture=architecture,
build_type=build_type,
)
def resolve_pipeline(

View File

@@ -3,10 +3,7 @@
# This config packages an already-built Linux application.
# Use this when you have a pre-built app and only need to package it.
#
# Expects: out/Default_<arch>/browseros
# Invoke with:
# browseros build --config build/config/package.linux.yaml --arch x64
# browseros build --config build/config/package.linux.yaml --arch arm64
# Expects: out/Default/chrome (Linux binary)
#
# Environment Variables:
# Use !env tag to reference environment variables:
@@ -14,6 +11,7 @@
build:
type: release
architecture: x64 # Linux x64
gn_flags:
file: build/config/gn/flags.linux.release.gn

View File

@@ -1,24 +1,17 @@
# BrowserOS Linux Release Build Configuration
#
# Pinned to arm64-only to validate the cross-compile sysroot bootstrap
# end-to-end on a Linux x64 host. Flip back to `[x64, arm64]` once arm64
# is green.
#
# Run:
# browseros build --config build/config/release.linux.yaml
#
# Environment Variables:
# Use !env tag to reference environment variables:
# Example: chromium_src: !env CHROMIUM_SRC
build:
type: release
architecture: arm64
architecture: x64 # Linux x64
gn_flags:
file: build/config/gn/flags.linux.release.gn
# Explicit module execution order. Runs once per architecture above.
# Explicit module execution order
modules:
# Phase 1: Setup
- clean

View File

@@ -17,64 +17,10 @@ from ...common.utils import (
run_command,
safe_rmtree,
join_paths,
get_platform_arch,
IS_LINUX,
)
from ...common.notify import get_notifier, COLOR_GREEN
# Target-arch packaging metadata. These describe the artifact we're
# producing, not the build machine. `appimage_arch` is passed to
# appimagetool via the ARCH env var; `deb_arch` is written into the
# .deb control file.
LINUX_ARCHITECTURE_CONFIG = {
"x64": {
"appimage_arch": "x86_64",
"deb_arch": "amd64",
},
"arm64": {
"appimage_arch": "aarch64",
"deb_arch": "arm64",
},
}
# Host-arch tool selection. appimagetool is a normal binary that runs on
# the build machine — when cross-compiling arm64 from an x64 host, we
# still need the x86_64 tool to actually execute. Keyed on
# get_platform_arch() (BUILD machine arch), NOT ctx.architecture.
LINUX_HOST_APPIMAGETOOL = {
"x64": (
"appimagetool-x86_64.AppImage",
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage",
),
"arm64": (
"appimagetool-aarch64.AppImage",
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage",
),
}
def get_linux_architecture_config(architecture: str) -> dict[str, str]:
config = LINUX_ARCHITECTURE_CONFIG.get(architecture)
if not config:
supported = ", ".join(sorted(LINUX_ARCHITECTURE_CONFIG))
raise ValueError(
f"Unsupported Linux architecture: {architecture}. Supported: {supported}"
)
return config
def get_host_appimagetool() -> tuple[str, str]:
"""Return (filename, url) for the appimagetool binary that runs on
the current build machine. Critical for cross-compile correctness."""
host_arch = get_platform_arch()
tool = LINUX_HOST_APPIMAGETOOL.get(host_arch)
if not tool:
supported = ", ".join(sorted(LINUX_HOST_APPIMAGETOOL))
raise ValueError(
f"No appimagetool binary for host arch '{host_arch}'. Supported: {supported}"
)
return tool
class LinuxPackageModule(CommandModule):
produces = ["appimage", "deb"]
@@ -84,10 +30,6 @@ class LinuxPackageModule(CommandModule):
def validate(self, ctx: Context) -> None:
if not IS_LINUX():
raise ValidationError("Linux packaging requires Linux")
try:
get_linux_architecture_config(ctx.architecture)
except ValueError as exc:
raise ValidationError(str(exc)) from exc
out_dir = join_paths(ctx.chromium_src, ctx.out_dir)
chrome_binary = join_paths(out_dir, ctx.BROWSEROS_APP_NAME)
@@ -131,7 +73,7 @@ class LinuxPackageModule(CommandModule):
artifacts.append(deb_path.name)
notifier.notify(
"📦 Package Created",
"Linux packages created successfully",
f"Linux packages created successfully",
{
"Artifacts": ", ".join(artifacts),
"Version": ctx.semantic_version,
@@ -342,30 +284,25 @@ export CHROME_WRAPPER="${{THIS}}"
def download_appimagetool(ctx: Context) -> Optional[Path]:
"""Download the appimagetool binary that runs on the build machine.
Note: this is keyed on the HOST arch, not ctx.architecture. When
cross-compiling arm64 packages from an x64 host, we still need the
x86_64 appimagetool because the tool executes locally; the target
arch is communicated via the ARCH env var in create_appimage().
"""
"""Download appimagetool if not available"""
tool_dir = Path(join_paths(ctx.root_dir, "build", "tools"))
tool_dir.mkdir(exist_ok=True)
tool_filename, url = get_host_appimagetool()
tool_path = Path(join_paths(tool_dir, tool_filename))
tool_path = Path(join_paths(tool_dir, "appimagetool-x86_64.AppImage"))
if tool_path.exists():
log_info(f"✓ appimagetool already available ({tool_filename})")
log_info("✓ appimagetool already available")
return tool_path
log_info(f"📥 Downloading {tool_filename}...")
log_info("📥 Downloading appimagetool...")
url = "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
cmd = ["wget", "-O", str(tool_path), url]
result = run_command(cmd, check=False)
if result.returncode == 0:
tool_path.chmod(0o755)
log_success(f"✓ Downloaded {tool_filename}")
log_success("✓ Downloaded appimagetool")
return tool_path
else:
log_error("Failed to download appimagetool")
@@ -375,7 +312,6 @@ def download_appimagetool(ctx: Context) -> Optional[Path]:
def create_appimage(ctx: Context, appdir: Path, output_path: Path) -> bool:
"""Create AppImage from AppDir"""
log_info("📦 Creating AppImage...")
arch_config = get_linux_architecture_config(ctx.architecture)
# Download appimagetool if needed
appimagetool = download_appimagetool(ctx)
@@ -383,7 +319,7 @@ def create_appimage(ctx: Context, appdir: Path, output_path: Path) -> bool:
return False
# Set architecture environment variable (required by appimagetool)
arch = arch_config["appimage_arch"]
arch = "x86_64" if ctx.architecture == "x64" else "aarch64"
# Create AppImage with ARCH env var set for this command only
cmd = [
@@ -448,7 +384,7 @@ def create_control_file(ctx: Context, debian_dir: Path) -> None:
version = version.lstrip("v").replace(" ", "").replace("_", ".")
# Architecture mapping
deb_arch = get_linux_architecture_config(ctx.architecture)["deb_arch"]
deb_arch = "amd64" if ctx.architecture == "x64" else "arm64"
control_content = f"""Package: browseros
Version: {version}
@@ -717,9 +653,7 @@ def package_appimage(ctx: Context, package_dir: Path) -> Optional[Path]:
"""
log_info("🖼️ Building AppImage...")
appdir = Path(
join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}-{ctx.architecture}.AppDir")
)
appdir = Path(join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}.AppDir"))
if appdir.exists():
safe_rmtree(appdir)
@@ -749,9 +683,7 @@ def package_deb(ctx: Context, package_dir: Path) -> Optional[Path]:
"""
log_info("📦 Building .deb package...")
debdir = Path(
join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}_{ctx.architecture}_deb")
)
debdir = Path(join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}_deb"))
if debdir.exists():
safe_rmtree(debdir)
@@ -771,8 +703,6 @@ def package_deb(ctx: Context, package_dir: Path) -> Optional[Path]:
return output_path
return None
def package_universal(contexts: List[Context]) -> bool:
"""Linux doesn't support universal binaries"""
log_warning("Universal binaries are not supported on Linux")

View File

@@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""Tests for Linux packaging architecture helpers."""
import unittest
from unittest.mock import patch
from build.modules.package.linux import (
LINUX_HOST_APPIMAGETOOL,
get_host_appimagetool,
get_linux_architecture_config,
)
class LinuxArchitectureConfigTest(unittest.TestCase):
def test_returns_x64_packaging_config(self) -> None:
config = get_linux_architecture_config("x64")
self.assertEqual(config["appimage_arch"], "x86_64")
self.assertEqual(config["deb_arch"], "amd64")
def test_returns_arm64_packaging_config(self) -> None:
config = get_linux_architecture_config("arm64")
self.assertEqual(config["appimage_arch"], "aarch64")
self.assertEqual(config["deb_arch"], "arm64")
def test_rejects_unsupported_architecture(self) -> None:
with self.assertRaisesRegex(ValueError, "Unsupported Linux architecture"):
get_linux_architecture_config("universal")
class HostAppImageToolTest(unittest.TestCase):
"""The appimagetool binary must match the BUILD machine's arch, not
the target arch — otherwise cross-compiling arm64 packages from an x64
host fails because the aarch64 tool can't execute on x64."""
def test_x64_host_picks_x86_64_tool(self) -> None:
with patch(
"build.modules.package.linux.get_platform_arch", return_value="x64"
):
filename, url = get_host_appimagetool()
self.assertEqual(filename, "appimagetool-x86_64.AppImage")
self.assertIn("x86_64", url)
def test_arm64_host_picks_aarch64_tool(self) -> None:
with patch(
"build.modules.package.linux.get_platform_arch", return_value="arm64"
):
filename, url = get_host_appimagetool()
self.assertEqual(filename, "appimagetool-aarch64.AppImage")
self.assertIn("aarch64", url)
def test_host_lookup_independent_of_target(self) -> None:
# Both architectures must be present in the host lookup so cross
# builds work in either direction.
self.assertIn("x64", LINUX_HOST_APPIMAGETOOL)
self.assertIn("arm64", LINUX_HOST_APPIMAGETOOL)
if __name__ == "__main__":
unittest.main()

View File

@@ -6,6 +6,7 @@ from datetime import datetime
from typing import Dict, List, Optional
from ...common.env import EnvConfig
from ...common.utils import log_warning
from ..storage import get_release_json, get_r2_client, BOTO3_AVAILABLE
PLATFORMS = ["macos", "win", "linux"]
@@ -23,8 +24,6 @@ DOWNLOAD_PATH_MAPPING = {
"linux": {
"x64_appimage": "download/BrowserOS.AppImage",
"x64_deb": "download/BrowserOS.deb",
"arm64_appimage": "download/BrowserOS-arm64.AppImage",
"arm64_deb": "download/BrowserOS-arm64.deb",
},
}

View File

@@ -1,19 +1,9 @@
#!/usr/bin/env python3
"""Build configuration module for BrowserOS build system"""
import sys
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
from ...common.utils import (
run_command,
log_info,
log_warning,
log_success,
join_paths,
IS_LINUX,
IS_WINDOWS,
)
from ...common.utils import run_command, log_info, log_success, join_paths, IS_WINDOWS
class ConfigureModule(CommandModule):
@@ -35,16 +25,6 @@ class ConfigureModule(CommandModule):
def execute(self, ctx: Context) -> None:
log_info(f"\n⚙️ Configuring {ctx.build_type} build for {ctx.architecture}...")
# Linux: ensure the target-arch Debian sysroot is installed before
# `gn gen`. sysroot.gni asserts on missing sysroots, and relying on
# `gclient sync` DEPS hooks is fragile — the hook only fires when
# .gclient declared the right `target_cpus` *before* sync, which
# isn't guaranteed for chromium_src checkouts that predate
# cross-arch support. install-sysroot.py is idempotent and fast,
# so call it unconditionally for the target arch.
if IS_LINUX():
self._ensure_linux_sysroot(ctx)
out_path = join_paths(ctx.chromium_src, ctx.out_dir)
out_path.mkdir(parents=True, exist_ok=True)
@@ -63,26 +43,3 @@ class ConfigureModule(CommandModule):
run_command(gn_args, cwd=ctx.chromium_src)
log_success("Build configured")
def _ensure_linux_sysroot(self, ctx: Context) -> None:
install_script = (
ctx.chromium_src / "build" / "linux" / "sysroot_scripts" / "install-sysroot.py"
)
if not install_script.exists():
log_warning(
f"⚠️ install-sysroot.py not found at {install_script}; "
f"skipping sysroot bootstrap. gn gen will fail if the "
f"{ctx.architecture} sysroot is missing."
)
return
# install-sysroot.py accepts our arch names directly: it translates
# `x64`→`amd64` internally via ARCH_TRANSLATIONS, and `arm64` is a
# valid pass-through value.
log_info(
f"📦 Ensuring Linux sysroot for {ctx.architecture} (idempotent)..."
)
run_command(
[sys.executable, str(install_script), f"--arch={ctx.architecture}"],
cwd=ctx.chromium_src,
)

View File

@@ -1,24 +1,12 @@
#!/usr/bin/env python3
"""Git operations module for BrowserOS build system"""
import re
import subprocess
import tarfile
import urllib.request
from typing import List
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
from ...common.utils import (
run_command,
log_info,
log_warning,
log_error,
log_success,
IS_LINUX,
IS_WINDOWS,
safe_rmtree,
)
from ...common.utils import run_command, log_info, log_error, log_success, IS_WINDOWS, safe_rmtree
class GitSetupModule(CommandModule):
@@ -44,12 +32,6 @@ class GitSetupModule(CommandModule):
log_info(f"🔀 Checking out tag: {ctx.chromium_version}")
run_command(["git", "checkout", f"tags/{ctx.chromium_version}"], cwd=ctx.chromium_src)
# On Linux, depot_tools fetches per-arch sysroots automatically when
# `.gclient` declares `target_cpus`. Ensure both x64 and arm64 are
# listed before sync so cross-compilation just works on x64 hosts.
if IS_LINUX():
self._ensure_gclient_target_cpus(ctx, ["x64", "arm64"])
log_info("📥 Syncing dependencies (this may take a while)...")
if IS_WINDOWS():
run_command(["gclient.bat", "sync", "-D", "--no-history", "--shallow"], cwd=ctx.chromium_src)
@@ -58,49 +40,6 @@ class GitSetupModule(CommandModule):
log_success("Git setup complete")
def _ensure_gclient_target_cpus(self, ctx: Context, required: List[str]) -> None:
"""Idempotently add `target_cpus` to .gclient so depot_tools fetches
the matching Linux sysroots for cross-compilation.
depot_tools convention: .gclient lives one directory above
chromium_src (i.e. ../.gclient). It is a Python file with a list
of solution dicts followed by optional top-level assignments.
We append a `target_cpus = [...]` line if missing or merge in any
archs that aren't already present.
"""
gclient_path = ctx.chromium_src.parent / ".gclient"
if not gclient_path.exists():
log_warning(
f"⚠️ .gclient not found at {gclient_path}; "
f"skipping target_cpus bootstrap. "
f"Cross-arch builds may fail until you run `fetch chromium`."
)
return
content = gclient_path.read_text()
match = re.search(r"^\s*target_cpus\s*=\s*\[([^\]]*)\]", content, re.MULTILINE)
if match:
existing = re.findall(r"['\"]([^'\"]+)['\"]", match.group(1))
missing = [arch for arch in required if arch not in existing]
if not missing:
log_info(f"✓ .gclient target_cpus already includes {required}")
return
merged = sorted(set(existing) | set(required))
new_line = f"target_cpus = {merged!r}"
content = (
content[: match.start()] + new_line + content[match.end() :]
)
log_info(
f"📝 Updating .gclient target_cpus: {existing}{merged}"
)
else:
new_line = f"\ntarget_cpus = {required!r}\n"
content = content.rstrip() + "\n" + new_line
log_info(f"📝 Adding target_cpus = {required} to .gclient")
gclient_path.write_text(content)
def _verify_tag_exists(self, ctx: Context) -> None:
result = subprocess.run(
["git", "tag", "-l", ctx.chromium_version],

View File

@@ -4,7 +4,7 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, cast
from typing import Dict, List, Optional, Tuple
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
@@ -21,7 +21,6 @@ from ...common.notify import get_notifier, COLOR_GREEN
from .r2 import (
BOTO3_AVAILABLE,
get_r2_client,
get_release_json,
upload_file_to_r2,
)
@@ -59,10 +58,7 @@ class UploadModule(CommandModule):
log_info("\nUploading package artifacts to R2...")
extra_metadata = {}
sparkle_signatures = cast(
Optional[dict[str, tuple[str, int]]],
ctx.artifacts.get("sparkle_signatures"),
)
sparkle_signatures = ctx.artifacts.get("sparkle_signatures")
if sparkle_signatures:
for filename, (sig, length) in sparkle_signatures.items():
extra_metadata[filename] = {
@@ -124,36 +120,6 @@ def generate_release_json(
return release_data
def merge_release_metadata(existing: Optional[Dict], new: Dict) -> Dict:
if not existing:
return new
merged = dict(existing)
merged.update({key: value for key, value in new.items() if key != "artifacts"})
artifacts = dict(existing.get("artifacts", {}))
artifacts.update(new.get("artifacts", {}))
merged["artifacts"] = artifacts
return merged
def _get_linux_artifact_key(filename: str) -> Optional[str]:
lower = filename.lower()
if ".appimage" in lower:
if "arm64" in lower or "aarch64" in lower:
return "arm64_appimage"
if "x64" in lower or "x86_64" in lower:
return "x64_appimage"
elif ".deb" in lower:
if "arm64" in lower or "aarch64" in lower:
return "arm64_deb"
if "amd64" in lower or "x64" in lower or "x86_64" in lower:
return "x64_deb"
return None
def _get_artifact_key(filename: str, platform: str) -> str:
"""Get artifact key name from filename
@@ -181,10 +147,10 @@ def _get_artifact_key(filename: str, platform: str) -> str:
return "x64_zip"
elif platform == "linux":
artifact_key = _get_linux_artifact_key(filename)
if artifact_key:
return artifact_key
log_warning(f"Unrecognized Linux artifact name: {filename}; using stem key")
if ".appimage" in lower:
return "x64_appimage"
elif ".deb" in lower:
return "x64_deb"
return Path(filename).stem
@@ -215,7 +181,7 @@ def detect_artifacts(ctx: Context) -> List[Path]:
def upload_release_artifacts(
ctx: Context,
extra_metadata: Optional[Dict[str, Dict[str, Any]]] = None,
extra_metadata: Optional[Dict[str, Dict[str, any]]] = None,
) -> Tuple[bool, Optional[Dict]]:
"""Upload release artifacts to R2 and generate release.json
@@ -274,13 +240,6 @@ def upload_release_artifacts(
artifact_metadata.append(metadata)
release_data = generate_release_json(ctx, artifact_metadata, platform)
if platform == "linux":
# Linux x64 and arm64 release jobs must be sequenced. A parallel
# fetch-merge-upload flow can still race and drop one architecture.
existing_release_data = get_release_json(
ctx.get_semantic_version(), platform, env
)
release_data = merge_release_metadata(existing_release_data, release_data)
release_json_path = ctx.get_dist_dir() / "release.json"
release_json_path.write_text(json.dumps(release_data, indent=2))
@@ -289,7 +248,7 @@ def upload_release_artifacts(
return False, None
log_success(f"\nSuccessfully uploaded {len(artifacts)} artifact(s) to R2")
log_info("\nRelease metadata:")
log_info(f"\nRelease metadata:")
log_info(f" Version: {release_data['version']}")
if platform == "macos":
log_info(f" Sparkle version: {release_data.get('sparkle_version', 'N/A')}")

View File

@@ -1,85 +0,0 @@
#!/usr/bin/env python3
"""Tests for release artifact upload metadata helpers."""
import unittest
from build.modules.storage.upload import _get_artifact_key, merge_release_metadata
class UploadMetadataTest(unittest.TestCase):
def test_linux_x64_artifacts_use_x64_keys(self) -> None:
self.assertEqual(
_get_artifact_key("BrowserOS_v1.2.3_x64.AppImage", "linux"),
"x64_appimage",
)
self.assertEqual(
_get_artifact_key("BrowserOS_v1.2.3_amd64.deb", "linux"),
"x64_deb",
)
def test_linux_arm64_artifacts_use_arm64_keys(self) -> None:
self.assertEqual(
_get_artifact_key("BrowserOS_v1.2.3_arm64.AppImage", "linux"),
"arm64_appimage",
)
self.assertEqual(
_get_artifact_key("BrowserOS_v1.2.3_arm64.deb", "linux"),
"arm64_deb",
)
self.assertEqual(
_get_artifact_key("BrowserOS_v1.2.3_aarch64.deb", "linux"),
"arm64_deb",
)
def test_merge_release_metadata_preserves_existing_artifacts(self) -> None:
existing = {
"platform": "linux",
"version": "1.2.3",
"build_date": "old",
"artifacts": {
"x64_appimage": {"filename": "BrowserOS_v1.2.3_x64.AppImage"},
"x64_deb": {"filename": "BrowserOS_v1.2.3_amd64.deb"},
},
}
new = {
"platform": "linux",
"version": "1.2.3",
"build_date": "new",
"artifacts": {
"arm64_appimage": {"filename": "BrowserOS_v1.2.3_arm64.AppImage"},
"arm64_deb": {"filename": "BrowserOS_v1.2.3_arm64.deb"},
},
}
merged = merge_release_metadata(existing, new)
self.assertEqual(merged["build_date"], "new")
self.assertEqual(
sorted(merged["artifacts"]),
["arm64_appimage", "arm64_deb", "x64_appimage", "x64_deb"],
)
def test_merge_release_metadata_overwrites_matching_artifact_keys(self) -> None:
existing = {
"platform": "linux",
"version": "1.2.3",
"artifacts": {
"x64_appimage": {"filename": "old.AppImage", "size": 1},
},
}
new = {
"platform": "linux",
"version": "1.2.3",
"artifacts": {
"x64_appimage": {"filename": "new.AppImage", "size": 2},
},
}
merged = merge_release_metadata(existing, new)
self.assertEqual(merged["artifacts"]["x64_appimage"]["filename"], "new.AppImage")
self.assertEqual(merged["artifacts"]["x64_appimage"]["size"], 2)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,9 +1,9 @@
diff --git a/chrome/browser/browseros/extensions/browseros_extension_loader.cc b/chrome/browser/browseros/extensions/browseros_extension_loader.cc
new file mode 100644
index 0000000000000..fdb6be443f25b
index 0000000000000..e61b45d08b7e2
--- /dev/null
+++ b/chrome/browser/browseros/extensions/browseros_extension_loader.cc
@@ -0,0 +1,269 @@
@@ -0,0 +1,226 @@
+// Copyright 2024 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
@@ -89,53 +89,10 @@ index 0000000000000..fdb6be443f25b
+ extension_ids_.merge(result.extension_ids);
+ last_config_ = std::move(result.config);
+
+ base::DictValue prefs_to_load = std::move(result.prefs);
+
+ if (prefs_to_load.empty()) {
+ LOG(WARNING) << "browseros: Install returned empty prefs, "
+ << "reconstructing from installed extensions";
+ prefs_to_load = ReconstructPrefsFromInstalledExtensions();
+ LOG(INFO) << "browseros: Reconstructed prefs for "
+ << prefs_to_load.size() << " installed extensions";
+ }
+
+ LoadFinished(std::move(prefs_to_load));
+ LoadFinished(std::move(result.prefs));
+ OnStartupComplete(result.from_bundled);
+}
+
+base::DictValue
+BrowserOSExtensionLoader::ReconstructPrefsFromInstalledExtensions() {
+ base::DictValue prefs;
+
+ extensions::ExtensionRegistry* registry =
+ extensions::ExtensionRegistry::Get(profile_);
+ if (!registry) {
+ return prefs;
+ }
+
+ const std::string update_url =
+ base::FeatureList::IsEnabled(features::kBrowserOsAlphaFeatures)
+ ? kBrowserOSAlphaUpdateUrl
+ : kBrowserOSUpdateUrl;
+
+ for (const std::string& id : GetBrowserOSExtensionIds()) {
+ const extensions::Extension* ext = registry->GetInstalledExtension(id);
+ if (!ext) {
+ continue;
+ }
+
+ base::DictValue ext_pref;
+ ext_pref.Set(extensions::ExternalProviderImpl::kExternalUpdateUrl,
+ update_url);
+ prefs.Set(id, std::move(ext_pref));
+
+ LOG(INFO) << "browseros: Reconstructed pref for installed extension "
+ << id << " v" << ext->version().GetString();
+ }
+
+ return prefs;
+}
+
+const base::FilePath BrowserOSExtensionLoader::GetBaseCrxFilePath() {
+ return bundled_crx_base_path_;
+}

View File

@@ -1,9 +1,9 @@
diff --git a/chrome/browser/browseros/extensions/browseros_extension_loader.h b/chrome/browser/browseros/extensions/browseros_extension_loader.h
new file mode 100644
index 0000000000000..ea2c856556f5f
index 0000000000000..2a14e9068156e
--- /dev/null
+++ b/chrome/browser/browseros/extensions/browseros_extension_loader.h
@@ -0,0 +1,86 @@
@@ -0,0 +1,81 @@
+// Copyright 2024 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
@@ -62,11 +62,6 @@ index 0000000000000..ea2c856556f5f
+ // Convergence point for both startup paths.
+ void OnStartupComplete(bool from_bundled);
+
+ // Reconstructs minimal prefs from already-installed BrowserOS extensions.
+ // Used as a fallback when both bundled CRX and remote config fail,
+ // preventing orphan detection from uninstalling existing extensions.
+ base::DictValue ReconstructPrefsFromInstalledExtensions();
+
+ // Installs remote extensions immediately via PendingExtensionManager + updater.
+ void InstallRemoteExtensionsNow(base::DictValue config);
+

View File

@@ -1,6 +1,6 @@
diff --git a/chrome/browser/browseros/extensions/browseros_extension_maintainer.cc b/chrome/browser/browseros/extensions/browseros_extension_maintainer.cc
new file mode 100644
index 0000000000000..5804d54696e8f
index 0000000000000..bb33ae5d3b156
--- /dev/null
+++ b/chrome/browser/browseros/extensions/browseros_extension_maintainer.cc
@@ -0,0 +1,395 @@

View File

@@ -1,13 +1,8 @@
diff --git a/chrome/browser/devtools/protocol/browser_handler.cc b/chrome/browser/devtools/protocol/browser_handler.cc
index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
index 30bd52d09c3fc..33c7d6d8455fc 100644
--- a/chrome/browser/devtools/protocol/browser_handler.cc
+++ b/chrome/browser/devtools/protocol/browser_handler.cc
@@ -4,23 +4,37 @@
#include "chrome/browser/devtools/protocol/browser_handler.h"
+#include <algorithm>
#include <set>
@@ -8,19 +8,32 @@
#include <vector>
#include "base/functional/bind.h"
@@ -40,7 +35,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/devtools_agent_host.h"
@@ -30,10 +44,21 @@
@@ -30,10 +43,21 @@
#include "ui/gfx/image/image.h"
#include "ui/gfx/image/image_png_rep.h"
@@ -62,7 +57,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
BrowserWindow* GetBrowserWindow(int window_id) {
BrowserWindow* result = nullptr;
ForEachCurrentBrowserWindowInterfaceOrderedByActivation(
@@ -72,17 +97,419 @@ std::unique_ptr<protocol::Browser::Bounds> GetBrowserWindowBounds(
@@ -72,17 +96,411 @@ std::unique_ptr<protocol::Browser::Bounds> GetBrowserWindowBounds(
.Build();
}
@@ -442,14 +437,6 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
+ out_indices->push_back(found_index);
+ }
+
+ if (!(*out_bwi)->GetTabStripModel()->SupportsTabGroups()) {
+ return Response::ServerError("Tab grouping not supported for this window");
+ }
+
+ std::ranges::sort(*out_indices);
+ out_indices->erase(std::ranges::unique(*out_indices).begin(),
+ out_indices->end());
+
+ return Response::Success();
+}
+
@@ -484,7 +471,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
Response BrowserHandler::GetWindowForTarget(
std::optional<std::string> target_id,
@@ -120,6 +547,65 @@ Response BrowserHandler::GetWindowForTarget(
@@ -120,6 +538,65 @@ Response BrowserHandler::GetWindowForTarget(
return Response::Success();
}
@@ -550,7 +537,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
Response BrowserHandler::GetWindowBounds(
int window_id,
std::unique_ptr<protocol::Browser::Bounds>* out_bounds) {
@@ -297,3 +783,909 @@ protocol::Response BrowserHandler::AddPrivacySandboxEnrollmentOverride(
@@ -297,3 +774,910 @@ protocol::Response BrowserHandler::AddPrivacySandboxEnrollmentOverride(
net::SchemefulSite(url_to_add));
return Response::Success();
}
@@ -1460,3 +1447,4 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
+bool BrowserHandler::IsHiddenWindow(int window_id) const {
+ return hidden_window_ids_.contains(window_id);
+}
+

View File

@@ -1,123 +0,0 @@
diff --git a/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc b/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc
index e57b0883b725f..58bfa8d8f5412 100644
--- a/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc
+++ b/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc
@@ -20,6 +20,7 @@
#include "base/test/test_switches.h"
#include "base/test/values_test_util.h"
#include "base/threading/thread_restrictions.h"
+#include "base/time/time.h"
#include "base/values.h"
#include "build/build_config.h"
#include "chrome/browser/apps/app_service/app_service_proxy.h"
@@ -30,6 +31,7 @@
#include "chrome/browser/data_saver/data_saver.h"
#include "chrome/browser/devtools/devtools_window.h"
#include "chrome/browser/devtools/protocol/devtools_protocol_test_support.h"
+#include "chrome/browser/history/history_service_factory.h"
#include "chrome/browser/preloading/preloading_prefs.h"
#include "chrome/browser/privacy_sandbox/privacy_sandbox_attestations/privacy_sandbox_attestations_mixin.h"
#include "chrome/browser/profiles/profile.h"
@@ -43,6 +45,8 @@
#include "components/content_settings/core/browser/cookie_settings.h"
#include "components/content_settings/core/common/pref_names.h"
#include "components/custom_handlers/protocol_handler_registry.h"
+#include "components/history/core/browser/history_service.h"
+#include "components/history/core/test/history_service_test_util.h"
#include "components/infobars/content/content_infobar_manager.h"
#include "components/infobars/core/infobar.h"
#include "components/infobars/core/infobar_delegate.h"
@@ -2202,6 +2206,93 @@ IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest,
SendCommandSync("Target.getTargets");
EXPECT_EQ(2u, result()->FindList("targetInfos")->size());
}
+
+IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest,
+ CreateTabGroupAcceptsUnsortedTabIds) {
+ AttachToBrowserTarget();
+
+ ASSERT_EQ(1, browser()->tab_strip_model()->count());
+
+ base::DictValue params;
+ params.Set("url", "about:blank");
+ params.Set("background", true);
+ ASSERT_TRUE(SendCommandSync("Browser.createTab", params.Clone()));
+ ASSERT_TRUE(SendCommandSync("Browser.createTab", std::move(params)));
+
+ const base::DictValue* tabs_result = SendCommandSync("Browser.getTabs");
+ ASSERT_TRUE(tabs_result);
+ const base::ListValue* tabs = tabs_result->FindList("tabs");
+ ASSERT_TRUE(tabs);
+ ASSERT_EQ(3u, tabs->size());
+
+ std::vector<int> tab_ids;
+ tab_ids.reserve(tabs->size());
+ for (const auto& tab : *tabs) {
+ tab_ids.push_back(*tab.GetDict().FindInt("tabId"));
+ }
+
+ base::ListValue unsorted_tab_ids;
+ unsorted_tab_ids.Append(tab_ids[2]);
+ unsorted_tab_ids.Append(tab_ids[0]);
+
+ base::DictValue create_group_params;
+ create_group_params.Set("tabIds", std::move(unsorted_tab_ids));
+ create_group_params.Set("title", "Unsorted");
+
+ const base::DictValue* create_group_result =
+ SendCommandSync("Browser.createTabGroup", std::move(create_group_params));
+ ASSERT_TRUE(create_group_result);
+ ASSERT_FALSE(error());
+
+ const base::DictValue* group = create_group_result->FindDict("group");
+ ASSERT_TRUE(group);
+ const base::ListValue* grouped_tab_ids = group->FindList("tabIds");
+ ASSERT_TRUE(grouped_tab_ids);
+ ASSERT_EQ(2u, grouped_tab_ids->size());
+ EXPECT_EQ(tab_ids[0], *grouped_tab_ids->front().GetIfInt());
+ EXPECT_EQ(tab_ids[2], *grouped_tab_ids->back().GetIfInt());
+ EXPECT_EQ("Unsorted", *group->FindString("title"));
+}
+
+IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest, HistorySearchUsesVisitTime) {
+ AttachToBrowserTarget();
+
+ history::HistoryService* history_service =
+ HistoryServiceFactory::GetForProfile(browser()->profile(),
+ ServiceAccessType::EXPLICIT_ACCESS);
+ ui_test_utils::WaitForHistoryToLoad(history_service);
+
+ const GURL url("https://history-timestamp-test.example/path");
+ const base::Time older_visit = base::Time::Now() - base::Days(2);
+ const base::Time newer_visit = base::Time::Now() - base::Hours(1);
+
+ history_service->AddPage(url, older_visit, history::SOURCE_BROWSED);
+ history_service->AddPage(url, newer_visit, history::SOURCE_BROWSED);
+ history::BlockUntilHistoryProcessesPendingRequests(history_service);
+
+ base::DictValue search_params;
+ search_params.Set("query", "");
+ search_params.Set(
+ "startTime",
+ (older_visit - base::Minutes(1)).InMillisecondsFSinceUnixEpoch());
+ search_params.Set(
+ "endTime",
+ (newer_visit - base::Minutes(1)).InMillisecondsFSinceUnixEpoch());
+
+ const base::DictValue* search_result =
+ SendCommandSync("History.search", std::move(search_params));
+ ASSERT_TRUE(search_result);
+ ASSERT_FALSE(error());
+
+ const base::ListValue* entries = search_result->FindList("entries");
+ ASSERT_TRUE(entries);
+ ASSERT_EQ(1u, entries->size());
+
+ const base::DictValue& entry = entries->front().GetDict();
+ EXPECT_EQ(url.spec(), *entry.FindString("url"));
+ EXPECT_EQ(older_visit.InMillisecondsFSinceUnixEpoch(),
+ *entry.FindDouble("lastVisitTime"));
+}
#endif // !BUILDFLAG(IS_ANDROID)
#if !BUILDFLAG(IS_ANDROID)

View File

@@ -1,6 +1,6 @@
diff --git a/chrome/browser/devtools/protocol/history_handler.cc b/chrome/browser/devtools/protocol/history_handler.cc
new file mode 100644
index 0000000000000..4087a679a527f
index 0000000000000..689f6e900a968
--- /dev/null
+++ b/chrome/browser/devtools/protocol/history_handler.cc
@@ -0,0 +1,188 @@
@@ -36,7 +36,7 @@ index 0000000000000..4087a679a527f
+ .SetId(base::NumberToString(result.id()))
+ .SetUrl(result.url().spec())
+ .SetTitle(base::UTF16ToUTF8(result.title()))
+ .SetLastVisitTime(result.visit_time().InMillisecondsFSinceUnixEpoch())
+ .SetLastVisitTime(result.last_visit().InMillisecondsFSinceUnixEpoch())
+ .SetVisitCount(result.visit_count())
+ .SetTypedCount(result.typed_count())
+ .Build();

View File

@@ -1,5 +1,5 @@
diff --git a/chrome/browser/extensions/chrome_extension_registrar_delegate.cc b/chrome/browser/extensions/chrome_extension_registrar_delegate.cc
index adfb4e4d49fa4..409e26fa1cb1b 100644
index 6eec0585e8925..55c2a73647527 100644
--- a/chrome/browser/extensions/chrome_extension_registrar_delegate.cc
+++ b/chrome/browser/extensions/chrome_extension_registrar_delegate.cc
@@ -12,6 +12,7 @@
@@ -10,26 +10,7 @@ index adfb4e4d49fa4..409e26fa1cb1b 100644
#include "chrome/browser/extensions/component_loader.h"
#include "chrome/browser/extensions/corrupted_extension_reinstaller.h"
#include "chrome/browser/extensions/data_deleter.h"
@@ -256,7 +257,17 @@ void ChromeExtensionRegistrarDelegate::PostUninstallExtension(
}
}
- DataDeleter::StartDeleting(profile_, extension.get(), subtask_done_callback);
+ // Preserve chrome.storage.local data for BrowserOS extensions. These may be
+ // transiently uninstalled during update cycles (e.g., when both bundled CRX
+ // and remote config fail on startup). User configuration must survive.
+ if (browseros::IsBrowserOSExtension(extension->id())) {
+ LOG(INFO) << "browseros: Preserving storage for extension "
+ << extension->id();
+ subtask_done_callback.Run();
+ } else {
+ DataDeleter::StartDeleting(profile_, extension.get(),
+ subtask_done_callback);
+ }
}
void ChromeExtensionRegistrarDelegate::DoLoadExtensionForReload(
@@ -322,6 +333,13 @@ bool ChromeExtensionRegistrarDelegate::CanDisableExtension(
@@ -317,6 +318,13 @@ bool ChromeExtensionRegistrarDelegate::CanDisableExtension(
return true;
}

View File

@@ -1,17 +1,8 @@
diff --git a/chrome/install_static/chromium_install_modes.h b/chrome/install_static/chromium_install_modes.h
index ee62888f89705..7ec72d302bc4b 100644
index 0cf937413e08a..a61c438a77379 100644
--- a/chrome/install_static/chromium_install_modes.h
+++ b/chrome/install_static/chromium_install_modes.h
@@ -21,7 +21,7 @@ inline constexpr wchar_t kCompanyPathName[] = L"";
// The brand-specific product name to be included as a component of the install
// and user data directory paths.
-inline constexpr wchar_t kProductPathName[] = L"Chromium";
+inline constexpr wchar_t kProductPathName[] = L"BrowserOS";
// The brand-specific safe browsing client name.
inline constexpr char kSafeBrowsingName[] = "chromium";
@@ -44,48 +44,49 @@ inline constexpr auto kInstallModes = std::to_array<InstallConstants>({
@@ -33,48 +33,49 @@ inline constexpr auto kInstallModes = std::to_array<InstallConstants>({
L"", // Empty install_suffix for the primary install mode.
.logo_suffix = L"", // No logo suffix for the primary install mode.
.app_guid =