Compare commits

..

12 Commits

Author SHA1 Message Date
Nikhil Sonti
5ea345e955 fix: skip windows exe patching in ci mode to avoid wine dependency
The server release CI workflow fails on ubuntu-latest because
patch-windows-exe.ts requires Wine to run rcedit. Thread the existing
--ci flag through compileServerBinaries so Windows PE metadata patching
is skipped in CI mode with a warning log.
2026-04-03 14:39:05 -07:00
Felarof
aee30ce8e1 Update README.md (#638) 2026-04-02 13:00:11 -07:00
Nikhil
0833c8d42d fix: windows app-data location fix (#637) 2026-04-02 08:53:04 -07:00
Nikhil
036c7f280b fix: tab-grouping cdp crash (#635)
* fix: tab group crash + history fix

* fix: tab group crash + history fix
2026-04-01 15:06:41 -07:00
Nikhil
000429277d fix: isolate server release packaging to ci mode (#629)
* fix: relax compile-only release env requirements

* refactor: add ci mode for server release builds
2026-03-31 20:57:44 -07:00
Nikhil
f8535fd96d fix: exclude eval framework from language stats via gitattributes (#630) 2026-03-31 20:44:06 -07:00
Nikhil
f0cbf77924 feat: add server release workflow (#627)
* feat: add server release workflow

* fix: address PR review comments for 0331-add_server_release_workflow

* refactor: rework 0331-add_server_release_workflow based on feedback

* refactor: rework 0331-add_server_release_workflow based on feedback
2026-03-31 17:37:06 -07:00
Nikhil
17be06eb2f fix: report release cli version correctly (#626) 2026-03-31 16:17:57 -07:00
Nikhil
0e90785500 fix: accept port-only input in CLI init command (#625)
Users can now run `browseros-cli init 9000` in addition to the full URL.
Updated default example port from 9004 to 9000.
2026-03-31 16:16:30 -07:00
Nikhil
2bb432b0f2 feat: use hidden pages for scheduled tasks (#624)
* feat: use hidden pages for scheduled tasks

* refactor: rework 0331-use_hidden_pages_for_scheduled_tasks based on feedback
2026-03-31 16:02:47 -07:00
shivammittal274
565ce18eba feat: add npm/npx distribution for BrowserOS CLI (#618)
* feat(cli): skip self-update prompts for package manager installs

Checks BROWSEROS_INSTALL_METHOD env var (npm, brew) and skips automatic
update checks. Users should use their package manager's update mechanism.
FormatNotice now shows the appropriate upgrade command based on install method.

* feat(cli): add npm bin wrapper for browseros-cli

* feat(cli): add npm postinstall script to download platform binary

Downloads the correct platform binary from GitHub releases during npm
install, verifies SHA256 checksums, and extracts to .binary directory.

* feat(cli): add npm package metadata and README

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move npm package files to correct monorepo path

The bin wrapper and postinstall were created at apps/cli/npm/ instead of
packages/browseros-agent/apps/cli/npm/. Moves them to the correct location.

* style: use node: protocol for builtin module imports

* feat(cli): add Makefile npm targets and release workflow npm publish step

Adds npm-version and npm-publish Makefile targets for version sync.
Adds Node.js setup and npm publish step to the release workflow.
Adds npm/npx install instructions to release notes template.

* fix(cli): fail on missing checksum entry and limit redirect depth

- Abort if checksums.txt downloaded but archive entry is missing
- Warn if checksums.txt itself failed to download
- Cap redirect depth at 5 to prevent stack overflow on circular redirects

* fix(cli): match install.sh checksum behavior — warn instead of abort

The existing shell installer (install.sh) warns and continues when the
checksum entry is missing from checksums.txt. Match that behavior in the
npm postinstall to avoid unnecessary install failures. Both files come
from the same GitHub release, so the checksum is a corruption check,
not a strong security boundary.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:30:58 +05:30
shivammittal274
81350c0d7f feat: replace model picker with shadcn Combobox + fuse.js fuzzy search (#617)
The model picker in NewProviderDialog rendered inline, causing dialog
resizing and lacked keyboard navigation. Replace it with a Popover +
Command (shadcn Combobox) pattern and add fuse.js for fuzzy search.

- Replace custom ModelPickerList with Popover + Command dropdown
- Add fuse.js for fuzzy model search (replaces string.includes)
- Add MODEL_SELECTED_EVENT and AI_PROVIDER_UPDATED_EVENT analytics
- Enrich PROVIDER_SELECTED_EVENT with model_id in chat sessions
2026-03-30 16:38:21 +05:30
35 changed files with 1132 additions and 255 deletions

2
.gitattributes vendored
View File

@@ -9,4 +9,6 @@ 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 Normal file
View File

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

View File

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

View File

@@ -1,17 +1,26 @@
import { zodResolver } from '@hookform/resolvers/zod'
import Fuse from 'fuse.js'
import {
Check,
CheckCircle2,
ChevronDown,
ExternalLink,
Loader2,
SearchIcon,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod/v3'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Dialog,
DialogContent,
@@ -30,6 +39,11 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Select,
SelectContent,
@@ -42,8 +56,10 @@ import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import {
AI_PROVIDER_ADDED_EVENT,
AI_PROVIDER_UPDATED_EVENT,
KIMI_API_KEY_CONFIGURED_EVENT,
KIMI_API_KEY_GUIDE_CLICKED_EVENT,
MODEL_SELECTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import {
@@ -55,11 +71,7 @@ import { type TestResult, testProvider } from '@/lib/llm-providers/testProvider'
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
import { track } from '@/lib/metrics/track'
import { cn } from '@/lib/utils'
import {
getModelContextLength,
getModelsForProvider,
type ModelInfo,
} from './models'
import { getModelContextLength, getModelsForProvider } from './models'
const providerTypeEnum = z.enum([
'moonshot',
@@ -182,100 +194,6 @@ function formatContextWindow(tokens: number): string {
return `${tokens}`
}
function ModelPickerList({
models,
selectedModelId,
onSelect,
onCustomSubmit,
onClose,
}: {
models: ModelInfo[]
selectedModelId: string
onSelect: (modelId: string) => void
onCustomSubmit: (modelId: string) => void
onClose: () => void
}) {
const [search, setSearch] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [onClose])
const query = search.toLowerCase()
const filtered = query
? models.filter((m) => m.modelId.toLowerCase().includes(query))
: models
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && search) {
e.preventDefault()
onCustomSubmit(search)
}
if (e.key === 'Escape') {
onClose()
}
}
return (
<div ref={containerRef} className="rounded-md border">
<div className="flex items-center gap-2 border-b px-3">
<SearchIcon className="h-4 w-4 shrink-0 text-muted-foreground opacity-50" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search or type a custom model ID..."
className="flex h-9 w-full bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-[200px] overflow-y-auto">
{filtered.length > 0 ? (
filtered.map((model) => {
const isSelected = selectedModelId === model.modelId
return (
<button
key={model.modelId}
type="button"
onClick={() => onSelect(model.modelId)}
className={cn(
'flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-accent',
isSelected && 'bg-accent font-medium',
)}
>
<span className="truncate">{model.modelId}</span>
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{formatContextWindow(model.contextLength)}
</span>
</button>
)
})
) : (
<div className="px-3 py-6 text-center text-muted-foreground text-sm">
No models match. Press Enter to use &quot;{search}&quot;
</div>
)}
</div>
</div>
)
}
/**
* Props for NewProviderDialog
* @public
@@ -303,7 +221,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
}) => {
const [isTesting, setIsTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [modelListOpen, setModelListOpen] = useState(false)
const [modelPickerOpen, setModelPickerOpen] = useState(false)
const [modelSearch, setModelSearch] = useState('')
const { supports } = useCapabilities()
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const kimiLaunch = useKimiLaunch()
@@ -376,6 +295,20 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
const modelInfoList = getModelsForProvider(watchedType as ProviderType)
const modelFuse = useMemo(
() =>
new Fuse(modelInfoList, {
keys: ['modelId'],
threshold: 0.4,
distance: 100,
}),
[modelInfoList],
)
const filteredModels = modelSearch
? modelFuse.search(modelSearch).map((r) => r.item)
: modelInfoList
// Handle provider type change (user-initiated via Select)
const handleTypeChange = (newType: ProviderType) => {
form.setValue('type', newType)
@@ -471,6 +404,11 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
provider_type: values.type,
model: values.modelId,
})
} else {
track(AI_PROVIDER_UPDATED_EVENT, {
provider_type: values.type,
model: values.modelId,
})
}
if (values.type === 'moonshot') {
track(KIMI_API_KEY_CONFIGURED_EVENT, {
@@ -924,36 +862,95 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
{...field}
/>
</FormControl>
) : modelListOpen ? (
<ModelPickerList
models={modelInfoList}
selectedModelId={field.value}
onSelect={(modelId) => {
form.setValue('modelId', modelId)
setModelListOpen(false)
}}
onCustomSubmit={(modelId) => {
form.setValue('modelId', modelId)
setModelListOpen(false)
}}
onClose={() => setModelListOpen(false)}
/>
) : (
<button
type="button"
onClick={() => setModelListOpen(true)}
className={cn(
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs',
field.value
? 'text-foreground'
: 'text-muted-foreground',
)}
<Popover
open={modelPickerOpen}
onOpenChange={(isOpen) => {
setModelPickerOpen(isOpen)
if (!isOpen) setModelSearch('')
}}
>
<span className="truncate">
{field.value || 'Select a model...'}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs',
field.value
? 'text-foreground'
: 'text-muted-foreground',
)}
>
<span className="truncate">
{field.value || 'Select a model...'}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search models..."
value={modelSearch}
onValueChange={setModelSearch}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
modelSearch &&
filteredModels.length === 0
) {
e.preventDefault()
form.setValue('modelId', modelSearch)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: modelSearch,
is_custom_model: true,
})
setModelPickerOpen(false)
setModelSearch('')
}
}}
/>
<CommandList>
<CommandEmpty>
No models found. Press Enter to use &quot;
{modelSearch}&quot;
</CommandEmpty>
<CommandGroup>
{filteredModels.map((model) => (
<CommandItem
key={model.modelId}
value={model.modelId}
onSelect={() => {
form.setValue('modelId', model.modelId)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: model.modelId,
context_window: model.contextLength,
is_custom_model: false,
})
setModelPickerOpen(false)
setModelSearch('')
}}
>
<span className="flex-1 truncate">
{model.modelId}
</span>
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{formatContextWindow(model.contextLength)}
</span>
{field.value === model.modelId && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
<FormMessage />
</FormItem>

View File

@@ -561,9 +561,11 @@ export const useChatSession = (options?: ChatSessionOptions) => {
}, [])
const handleSelectProvider = (provider: Provider) => {
const fullProvider = llmProviders.find((p) => p.id === provider.id)
track(PROVIDER_SELECTED_EVENT, {
provider_id: provider.id,
provider_type: provider.type,
model_id: fullProvider?.modelId,
})
setDefaultProvider(provider.id)
}

View File

@@ -29,6 +29,12 @@ export const CONVERSATION_RESET_EVENT = 'ui.conversation.reset'
/** @public */
export const AI_PROVIDER_ADDED_EVENT = 'settings.ai_provider.added'
/** @public */
export const AI_PROVIDER_UPDATED_EVENT = 'settings.ai_provider.updated'
/** @public */
export const MODEL_SELECTED_EVENT = 'settings.model.selected'
/** @public */
export const CHATGPT_PRO_OAUTH_STARTED_EVENT =
'settings.chatgpt_pro.oauth_started'

View File

@@ -67,6 +67,7 @@
"embla-carousel-react": "^8.6.0",
"es-toolkit": "^1.42.0",
"eventsource-parser": "^3.0.6",
"fuse.js": "^7.1.0",
"graphql": "^16.12.0",
"hono": "^4.12.3",
"idb-keyval": "^6.2.2",

View File

@@ -2,13 +2,17 @@ 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
@@ -45,6 +49,11 @@ release:
fi; \
mv "$(DIST)/$(BINARY)$$EXT" "$(DIST)/$(BINARY)_$${OS}_$${ARCH}$$EXT"; \
done
@ACTUAL_VERSION=$$($(HOST_BINARY) --version | awk '{print $$3}'); \
if [ "$$ACTUAL_VERSION" != "$(VERSION)" ]; then \
echo "Error: expected $(HOST_BINARY) to report version $(VERSION), got $$ACTUAL_VERSION" >&2; \
exit 1; \
fi
@cd $(DIST) && (command -v sha256sum >/dev/null 2>&1 && sha256sum *.tar.gz *.zip || shasum -a 256 *.tar.gz *.zip) > checksums.txt
@echo "=== Built artifacts ==="
@ls -lh $(DIST)

View File

@@ -25,13 +25,17 @@ 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:9004/mcp
The URL looks like: http://127.0.0.1:9000/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, use the provided URL
browseros-cli init <url> Non-interactive (full URL or port number)
browseros-cli init --auto Auto-discover from ~/.browseros/server.json
browseros-cli init Interactive prompt`,
Annotations: map[string]string{"group": "Setup:"},
@@ -65,13 +69,14 @@ Three modes:
bold.Println("BrowserOS CLI Setup")
fmt.Println()
fmt.Println("Open BrowserOS → Settings → BrowserOS MCP")
fmt.Println("Copy the Server URL shown there.")
fmt.Println("Copy the Server URL or port number shown there.")
fmt.Println()
dim.Println("It looks like: http://127.0.0.1:9004/mcp")
dim.Println("Examples: http://127.0.0.1:9000/mcp")
dim.Println(" 9000")
fmt.Println()
reader := bufio.NewReader(os.Stdin)
fmt.Print("Server URL: ")
fmt.Print("Server URL or port: ")
line, err := reader.ReadString('\n')
if err != nil {
output.Error("failed to read input", 1)

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ You do not have a filesystem workspace in this session. Return all results direc
// Mode-aware framing
if (options?.isScheduledTask) {
role +=
'\n\nYou are running as a scheduled background task in a dedicated hidden browser window. Complete the task autonomously and report results.'
'\n\nYou are running as a scheduled background task on a system-managed hidden page. 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 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.`
**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.`
if (!isNewTab) {
executionContent += `
@@ -661,22 +661,24 @@ function getUserContext(
if (options?.isScheduledTask) {
pageCtx +=
'\nYou are running as a **scheduled background task** in a dedicated hidden browser window.'
'\nYou are running as a **scheduled background task** on a system-managed hidden page.'
}
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 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.`
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.`
pageCtx +=
'\n3. **Do NOT close your dedicated hidden window** (via `close_window`). It is managed by the system and will be cleaned up automatically.'
'\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.'
pageCtx +=
'\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.'
'\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.'
}
pageCtx += '\n</page_context>'
@@ -737,7 +739,7 @@ export interface BuildSystemPromptOptions {
userSystemPrompt?: string
exclude?: string[]
isScheduledTask?: boolean
scheduledTaskWindowId?: number
scheduledTaskPageId?: number
workspaceDir?: string
soulContent?: string
isSoulBootstrap?: boolean

View File

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

View File

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

View File

@@ -173,7 +173,7 @@ export const new_page = defineTool({
export const new_hidden_page = defineTool({
name: 'new_hidden_page',
description:
'Open a new hidden page (tab) and navigate to a URL. Hidden pages are not visible to the user and useful for background data fetching or automation. Note: take_screenshot is not supported on hidden tabs — use show_page first to make it visible.',
'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.',
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 need to make the page visible (e.g. for screenshots). Errors if the page is already visible.',
'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.',
input: z.object({
page: pageParam,
windowId: z

View File

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

View File

@@ -86,7 +86,7 @@ function buildScheduled(overrides?: Partial<BuildSystemPromptOptions>): string {
return buildSystemPrompt({
isScheduledTask: true,
workspaceDir: '/tmp/scheduled',
scheduledTaskWindowId: 42,
scheduledTaskPageId: 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, in a hidden window, no user interaction
// - Scheduled: must know it's autonomous, on a hidden page, 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,20 +310,21 @@ describe('mode-aware framing', () => {
expect(prompt).not.toContain('<page_context>')
})
it('scheduled task includes windowId in page context', () => {
const prompt = buildScheduled({ scheduledTaskWindowId: 99 })
expect(prompt).toContain('windowId: 99')
it('scheduled task includes starting pageId in page context', () => {
const prompt = buildScheduled({ scheduledTaskPageId: 99 })
expect(prompt).toContain('starting page ID `99`')
})
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 without pageId uses Browser Context reference', () => {
const prompt = buildScheduled({ scheduledTaskPageId: undefined })
expect(prompt).toContain('the page ID from the Browser Context')
})
it('scheduled task includes hidden window management rules', () => {
it('scheduled task includes hidden page management rules', () => {
const prompt = buildScheduled()
expect(prompt).toContain('Do NOT close your dedicated hidden window')
expect(prompt).toContain('Do NOT close your starting hidden page')
expect(prompt).toContain('Do NOT create new windows')
expect(prompt).toContain('Close extra hidden pages')
})
})
@@ -1060,11 +1061,12 @@ describe('execution section', () => {
it('prohibits hidden windows for user tasks', () => {
// Why: Run 2 used create_hidden_window instead of background tabs.
// Hidden windows are invisible to users and can't be screenshotted.
// Hidden pages are invisible to users, so user-requested work must stay on visible tabs.
const prompt = buildRegular()
expect(prompt).toContain('Do NOT use')
expect(prompt).toContain('create_hidden_window')
expect(prompt).toContain('new_hidden_page')
expect(prompt).not.toContain('cannot be screenshotted')
})
it('includes tab retry discipline', () => {

View File

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

View File

@@ -8,11 +8,16 @@
import { afterAll, describe, it } from 'bun:test'
import assert from 'node:assert'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import {
existsSync,
mkdtempSync,
readFileSync,
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'
@@ -24,12 +29,30 @@ function getNativeTarget(): { id: string; ext: string } {
return { id: `${os}-${cpu}`, ext: process.platform === 'win32' ? '.exe' : '' }
}
// Stub values so the build config validation passes without real secrets
const BUILD_ENV_STUBS: Record<string, string> = {
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> = {
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',
@@ -39,23 +62,58 @@ const BUILD_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}`,
)
// Empty manifest so the build skips R2 resource downloads
const zipPath = resolve(
rootDir,
`dist/prod/server/browseros-server-resources-${target.id}.zip`,
)
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
@@ -71,7 +129,7 @@ describe('server build', () => {
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, ...BUILD_ENV_STUBS },
env: buildEnv({ ...INLINE_ENV_STUBS, ...R2_ENV_STUBS }),
},
)
const buildExit = await build.exited
@@ -97,4 +155,48 @@ describe('server build', () => {
)
assert.strictEqual(versionOutput.trim(), expectedVersion)
}, 300_000)
it('keeps compile-only on the strict production validation path', async () => {
resetProdEnvToTemplate()
const build = Bun.spawn(
['bun', buildScript, `--target=${target.id}`, '--compile-only'],
{
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: buildEnv({}, PROD_SECRET_KEYS),
},
)
const buildExit = await build.exited
const stderr = await new Response(build.stderr).text()
assert.notStrictEqual(buildExit, 0, 'Compile-only build should fail')
assert.match(stderr, /Production build requires variables:/)
assert.match(stderr, /CODEGEN_SERVICE_URL/)
assert.match(stderr, /POSTHOG_API_KEY/)
assert.match(stderr, /SENTRY_DSN/)
}, 300_000)
it('archives CI builds without R2 config or production env secrets', async () => {
resetProdEnvToTemplate()
rmSync(zipPath, { force: true })
const build = Bun.spawn(
['bun', buildScript, `--target=${target.id}`, '--ci'],
{
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: buildEnv({}, PROD_SECRET_KEYS),
},
)
const buildExit = await build.exited
if (buildExit !== 0) {
const stderr = await new Response(build.stderr).text()
assert.fail(`CI build failed (exit ${buildExit}):\n${stderr}`)
}
assert.ok(existsSync(zipPath), `Expected archive at ${zipPath}`)
}, 300_000)
})

View File

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

View File

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

View File

@@ -74,6 +74,7 @@
"embla-carousel-react": "^8.6.0",
"es-toolkit": "^1.42.0",
"eventsource-parser": "^3.0.6",
"fuse.js": "^7.1.0",
"graphql": "^16.12.0",
"hono": "^4.12.3",
"idb-keyval": "^6.2.2",

View File

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

View File

@@ -37,29 +37,39 @@ export async function archiveAndUploadArtifacts(
r2: R2Config,
upload: boolean,
): Promise<UploadResult[]> {
const results: UploadResult[] = []
const results = await archiveArtifacts(artifacts)
if (!upload) {
return results
}
for (const artifact of artifacts) {
const zipPath = zipPathForArtifact(artifact)
await zipArtifactRoot(artifact.rootDir, zipPath)
if (!upload) {
results.push({ targetId: artifact.target.id, zipPath })
continue
}
const fileName = basename(zipPath)
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, zipPath)
await uploadFileToObject(client, r2, versionR2Key, zipPath)
results.push({
targetId: artifact.target.id,
zipPath,
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 })
}
return results
}

View File

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

View File

@@ -1,6 +1,7 @@
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'
@@ -52,6 +53,7 @@ async function bundleServer(
async function compileTarget(
target: BuildTarget,
env: NodeJS.ProcessEnv,
ci: boolean,
): Promise<string> {
const binaryPath = compiledBinaryPath(target)
const args = [
@@ -66,11 +68,15 @@ async function compileTarget(
await runCommand('bun', args, env)
if (target.os === 'windows') {
await runCommand(
'bun',
['scripts/patch-windows-exe.ts', binaryPath],
process.env,
)
if (ci) {
log.warn('Skipping Windows exe metadata patching in CI mode')
} else {
await runCommand(
'bun',
['scripts/patch-windows-exe.ts', binaryPath],
process.env,
)
}
}
return binaryPath
@@ -81,14 +87,16 @@ 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)
const binaryPath = await compileTarget(target, processEnv, ci)
compiled.push({ target, binaryPath })
}

View File

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

View File

@@ -2,13 +2,20 @@ import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { log } from '../log'
import { archiveAndUploadArtifacts } from './archive'
import { archiveAndUploadArtifacts, archiveArtifacts } 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 { stageTargetArtifact } from './stage'
import { stageCompiledArtifact, stageTargetArtifact } from './stage'
function buildModeLabel(argv: { compileOnly: boolean; ci: boolean }): string {
if (argv.ci) {
return 'ci'
}
return argv.compileOnly ? 'compile-only' : 'full'
}
export async function runProdResourceBuild(argv: string[]): Promise<void> {
const rootDir = resolve(import.meta.dir, '../../..')
@@ -18,19 +25,45 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
const buildConfig = loadBuildConfig(rootDir, {
compileOnly: args.compileOnly,
ci: args.ci,
})
log.header(`Building BrowserOS server artifacts v${buildConfig.version}`)
log.info(`Targets: ${args.targets.map((target) => target.id).join(', ')}`)
log.info(`Mode: ${args.compileOnly ? 'compile-only' : 'full'}`)
log.info(`Mode: ${buildModeLabel(args)}`)
const compiled = await compileServerBinaries(
args.targets,
buildConfig.envVars,
buildConfig.processEnv,
buildConfig.version,
{ ci: args.ci },
)
if (args.ci) {
const distRoot = getDistProdRoot()
const localArtifacts = []
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}`)
}
return
}
if (args.compileOnly) {
log.done('Compile-only build completed')
for (const binary of compiled) {

View File

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

View File

@@ -22,6 +22,7 @@ export interface BuildArgs {
manifestPath: string
upload: boolean
compileOnly: boolean
ci: boolean
}
export interface R2Config {

View File

@@ -1,8 +1,13 @@
diff --git a/chrome/browser/devtools/protocol/browser_handler.cc b/chrome/browser/devtools/protocol/browser_handler.cc
index 30bd52d09c3fc..33c7d6d8455fc 100644
index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
--- a/chrome/browser/devtools/protocol/browser_handler.cc
+++ b/chrome/browser/devtools/protocol/browser_handler.cc
@@ -8,19 +8,32 @@
@@ -4,23 +4,37 @@
#include "chrome/browser/devtools/protocol/browser_handler.h"
+#include <algorithm>
#include <set>
#include <vector>
#include "base/functional/bind.h"
@@ -35,7 +40,7 @@ index 30bd52d09c3fc..33c7d6d8455fc 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 +43,21 @@
@@ -30,10 +44,21 @@
#include "ui/gfx/image/image.h"
#include "ui/gfx/image/image_png_rep.h"
@@ -57,7 +62,7 @@ index 30bd52d09c3fc..33c7d6d8455fc 100644
BrowserWindow* GetBrowserWindow(int window_id) {
BrowserWindow* result = nullptr;
ForEachCurrentBrowserWindowInterfaceOrderedByActivation(
@@ -72,17 +96,411 @@ std::unique_ptr<protocol::Browser::Bounds> GetBrowserWindowBounds(
@@ -72,17 +97,419 @@ std::unique_ptr<protocol::Browser::Bounds> GetBrowserWindowBounds(
.Build();
}
@@ -437,6 +442,14 @@ index 30bd52d09c3fc..33c7d6d8455fc 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();
+}
+
@@ -471,7 +484,7 @@ index 30bd52d09c3fc..33c7d6d8455fc 100644
Response BrowserHandler::GetWindowForTarget(
std::optional<std::string> target_id,
@@ -120,6 +538,65 @@ Response BrowserHandler::GetWindowForTarget(
@@ -120,6 +547,65 @@ Response BrowserHandler::GetWindowForTarget(
return Response::Success();
}
@@ -537,7 +550,7 @@ index 30bd52d09c3fc..33c7d6d8455fc 100644
Response BrowserHandler::GetWindowBounds(
int window_id,
std::unique_ptr<protocol::Browser::Bounds>* out_bounds) {
@@ -297,3 +774,910 @@ protocol::Response BrowserHandler::AddPrivacySandboxEnrollmentOverride(
@@ -297,3 +783,909 @@ protocol::Response BrowserHandler::AddPrivacySandboxEnrollmentOverride(
net::SchemefulSite(url_to_add));
return Response::Success();
}
@@ -1447,4 +1460,3 @@ index 30bd52d09c3fc..33c7d6d8455fc 100644
+bool BrowserHandler::IsHiddenWindow(int window_id) const {
+ return hidden_window_ids_.contains(window_id);
+}
+

View File

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

View File

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

View File

@@ -1,8 +1,17 @@
diff --git a/chrome/install_static/chromium_install_modes.h b/chrome/install_static/chromium_install_modes.h
index 0cf937413e08a..a61c438a77379 100644
index ee62888f89705..7ec72d302bc4b 100644
--- a/chrome/install_static/chromium_install_modes.h
+++ b/chrome/install_static/chromium_install_modes.h
@@ -33,48 +33,49 @@ inline constexpr auto kInstallModes = std::to_array<InstallConstants>({
@@ -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>({
L"", // Empty install_suffix for the primary install mode.
.logo_suffix = L"", // No logo suffix for the primary install mode.
.app_guid =