mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
1 Commits
fix/linux-
...
feat/brows
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f6eabcf0e |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -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
|
||||
|
||||
147
.github/workflows/release-server.yml
vendored
147
.github/workflows/release-server.yml
vendored
@@ -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 }}
|
||||
@@ -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 © 2026 Felafax, Inc.
|
||||
Copyright © 2025 Felafax, Inc.
|
||||
|
||||
## Stargazers
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.81",
|
||||
"version": "0.0.80",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
247
packages/browseros-agent/apps/server/poc/test-openclaw-multi.ts
Normal file
247
packages/browseros-agent/apps/server/poc/test-openclaw-multi.ts
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
},
|
||||
"apps/server": {
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.81",
|
||||
"version": "0.0.80",
|
||||
"bin": {
|
||||
"browseros-server": "./src/index.ts",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface BuildArgs {
|
||||
targets: BuildTarget[]
|
||||
manifestPath: string
|
||||
upload: boolean
|
||||
ci: boolean
|
||||
compileOnly: boolean
|
||||
}
|
||||
|
||||
export interface R2Config {
|
||||
|
||||
48
packages/browseros/build/cli/build.py
generated
48
packages/browseros/build/cli/build.py
generated
@@ -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")
|
||||
|
||||
91
packages/browseros/build/common/resolver.py
generated
91
packages/browseros/build/common/resolver.py
generated
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
94
packages/browseros/build/modules/package/linux.py
generated
94
packages/browseros/build/modules/package/linux.py
generated
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
45
packages/browseros/build/modules/setup/configure.py
generated
45
packages/browseros/build/modules/setup/configure.py
generated
@@ -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,
|
||||
)
|
||||
|
||||
63
packages/browseros/build/modules/setup/git.py
generated
63
packages/browseros/build/modules/setup/git.py
generated
@@ -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],
|
||||
|
||||
57
packages/browseros/build/modules/storage/upload.py
generated
57
packages/browseros/build/modules/storage/upload.py
generated
@@ -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')}")
|
||||
|
||||
@@ -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()
|
||||
@@ -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_;
|
||||
+}
|
||||
|
||||
@@ -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);
|
||||
+
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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);
|
||||
+}
|
||||
+
|
||||
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user