mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
12 Commits
feat/model
...
fix/github
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ea345e955 | ||
|
|
aee30ce8e1 | ||
|
|
0833c8d42d | ||
|
|
036c7f280b | ||
|
|
000429277d | ||
|
|
f8535fd96d | ||
|
|
f0cbf77924 | ||
|
|
17be06eb2f | ||
|
|
0e90785500 | ||
|
|
2bb432b0f2 | ||
|
|
565ce18eba | ||
|
|
81350c0d7f |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -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
|
||||
|
||||
21
.github/workflows/release-cli.yml
vendored
21
.github/workflows/release-cli.yml
vendored
@@ -103,6 +103,13 @@ jobs:
|
||||
|
||||
## Install `browseros-cli`
|
||||
|
||||
### npm / npx
|
||||
|
||||
```bash
|
||||
npx browseros-cli --help
|
||||
npm install -g browseros-cli
|
||||
```
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
@@ -138,3 +145,17 @@ jobs:
|
||||
--notes-file /tmp/release-notes.md \
|
||||
${CLI_DIST}/*
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
make npm-version VERSION=${{ inputs.version }}
|
||||
cd npm
|
||||
npm publish --access public
|
||||
|
||||
147
.github/workflows/release-server.yml
vendored
Normal file
147
.github/workflows/release-server.yml
vendored
Normal 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 }}
|
||||
@@ -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 © 2025 Felafax, Inc.
|
||||
Copyright © 2026 Felafax, Inc.
|
||||
|
||||
## Stargazers
|
||||
|
||||
|
||||
@@ -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 "{search}"
|
||||
</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 "
|
||||
{modelSearch}"
|
||||
</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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,21 @@ 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)
|
||||
|
||||
.PHONY: npm-version npm-publish
|
||||
|
||||
npm-version:
|
||||
@if [ "$(VERSION)" = "dev" ]; then echo "Error: VERSION required" >&2; exit 1; fi
|
||||
@node -e "const p=require('./npm/package.json');p.version='$(VERSION)';require('fs').writeFileSync('./npm/package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
@echo "npm/package.json version set to $(VERSION)"
|
||||
|
||||
npm-publish: npm-version
|
||||
cd npm && npm publish
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
2
packages/browseros-agent/apps/cli/npm/.npmignore
Normal file
2
packages/browseros-agent/apps/cli/npm/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
.binary/
|
||||
node_modules/
|
||||
81
packages/browseros-agent/apps/cli/npm/README.md
Normal file
81
packages/browseros-agent/apps/cli/npm/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# browseros-cli
|
||||
|
||||
Command-line interface for controlling BrowserOS -- launch and automate the browser from the terminal.
|
||||
|
||||
## Installation
|
||||
|
||||
**Zero install (recommended):**
|
||||
|
||||
```bash
|
||||
npx browseros-cli --help
|
||||
```
|
||||
|
||||
**Global install:**
|
||||
|
||||
```bash
|
||||
npm install -g browseros-cli
|
||||
```
|
||||
|
||||
**Shell script fallback:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Download BrowserOS
|
||||
browseros-cli install
|
||||
|
||||
# Start BrowserOS
|
||||
browseros-cli launch
|
||||
|
||||
# Auto-configure MCP settings for your AI tools
|
||||
browseros-cli init --auto
|
||||
|
||||
# Verify everything is working
|
||||
browseros-cli health
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
browseros-cli navigate "https://example.com"
|
||||
```
|
||||
|
||||
### Observation
|
||||
|
||||
```bash
|
||||
browseros-cli snapshot # Get the accessibility tree of the current page
|
||||
browseros-cli console-logs # View browser console output
|
||||
```
|
||||
|
||||
### Screenshots
|
||||
|
||||
```bash
|
||||
browseros-cli screenshot # Capture the current page
|
||||
```
|
||||
|
||||
### Input
|
||||
|
||||
```bash
|
||||
browseros-cli click 42 # Click an element by its node ID
|
||||
browseros-cli fill 85 "query" # Type text into an input field
|
||||
```
|
||||
|
||||
### Agent Mode
|
||||
|
||||
```bash
|
||||
browseros-cli agent "Search for flights to Tokyo"
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available at [browseros.com](https://browseros.com).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
32
packages/browseros-agent/apps/cli/npm/bin/browseros-cli.js
Normal file
32
packages/browseros-agent/apps/cli/npm/bin/browseros-cli.js
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execFileSync, spawnSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
|
||||
const BINARY_DIR = path.join(__dirname, '..', '.binary')
|
||||
const EXT = process.platform === 'win32' ? '.exe' : ''
|
||||
const BIN_PATH = path.join(BINARY_DIR, `browseros-cli${EXT}`)
|
||||
|
||||
if (!fs.existsSync(BIN_PATH)) {
|
||||
console.error('browseros-cli: binary not found, downloading...')
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(__dirname, '..', 'scripts', 'postinstall.js')],
|
||||
{ stdio: 'inherit', env: { ...process.env, BROWSEROS_NPM_FORCE: '1' } },
|
||||
)
|
||||
} catch {
|
||||
console.error(
|
||||
'browseros-cli: failed to download binary. Try reinstalling:\n npm install -g browseros-cli',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const result = spawnSync(BIN_PATH, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, BROWSEROS_INSTALL_METHOD: 'npm' },
|
||||
})
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
45
packages/browseros-agent/apps/cli/npm/package.json
Normal file
45
packages/browseros-agent/apps/cli/npm/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "browseros-cli",
|
||||
"version": "0.2.0",
|
||||
"description": "Command-line interface for controlling BrowserOS — launch and automate the browser from the terminal",
|
||||
"bin": {
|
||||
"browseros-cli": "bin/browseros-cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
},
|
||||
"keywords": [
|
||||
"browseros",
|
||||
"cli",
|
||||
"browser",
|
||||
"automation",
|
||||
"mcp",
|
||||
"ai-agent",
|
||||
"model-context-protocol"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/browseros-ai/BrowserOS",
|
||||
"directory": "packages/browseros-agent/apps/cli/npm"
|
||||
},
|
||||
"homepage": "https://browseros.com",
|
||||
"bugs": "https://github.com/browseros-ai/BrowserOS/issues",
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"scripts/",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
142
packages/browseros-agent/apps/cli/npm/scripts/postinstall.js
Normal file
142
packages/browseros-agent/apps/cli/npm/scripts/postinstall.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const https = require('node:https')
|
||||
const http = require('node:http')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { execSync } = require('node:child_process')
|
||||
const { createHash } = require('node:crypto')
|
||||
|
||||
const VERSION = require('../package.json').version
|
||||
const GITHUB_RELEASE_BASE = `https://github.com/browseros-ai/BrowserOS/releases/download/browseros-cli-v${VERSION}`
|
||||
const BINARY_DIR = path.join(__dirname, '..', '.binary')
|
||||
const EXT = process.platform === 'win32' ? '.exe' : ''
|
||||
const BINARY_PATH = path.join(BINARY_DIR, `browseros-cli${EXT}`)
|
||||
|
||||
if (process.env.CI && !process.env.BROWSEROS_NPM_FORCE) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const PLATFORM_MAP = { darwin: 'darwin', linux: 'linux', win32: 'windows' }
|
||||
const ARCH_MAP = { x64: 'amd64', arm64: 'arm64' }
|
||||
|
||||
const platform = PLATFORM_MAP[process.platform]
|
||||
const arch = ARCH_MAP[process.arch]
|
||||
|
||||
if (!platform || !arch) {
|
||||
console.error(
|
||||
`browseros-cli: unsupported platform ${process.platform}/${process.arch}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const isWindows = platform === 'windows'
|
||||
const archiveExt = isWindows ? 'zip' : 'tar.gz'
|
||||
const archiveName = `browseros-cli_${VERSION}_${platform}_${arch}.${archiveExt}`
|
||||
const archiveURL = `${GITHUB_RELEASE_BASE}/${archiveName}`
|
||||
const checksumURL = `${GITHUB_RELEASE_BASE}/checksums.txt`
|
||||
|
||||
const MAX_REDIRECTS = 5
|
||||
|
||||
function download(url, redirects = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > MAX_REDIRECTS) {
|
||||
return reject(new Error(`Too many redirects for ${url}`))
|
||||
}
|
||||
const client = url.startsWith('https') ? https : http
|
||||
client
|
||||
.get(url, { headers: { 'User-Agent': 'browseros-cli-npm' } }, (res) => {
|
||||
if (
|
||||
res.statusCode >= 300 &&
|
||||
res.statusCode < 400 &&
|
||||
res.headers.location
|
||||
) {
|
||||
return download(res.headers.location, redirects + 1).then(
|
||||
resolve,
|
||||
reject,
|
||||
)
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`HTTP ${res.statusCode} for ${url}`))
|
||||
}
|
||||
const chunks = []
|
||||
res.on('data', (chunk) => chunks.push(chunk))
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
res.on('error', reject)
|
||||
})
|
||||
.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(
|
||||
`browseros-cli: downloading v${VERSION} for ${platform}/${arch}...`,
|
||||
)
|
||||
|
||||
const [archiveBuffer, checksumBuffer] = await Promise.all([
|
||||
download(archiveURL),
|
||||
download(checksumURL).catch(() => null),
|
||||
])
|
||||
|
||||
if (checksumBuffer) {
|
||||
const checksumText = checksumBuffer.toString('utf-8')
|
||||
const expectedLine = checksumText
|
||||
.split('\n')
|
||||
.find((l) => l.includes(archiveName))
|
||||
if (expectedLine) {
|
||||
const expected = expectedLine.split(/\s+/)[0]
|
||||
const actual = createHash('sha256').update(archiveBuffer).digest('hex')
|
||||
if (actual !== expected) {
|
||||
console.error(
|
||||
`browseros-cli: checksum mismatch!\n expected: ${expected}\n got: ${actual}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('browseros-cli: checksum verified.')
|
||||
} else {
|
||||
console.warn(
|
||||
'browseros-cli: warning: checksum entry not found in checksums.txt, skipping verification.',
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
'browseros-cli: warning: could not fetch checksums.txt, skipping verification.',
|
||||
)
|
||||
}
|
||||
|
||||
fs.mkdirSync(BINARY_DIR, { recursive: true })
|
||||
const tmpArchive = path.join(BINARY_DIR, archiveName)
|
||||
fs.writeFileSync(tmpArchive, archiveBuffer)
|
||||
|
||||
if (isWindows) {
|
||||
execSync(
|
||||
`powershell -Command "Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${BINARY_DIR}'"`,
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
} else {
|
||||
execSync(`tar -xzf "${tmpArchive}" -C "${BINARY_DIR}"`, {
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
|
||||
fs.unlinkSync(tmpArchive)
|
||||
|
||||
if (!fs.existsSync(BINARY_PATH)) {
|
||||
console.error(
|
||||
`browseros-cli: binary not found after extraction at ${BINARY_PATH}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!isWindows) {
|
||||
fs.chmodSync(BINARY_PATH, 0o755)
|
||||
}
|
||||
|
||||
console.log(`browseros-cli: installed v${VERSION} successfully.`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`browseros-cli: installation failed: ${err.message}`)
|
||||
console.error(
|
||||
'You can install manually: curl -fsSL https://cdn.browseros.com/cli/install.sh | bash',
|
||||
)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
DefaultHTTPTimeout = 2 * time.Second
|
||||
DefaultDownloadTimeout = 5 * time.Minute
|
||||
SkipCheckEnv = "BROWSEROS_SKIP_UPDATE_CHECK"
|
||||
InstallMethodEnv = "BROWSEROS_INSTALL_METHOD"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
@@ -95,9 +96,21 @@ func (m *Manager) AutomaticEnabled() bool {
|
||||
if os.Getenv(SkipCheckEnv) != "" {
|
||||
return false
|
||||
}
|
||||
if installedViaPackageManager() {
|
||||
return false
|
||||
}
|
||||
return IsReleaseVersion(m.options.CurrentVersion)
|
||||
}
|
||||
|
||||
func installedViaPackageManager() bool {
|
||||
method := os.Getenv(InstallMethodEnv)
|
||||
switch method {
|
||||
case "npm", "brew", "homebrew":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) ShouldCheck() bool {
|
||||
if !m.AutomaticEnabled() {
|
||||
return false
|
||||
@@ -210,11 +223,22 @@ func (m *Manager) Apply(ctx context.Context, result *CheckResult) error {
|
||||
}
|
||||
|
||||
func FormatNotice(currentVersion, latestVersion string) string {
|
||||
return fmt.Sprintf(
|
||||
"Update available: browseros-cli v%s (current v%s)\nRun `browseros-cli update` to upgrade.",
|
||||
notice := fmt.Sprintf(
|
||||
"Update available: browseros-cli v%s (current v%s)",
|
||||
latestVersion,
|
||||
currentVersion,
|
||||
)
|
||||
|
||||
switch os.Getenv(InstallMethodEnv) {
|
||||
case "npm":
|
||||
notice += "\nRun `npm update -g browseros-cli` to upgrade."
|
||||
case "brew", "homebrew":
|
||||
notice += "\nRun `brew upgrade browseros-cli` to upgrade."
|
||||
default:
|
||||
notice += "\nRun `browseros-cli update` to upgrade."
|
||||
}
|
||||
|
||||
return notice
|
||||
}
|
||||
|
||||
func (m *Manager) recordError(err error) {
|
||||
|
||||
@@ -144,6 +144,32 @@ func TestManagerSaveAppliedState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticEnabledSkipsForPackageManagerInstall(t *testing.T) {
|
||||
t.Setenv("BROWSEROS_INSTALL_METHOD", "npm")
|
||||
|
||||
manager := NewManager(Options{
|
||||
CurrentVersion: "1.0.0",
|
||||
Automatic: true,
|
||||
})
|
||||
|
||||
if manager.AutomaticEnabled() {
|
||||
t.Fatal("AutomaticEnabled() = true, want false when BROWSEROS_INSTALL_METHOD=npm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticEnabledAllowsNormalInstall(t *testing.T) {
|
||||
t.Setenv("BROWSEROS_INSTALL_METHOD", "")
|
||||
|
||||
manager := NewManager(Options{
|
||||
CurrentVersion: "1.0.0",
|
||||
Automatic: true,
|
||||
})
|
||||
|
||||
if !manager.AutomaticEnabled() {
|
||||
t.Fatal("AutomaticEnabled() = false, want true when BROWSEROS_INSTALL_METHOD is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func runtimePlatformKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := PlatformKey(runtimeGOOS(), runtimeGOARCH())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface BuildArgs {
|
||||
manifestPath: string
|
||||
upload: boolean
|
||||
compileOnly: boolean
|
||||
ci: boolean
|
||||
}
|
||||
|
||||
export interface R2Config {
|
||||
|
||||
@@ -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);
|
||||
+}
|
||||
+
|
||||
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user