mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
20 Commits
fix/test-g
...
fix/podman
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd40e011da | ||
|
|
3551ac5f23 | ||
|
|
12770e6d01 | ||
|
|
7a9d80dd1b | ||
|
|
2a61dcbc58 | ||
|
|
f5a2b7315c | ||
|
|
6de3b3422c | ||
|
|
224b6cd3a8 | ||
|
|
7baee8d57e | ||
|
|
e8e8c36fdb | ||
|
|
3810005457 | ||
|
|
688f7962cb | ||
|
|
526d784d82 | ||
|
|
331fec07e6 | ||
|
|
0652ee8ca8 | ||
|
|
156f5dbc5d | ||
|
|
ebd3200cfe | ||
|
|
4172daa130 | ||
|
|
c1b1e53a86 | ||
|
|
d653883e99 |
77
.github/workflows/test.yml
vendored
77
.github/workflows/test.yml
vendored
@@ -30,12 +30,54 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- suite: tools
|
||||
test_path: tests/tools
|
||||
junit_path: test-results/tools.xml
|
||||
- suite: integration
|
||||
test_path: tests/server.integration.test.ts
|
||||
junit_path: test-results/integration.xml
|
||||
- suite: server-agent
|
||||
command: (cd apps/server && bun run test:agent)
|
||||
junit_path: test-results/server-agent.xml
|
||||
needs_browser: false
|
||||
- suite: server-api
|
||||
command: (cd apps/server && bun run test:api)
|
||||
junit_path: test-results/server-api.xml
|
||||
needs_browser: false
|
||||
- suite: server-skills
|
||||
command: (cd apps/server && bun run test:skills)
|
||||
junit_path: test-results/server-skills.xml
|
||||
needs_browser: false
|
||||
- suite: server-tools
|
||||
command: (cd apps/server && bun run test:tools)
|
||||
junit_path: test-results/server-tools.xml
|
||||
needs_browser: true
|
||||
- suite: server-browser
|
||||
command: (cd apps/server && bun run test:browser)
|
||||
junit_path: test-results/server-browser.xml
|
||||
needs_browser: false
|
||||
- suite: server-integration
|
||||
command: (cd apps/server && bun run test:integration)
|
||||
junit_path: test-results/server-integration.xml
|
||||
needs_browser: true
|
||||
- suite: server-sdk
|
||||
command: (cd apps/server && bun run test:sdk)
|
||||
junit_path: test-results/server-sdk.xml
|
||||
needs_browser: true
|
||||
- suite: server-root
|
||||
command: (cd apps/server && bun run test:root)
|
||||
junit_path: test-results/server-root.xml
|
||||
needs_browser: false
|
||||
- suite: agent
|
||||
command: bun run test:agent
|
||||
junit_path: test-results/agent.xml
|
||||
needs_browser: false
|
||||
- suite: eval
|
||||
command: bun run test:eval
|
||||
junit_path: test-results/eval.xml
|
||||
needs_browser: false
|
||||
- suite: agent-sdk
|
||||
command: bun run test:agent-sdk
|
||||
junit_path: test-results/agent-sdk.xml
|
||||
needs_browser: false
|
||||
- suite: build
|
||||
command: bun run test:build
|
||||
junit_path: test-results/build.xml
|
||||
needs_browser: false
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -48,6 +90,7 @@ jobs:
|
||||
run: bun ci
|
||||
|
||||
- name: Resolve BrowserOS cache key
|
||||
if: matrix.needs_browser == true
|
||||
id: browseros-cache-key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -62,6 +105,7 @@ jobs:
|
||||
echo "key=browseros-appimage-${{ runner.os }}-$cache_key" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore BrowserOS cache
|
||||
if: matrix.needs_browser == true
|
||||
id: browseros-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -69,13 +113,14 @@ jobs:
|
||||
key: ${{ steps.browseros-cache-key.outputs.key }}
|
||||
|
||||
- name: Download BrowserOS
|
||||
if: steps.browseros-cache.outputs.cache-hit != 'true'
|
||||
if: matrix.needs_browser == true && steps.browseros-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p .ci/bin
|
||||
curl -fsSL "$BROWSEROS_APPIMAGE_URL" -o .ci/bin/BrowserOS.AppImage
|
||||
chmod +x .ci/bin/BrowserOS.AppImage
|
||||
|
||||
- name: Prepare BrowserOS wrapper
|
||||
if: matrix.needs_browser == true
|
||||
run: |
|
||||
mkdir -p .ci/bin
|
||||
cat > .ci/bin/browseros <<'EOF'
|
||||
@@ -96,16 +141,23 @@ jobs:
|
||||
BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros
|
||||
BROWSEROS_TEST_HEADLESS: "true"
|
||||
BROWSEROS_TEST_EXTRA_ARGS: --no-sandbox --disable-dev-shm-usage
|
||||
BROWSEROS_JUNIT_PATH: ${{ github.workspace }}/packages/browseros-agent/${{ matrix.junit_path }}
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p test-results
|
||||
cd apps/server
|
||||
bun run test:cleanup
|
||||
bun --env-file=.env.development test "${{ matrix.test_path }}" --reporter=junit --reporter-outfile="../../${{ matrix.junit_path }}"
|
||||
${{ matrix.command }}
|
||||
exit_code=$?
|
||||
cd ../..
|
||||
if [ ! -f "${{ matrix.junit_path }}" ]; then
|
||||
cat > "${{ matrix.junit_path }}" <<EOF
|
||||
if [ "$exit_code" = "0" ]; then
|
||||
cat > "${{ matrix.junit_path }}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites tests="0" failures="0">
|
||||
<testsuite name="${{ matrix.suite }}" tests="0" failures="0">
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
EOF
|
||||
else
|
||||
cat > "${{ matrix.junit_path }}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites tests="1" failures="1">
|
||||
<testsuite name="${{ matrix.suite }}" tests="1" failures="1">
|
||||
@@ -115,6 +167,7 @@ jobs:
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
**/.DS_Store
|
||||
**.auctor/**
|
||||
.auctor.json
|
||||
.gcs_entries
|
||||
**/dmg
|
||||
**/env
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type {
|
||||
BrowserOSCustomRoleInput,
|
||||
BrowserOSRoleBoundary,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
@@ -35,55 +33,19 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { AgentChat } from './AgentChat'
|
||||
import { AgentTerminal } from './AgentTerminal'
|
||||
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type OpenClawStatus,
|
||||
type RoleTemplateSummary,
|
||||
useOpenClawAgents,
|
||||
useOpenClawMutations,
|
||||
useOpenClawRoles,
|
||||
useOpenClawStatus,
|
||||
usePodmanOverrides,
|
||||
} from './useOpenClaw'
|
||||
|
||||
const OAUTH_ONLY_TYPES = new Set(['chatgpt-pro', 'github-copilot', 'qwen-code'])
|
||||
const CUSTOM_ROLE_VALUE = '__custom__'
|
||||
const PLAIN_AGENT_VALUE = '__plain__'
|
||||
type AgentCreationMode = 'builtin' | 'custom' | 'plain'
|
||||
|
||||
function createDefaultCustomRoleBoundaries(): BrowserOSRoleBoundary[] {
|
||||
return [
|
||||
{
|
||||
key: 'draft-external-comms',
|
||||
label: 'Draft external communications',
|
||||
description: 'May prepare outbound messages for review.',
|
||||
defaultMode: 'allow',
|
||||
},
|
||||
{
|
||||
key: 'send-external-comms',
|
||||
label: 'Send external communications',
|
||||
description: 'Should require approval before sending messages.',
|
||||
defaultMode: 'ask',
|
||||
},
|
||||
{
|
||||
key: 'calendar-mutations',
|
||||
label: 'Modify calendar events',
|
||||
description: 'Should ask before moving or creating calendar events.',
|
||||
defaultMode: 'ask',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function parseCommaSeparatedList(input: string): string[] {
|
||||
return input
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const CONTROL_PLANE_COPY: Record<
|
||||
OpenClawStatus['controlPlaneStatus'],
|
||||
{
|
||||
@@ -267,6 +229,138 @@ const ProviderSelector: FC<ProviderSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
interface PodmanOverridesCardProps {
|
||||
variant: 'inline' | 'standalone'
|
||||
}
|
||||
|
||||
const PodmanOverridesCard: FC<PodmanOverridesCardProps> = ({ variant }) => {
|
||||
const { overrides, loading, saving, error, saveOverrides, clearOverrides } =
|
||||
usePodmanOverrides()
|
||||
|
||||
const [value, setValue] = useState('')
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(variant === 'standalone')
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!touched && overrides) setValue(overrides.podmanPath ?? '')
|
||||
}, [overrides, touched])
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
setLocalError(null)
|
||||
try {
|
||||
await saveOverrides(trimmed)
|
||||
setTouched(false)
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
setLocalError(null)
|
||||
try {
|
||||
await clearOverrides()
|
||||
setValue('')
|
||||
setTouched(false)
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const hasOverride = !!overrides?.podmanPath
|
||||
const effective = overrides?.effectivePodmanPath ?? null
|
||||
const inlineErrorMessage = localError ?? error?.message ?? null
|
||||
|
||||
const body = (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={`podman-path-${variant}`}
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
Podman binary path
|
||||
</label>
|
||||
<Input
|
||||
id={`podman-path-${variant}`}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setTouched(true)
|
||||
setValue(event.target.value)
|
||||
}}
|
||||
placeholder="/opt/homebrew/bin/podman"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Install Podman yourself (e.g. <code>brew install podman</code>) and
|
||||
paste the absolute path to the binary. Restart the gateway after
|
||||
saving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{effective && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Currently using: <code className="break-all">{effective}</code>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{inlineErrorMessage && (
|
||||
<p className="text-destructive text-xs">{inlineErrorMessage}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading || !value.trim()}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
disabled={saving || loading || !hasOverride}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (variant === 'inline') {
|
||||
return (
|
||||
<div className="mt-3 rounded-md border bg-background p-3">
|
||||
<p className="mb-2 font-medium text-sm">Use your own Podman</p>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer py-3"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{collapsed ? (
|
||||
<ChevronRight className="size-4" />
|
||||
) : (
|
||||
<ChevronDown className="size-4" />
|
||||
)}
|
||||
Advanced: Podman binary path
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{!collapsed && <CardContent className="pt-0">{body}</CardContent>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const AgentsPage: FC = () => {
|
||||
const {
|
||||
status,
|
||||
@@ -281,7 +375,6 @@ export const AgentsPage: FC = () => {
|
||||
loading: agentsLoading,
|
||||
error: agentsError,
|
||||
} = useOpenClawAgents(agentsQueryEnabled)
|
||||
const { roles, loading: rolesLoading, error: rolesError } = useOpenClawRoles()
|
||||
const {
|
||||
setupOpenClaw,
|
||||
createAgent,
|
||||
@@ -299,44 +392,15 @@ export const AgentsPage: FC = () => {
|
||||
|
||||
const [setupOpen, setSetupOpen] = useState(false)
|
||||
const [setupProviderId, setSetupProviderId] = useState('')
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [selectedRoleValue, setSelectedRoleValue] = useState<
|
||||
| RoleTemplateSummary['id']
|
||||
| typeof CUSTOM_ROLE_VALUE
|
||||
| typeof PLAIN_AGENT_VALUE
|
||||
>('chief-of-staff')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [createProviderId, setCreateProviderId] = useState('')
|
||||
const [customRole, setCustomRole] = useState<BrowserOSCustomRoleInput>({
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
recommendedApps: [],
|
||||
boundaries: createDefaultCustomRoleBoundaries(),
|
||||
})
|
||||
|
||||
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
|
||||
const [showTerminal, setShowTerminal] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const compatibleProviders = providers.filter(
|
||||
(provider) => provider.apiKey && !OAUTH_ONLY_TYPES.has(provider.type),
|
||||
)
|
||||
const creationMode: AgentCreationMode =
|
||||
selectedRoleValue === CUSTOM_ROLE_VALUE
|
||||
? 'custom'
|
||||
: selectedRoleValue === PLAIN_AGENT_VALUE
|
||||
? 'plain'
|
||||
: 'builtin'
|
||||
const isCustomRole = creationMode === 'custom'
|
||||
const isPlainAgent = creationMode === 'plain'
|
||||
const selectedRole =
|
||||
creationMode === 'builtin'
|
||||
? (roles.find((role) => role.id === selectedRoleValue) ??
|
||||
roles[0] ??
|
||||
null)
|
||||
: null
|
||||
const compatibleProviders = getOpenClawSupportedProviders(providers)
|
||||
|
||||
useEffect(() => {
|
||||
if (compatibleProviders.length === 0) return
|
||||
@@ -355,48 +419,13 @@ export const AgentsPage: FC = () => {
|
||||
defaultProviderId,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen || roles.length === 0) return
|
||||
|
||||
const defaultRole = roles.find((role) => role.id === 'chief-of-staff')
|
||||
const nextRole = defaultRole ?? roles[0]
|
||||
|
||||
setSelectedRoleValue((current) => {
|
||||
if (current === CUSTOM_ROLE_VALUE || current === PLAIN_AGENT_VALUE)
|
||||
return current
|
||||
const hasCurrent = roles.some((role) => role.id === current)
|
||||
return hasCurrent ? current : nextRole.id
|
||||
})
|
||||
setNewName((current) => current || nextRole.defaultAgentName)
|
||||
}, [createOpen, roles])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
|
||||
if (isCustomRole) {
|
||||
setNewName(
|
||||
(current) =>
|
||||
current || customRole.name.trim().toLowerCase().replace(/\s+/g, '-'),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlainAgent) {
|
||||
setNewName((current) => current || 'agent')
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedRole) {
|
||||
setNewName((current) => current || selectedRole.defaultAgentName)
|
||||
}
|
||||
}, [createOpen, isCustomRole, isPlainAgent, customRole.name, selectedRole])
|
||||
setNewName((current) => current || 'agent')
|
||||
}, [createOpen])
|
||||
|
||||
const inlineError =
|
||||
error ??
|
||||
statusError?.message ??
|
||||
agentsError?.message ??
|
||||
rolesError?.message ??
|
||||
null
|
||||
error ?? statusError?.message ?? agentsError?.message ?? null
|
||||
|
||||
const gatewayUiState = useMemo(() => {
|
||||
if (!status) {
|
||||
@@ -462,34 +491,10 @@ export const AgentsPage: FC = () => {
|
||||
(item) => item.id === createProviderId,
|
||||
)
|
||||
const normalizedName = newName.trim().toLowerCase().replace(/\s+/g, '-')
|
||||
const customRolePayload = isCustomRole
|
||||
? {
|
||||
...customRole,
|
||||
name: customRole.name.trim(),
|
||||
shortDescription: customRole.shortDescription.trim(),
|
||||
longDescription: customRole.longDescription.trim(),
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (
|
||||
isCustomRole &&
|
||||
(!customRolePayload?.name ||
|
||||
!customRolePayload.shortDescription ||
|
||||
!customRolePayload.longDescription)
|
||||
) {
|
||||
setError(
|
||||
'Custom roles require a role name, short description, and long description.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (creationMode === 'builtin' && !selectedRole) return
|
||||
|
||||
await runWithErrorHandling(async () => {
|
||||
await createAgent({
|
||||
name: normalizedName,
|
||||
roleId: creationMode === 'builtin' ? selectedRole?.id : undefined,
|
||||
customRole: isCustomRole ? customRolePayload : undefined,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
baseUrl: provider?.baseUrl,
|
||||
@@ -498,13 +503,6 @@ export const AgentsPage: FC = () => {
|
||||
})
|
||||
setCreateOpen(false)
|
||||
setNewName('')
|
||||
setCustomRole({
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
recommendedApps: [],
|
||||
boundaries: createDefaultCustomRoleBoundaries(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -689,6 +687,7 @@ export const AgentsPage: FC = () => {
|
||||
Restart Gateway
|
||||
</Button>
|
||||
</div>
|
||||
<PodmanOverridesCard variant="inline" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -708,6 +707,9 @@ export const AgentsPage: FC = () => {
|
||||
{status.podmanAvailable && (
|
||||
<Button onClick={() => setSetupOpen(true)}>Set Up Now</Button>
|
||||
)}
|
||||
<div className="w-full max-w-md">
|
||||
<PodmanOverridesCard variant="inline" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -751,6 +753,9 @@ export const AgentsPage: FC = () => {
|
||||
Restart Gateway
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full max-w-md">
|
||||
<PodmanOverridesCard variant="inline" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -788,20 +793,10 @@ export const AgentsPage: FC = () => {
|
||||
<CardTitle className="text-base">
|
||||
{agent.name}
|
||||
</CardTitle>
|
||||
{agent.role && (
|
||||
<Badge variant="secondary">
|
||||
{agent.role.roleName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-mono text-muted-foreground text-xs">
|
||||
{agent.workspace}
|
||||
</p>
|
||||
{agent.role && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{agent.role.shortDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -832,6 +827,8 @@ export const AgentsPage: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PodmanOverridesCard variant="standalone" />
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -868,246 +865,6 @@ export const AgentsPage: FC = () => {
|
||||
<DialogTitle>Create Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="agent-role">
|
||||
Agent Role
|
||||
</label>
|
||||
<Select
|
||||
value={selectedRoleValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === CUSTOM_ROLE_VALUE) {
|
||||
setSelectedRoleValue(CUSTOM_ROLE_VALUE)
|
||||
setNewName(
|
||||
customRole.name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-') || 'custom-agent',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (value === PLAIN_AGENT_VALUE) {
|
||||
setSelectedRoleValue(PLAIN_AGENT_VALUE)
|
||||
setNewName('agent')
|
||||
return
|
||||
}
|
||||
|
||||
const role = roles.find((item) => item.id === value)
|
||||
if (!role) return
|
||||
|
||||
setSelectedRoleValue(role.id)
|
||||
setNewName(role.defaultAgentName)
|
||||
}}
|
||||
disabled={rolesLoading}
|
||||
>
|
||||
<SelectTrigger id="agent-role">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
rolesLoading ? 'Loading roles...' : 'Select a role'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={PLAIN_AGENT_VALUE}>Plain Agent</SelectItem>
|
||||
<SelectItem value={CUSTOM_ROLE_VALUE}>Custom Role</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedRole && !isCustomRole && (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-4">
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{selectedRole.name}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{selectedRole.shortDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-xs">
|
||||
Recommended Apps
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{selectedRole.recommendedApps.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-xs">
|
||||
Default Boundaries
|
||||
</div>
|
||||
<ul className="space-y-1 text-muted-foreground text-xs">
|
||||
{selectedRole.boundaries.map((boundary) => (
|
||||
<li key={boundary.key}>
|
||||
{boundary.label}: {boundary.defaultMode}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{isPlainAgent && (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-4">
|
||||
<div className="font-medium text-sm">Plain Agent</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No role bootstrap or defaults. Intended for temporary
|
||||
development and testing only.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCustomRole && (
|
||||
<Card>
|
||||
<CardContent className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="custom-role-name"
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
Custom Role Name
|
||||
</label>
|
||||
<Input
|
||||
id="custom-role-name"
|
||||
value={customRole.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value
|
||||
setCustomRole((current) => ({ ...current, name }))
|
||||
setNewName(
|
||||
name.trim().toLowerCase().replace(/\s+/g, '-') ||
|
||||
'custom-agent',
|
||||
)
|
||||
}}
|
||||
placeholder="Board Prep Operator"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="custom-role-short-description"
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
Short Description
|
||||
</label>
|
||||
<Input
|
||||
id="custom-role-short-description"
|
||||
value={customRole.shortDescription}
|
||||
onChange={(event) =>
|
||||
setCustomRole((current) => ({
|
||||
...current,
|
||||
shortDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Prepares executive briefs and weekly follow-ups."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="custom-role-long-description"
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
Long Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="custom-role-long-description"
|
||||
value={customRole.longDescription}
|
||||
onChange={(event) =>
|
||||
setCustomRole((current) => ({
|
||||
...current,
|
||||
longDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Describe the role, purpose, and what kinds of outcomes this agent should produce."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="custom-role-apps"
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
Recommended Apps
|
||||
</label>
|
||||
<Input
|
||||
id="custom-role-apps"
|
||||
value={customRole.recommendedApps.join(', ')}
|
||||
onChange={(event) =>
|
||||
setCustomRole((current) => ({
|
||||
...current,
|
||||
recommendedApps: parseCommaSeparatedList(
|
||||
event.target.value,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder="gmail, slack, notion"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Comma-separated. Used as role guidance only in this
|
||||
milestone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
Boundary Defaults
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Set the starting behavior for common high-impact
|
||||
actions.
|
||||
</p>
|
||||
</div>
|
||||
{customRole.boundaries.map((boundary) => (
|
||||
<div
|
||||
key={boundary.key}
|
||||
className="grid gap-2 rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{boundary.label}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{boundary.description}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={boundary.defaultMode}
|
||||
onValueChange={(value) =>
|
||||
setCustomRole((current) => ({
|
||||
...current,
|
||||
boundaries: current.boundaries.map((item) =>
|
||||
item.key === boundary.key
|
||||
? {
|
||||
...item,
|
||||
defaultMode:
|
||||
value as BrowserOSRoleBoundary['defaultMode'],
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow</SelectItem>
|
||||
<SelectItem value="ask">Ask</SelectItem>
|
||||
<SelectItem value="block">Block</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="agent-name"
|
||||
@@ -1141,10 +898,8 @@ export const AgentsPage: FC = () => {
|
||||
disabled={
|
||||
!newName.trim() ||
|
||||
creating ||
|
||||
rolesLoading ||
|
||||
!gatewayUiState.canManageAgents ||
|
||||
compatibleProviders.length === 0 ||
|
||||
(creationMode === 'builtin' && !selectedRole)
|
||||
compatibleProviders.length === 0
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
const OPENCLAW_SUPPORTED_PROVIDER_TYPES: ProviderType[] = [
|
||||
'openrouter',
|
||||
'openai',
|
||||
'anthropic',
|
||||
'moonshot',
|
||||
]
|
||||
|
||||
export function isOpenClawSupportedProviderType(
|
||||
providerType: ProviderType,
|
||||
): boolean {
|
||||
return OPENCLAW_SUPPORTED_PROVIDER_TYPES.includes(providerType)
|
||||
}
|
||||
|
||||
export function getOpenClawSupportedProviders(
|
||||
providers: LlmProviderConfig[],
|
||||
): LlmProviderConfig[] {
|
||||
return providers.filter(
|
||||
(provider) =>
|
||||
!!provider.apiKey && isOpenClawSupportedProviderType(provider.type),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSCustomRoleInput,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
@@ -11,27 +7,6 @@ export interface AgentEntry {
|
||||
name: string
|
||||
workspace: string
|
||||
model?: unknown
|
||||
role?: {
|
||||
roleSource: 'builtin' | 'custom'
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
roleName: string
|
||||
shortDescription: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RoleTemplateSummary {
|
||||
id: BrowserOSAgentRoleId
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
recommendedApps: string[]
|
||||
defaultAgentName: string
|
||||
boundaries: Array<{
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
defaultMode: 'allow' | 'ask' | 'block'
|
||||
}>
|
||||
}
|
||||
|
||||
export interface OpenClawStatus {
|
||||
@@ -61,8 +36,6 @@ export interface OpenClawStatus {
|
||||
|
||||
export interface OpenClawAgentMutationInput {
|
||||
name: string
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
customRole?: BrowserOSCustomRoleInput
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
@@ -86,9 +59,14 @@ export function getModelDisplayName(model: unknown): string | undefined {
|
||||
export const OPENCLAW_QUERY_KEYS = {
|
||||
status: 'openclaw-status',
|
||||
agents: 'openclaw-agents',
|
||||
roles: 'openclaw-roles',
|
||||
podmanOverrides: 'openclaw-podman-overrides',
|
||||
} as const
|
||||
|
||||
export interface PodmanOverrides {
|
||||
podmanPath: string | null
|
||||
effectivePodmanPath: string
|
||||
}
|
||||
|
||||
async function clawFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -117,16 +95,6 @@ async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
|
||||
return data.agents ?? []
|
||||
}
|
||||
|
||||
async function fetchOpenClawRoles(
|
||||
baseUrl: string,
|
||||
): Promise<RoleTemplateSummary[]> {
|
||||
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
|
||||
baseUrl,
|
||||
'/roles',
|
||||
)
|
||||
return data.roles ?? []
|
||||
}
|
||||
|
||||
async function invalidateOpenClawQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
): Promise<void> {
|
||||
@@ -179,28 +147,6 @@ export function useOpenClawAgents(enabled = true) {
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenClawRoles() {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<RoleTemplateSummary[], Error>({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
|
||||
queryFn: () => fetchOpenClawRoles(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
return {
|
||||
roles: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenClawMutations() {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -301,6 +247,50 @@ export function useOpenClawMutations() {
|
||||
}
|
||||
}
|
||||
|
||||
export function usePodmanOverrides() {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const query = useQuery<PodmanOverrides, Error>({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides, baseUrl],
|
||||
queryFn: () =>
|
||||
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides'),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (podmanPath: string | null) =>
|
||||
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath }),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.status],
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
overrides: query.data ?? null,
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: (query.error ?? urlError) as Error | null,
|
||||
saving: saveMutation.isPending,
|
||||
saveOverrides: (podmanPath: string) => saveMutation.mutateAsync(podmanPath),
|
||||
clearOverrides: () => saveMutation.mutateAsync(null),
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
|
||||
@@ -7,8 +7,9 @@ import { PRODUCT_WEB_HOST } from './lib/constants/productWebHost'
|
||||
// biome-ignore lint/style/noProcessEnv: build config file needs env access
|
||||
const env = process.env
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: required env var
|
||||
const apiUrl = new URL(env.VITE_PUBLIC_BROWSEROS_API!)
|
||||
const apiUrl = new URL(
|
||||
env.VITE_PUBLIC_BROWSEROS_API?.trim() || 'https://api.browseros.com',
|
||||
)
|
||||
const apiPattern = apiUrl.port
|
||||
? `${apiUrl.hostname}:${apiUrl.port}`
|
||||
: apiUrl.hostname
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.85",
|
||||
"version": "0.0.88",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -10,9 +10,21 @@
|
||||
"scripts": {
|
||||
"start": "bun --watch --env-file=.env.development src/index.ts",
|
||||
"build": "bun ../../scripts/build/server.ts --target=all",
|
||||
"test:tools": "bun run test:cleanup && bun --env-file=.env.development test tests/tools",
|
||||
"test:integration": "bun run test:cleanup && bun --env-file=.env.development test tests/server.integration.test.ts",
|
||||
"test:sdk": "echo 'SDK tests disabled: test environment does not provide the extract/verify LLM service'",
|
||||
"test": "bun run test:all",
|
||||
"test:all": "bun run ./tests/__helpers__/run-test-group.ts all",
|
||||
"test:agent": "bun run ./tests/__helpers__/run-test-group.ts agent",
|
||||
"test:api": "bun run ./tests/__helpers__/run-test-group.ts api",
|
||||
"test:browser": "bun run ./tests/__helpers__/run-test-group.ts browser",
|
||||
"test:cdp": "bun run test:browser",
|
||||
"test:core": "bun run ./tests/__helpers__/run-test-group.ts core",
|
||||
"test:integration": "bun run ./tests/__helpers__/run-test-group.ts integration",
|
||||
"test:root": "bun run ./tests/__helpers__/run-test-group.ts root",
|
||||
"test:sdk": "bun run ./tests/__helpers__/run-test-group.ts sdk",
|
||||
"test:skills": "bun run ./tests/__helpers__/run-test-group.ts skills",
|
||||
"test:tools": "bun run ./tests/__helpers__/run-test-group.ts tools",
|
||||
"test:tools:acl": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/acl-scorer.test.ts",
|
||||
"test:tools:filesystem": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/filesystem",
|
||||
"test:tools:input": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/input.test.ts",
|
||||
"test:cleanup": "./tests/__helpers__/cleanup.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"devtools": "bunx @ai-sdk/devtools"
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
services:
|
||||
openclaw-gateway:
|
||||
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}
|
||||
ports:
|
||||
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
|
||||
environment:
|
||||
- HOME=/home/node
|
||||
- NODE_ENV=production
|
||||
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN}
|
||||
- OPENCLAW_GATEWAY_BIND=lan
|
||||
- TZ=${TZ}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||
- GROQ_API_KEY=${GROQ_API_KEY:-}
|
||||
- MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
|
||||
- MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
|
||||
extra_hosts:
|
||||
- "host.containers.internal:host-gateway"
|
||||
command:
|
||||
- node
|
||||
- dist/index.js
|
||||
- gateway
|
||||
- --bind
|
||||
- lan
|
||||
- --port
|
||||
- "18789"
|
||||
- --allow-unconfigured
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
@@ -7,12 +7,9 @@
|
||||
* Thin layer delegating to OpenClawService.
|
||||
*/
|
||||
|
||||
import { accessSync, existsSync, constants as fsConstants } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import { BROWSEROS_ROLE_TEMPLATES } from '@browseros/shared/constants/role-aware-agents'
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSCustomRoleInput,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { logger } from '../../lib/logger'
|
||||
@@ -22,23 +19,35 @@ import {
|
||||
OpenClawInvalidAgentNameError,
|
||||
OpenClawProtectedAgentError,
|
||||
} from '../services/openclaw/errors'
|
||||
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
|
||||
import { getOpenClawService } from '../services/openclaw/openclaw-service'
|
||||
|
||||
function isValidBoundaryMode(
|
||||
value: unknown,
|
||||
): value is BrowserOSCustomRoleInput['boundaries'][number]['defaultMode'] {
|
||||
return value === 'allow' || value === 'ask' || value === 'block'
|
||||
function getCreateAgentValidationError(body: { name?: string }): string | null {
|
||||
if (!body.name?.trim()) {
|
||||
return 'Name is required'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isValidCustomRoleBoundary(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const boundary = value as Record<string, unknown>
|
||||
return (
|
||||
typeof boundary.key === 'string' &&
|
||||
typeof boundary.label === 'string' &&
|
||||
typeof boundary.description === 'string' &&
|
||||
isValidBoundaryMode(boundary.defaultMode)
|
||||
)
|
||||
function getPodmanOverrideValidationError(body: {
|
||||
podmanPath?: string | null
|
||||
}): string | null {
|
||||
if (body.podmanPath === null) return null
|
||||
if (typeof body.podmanPath !== 'string' || !body.podmanPath.trim()) {
|
||||
return 'podmanPath must be a non-empty absolute path or null'
|
||||
}
|
||||
if (!path.isAbsolute(body.podmanPath)) {
|
||||
return 'podmanPath must be an absolute path'
|
||||
}
|
||||
if (!existsSync(body.podmanPath)) {
|
||||
return `File does not exist: ${body.podmanPath}`
|
||||
}
|
||||
try {
|
||||
accessSync(body.podmanPath, fsConstants.X_OK)
|
||||
} catch {
|
||||
return `File is not executable: ${body.podmanPath}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createOpenClawRoutes() {
|
||||
@@ -89,6 +98,9 @@ export function createOpenClawRoutes() {
|
||||
providerType: body.providerType,
|
||||
providerName: body.providerName,
|
||||
})
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (message.includes('Podman is not available')) {
|
||||
return c.json({ error: message }, 503)
|
||||
}
|
||||
@@ -154,97 +166,23 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
})
|
||||
|
||||
.get('/roles', async (c) => {
|
||||
return c.json({
|
||||
roles: BROWSEROS_ROLE_TEMPLATES.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
recommendedApps: role.recommendedApps,
|
||||
boundaries: role.boundaries,
|
||||
defaultAgentName: role.defaultAgentName,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
.post('/agents', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
name: string
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
customRole?: BrowserOSCustomRoleInput
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}>()
|
||||
const name = body.name?.trim()
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: 'Name is required' }, 400)
|
||||
}
|
||||
if (body.roleId && body.customRole) {
|
||||
return c.json(
|
||||
{ error: 'Provide either roleId or customRole, not both' },
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
(!body.customRole.name?.trim() ||
|
||||
!body.customRole.shortDescription?.trim() ||
|
||||
!body.customRole.longDescription?.trim())
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Custom roles require name, shortDescription, and longDescription',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
(!Array.isArray(body.customRole.recommendedApps) ||
|
||||
!Array.isArray(body.customRole.boundaries))
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Custom roles require recommendedApps and boundaries arrays',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
!body.customRole.recommendedApps.every((app) => typeof app === 'string')
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Custom role recommendedApps must be an array of strings',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
!body.customRole.boundaries.every(isValidCustomRoleBoundary)
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Custom role boundaries must include key, label, description, and a valid defaultMode',
|
||||
},
|
||||
400,
|
||||
)
|
||||
const validationError = getCreateAgentValidationError(body)
|
||||
if (validationError) {
|
||||
return c.json({ error: validationError }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await getOpenClawService().createAgent({
|
||||
name,
|
||||
roleId: body.roleId,
|
||||
customRole: body.customRole,
|
||||
name: body.name.trim(),
|
||||
providerType: body.providerType,
|
||||
providerName: body.providerName,
|
||||
baseUrl: body.baseUrl,
|
||||
@@ -259,6 +197,9 @@ export function createOpenClawRoutes() {
|
||||
if (err instanceof OpenClawInvalidAgentNameError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
@@ -323,6 +264,9 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
@@ -352,14 +296,52 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
|
||||
try {
|
||||
await getOpenClawService().updateProviderKeys(body)
|
||||
const result = await getOpenClawService().updateProviderKeys(body)
|
||||
return c.json({
|
||||
status: 'restarting',
|
||||
message: 'Provider updated, restarting gateway',
|
||||
status: result.restarted ? 'restarting' : 'updated',
|
||||
message: result.restarted
|
||||
? 'Provider updated, restarting gateway'
|
||||
: 'Provider updated without a restart',
|
||||
})
|
||||
} catch (err) {
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/podman-overrides', async (c) => {
|
||||
try {
|
||||
const overrides = await getOpenClawService().getPodmanOverrides()
|
||||
return c.json(overrides)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error('Podman overrides read failed', { error: message })
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/podman-overrides', async (c) => {
|
||||
const body = await c.req.json<{ podmanPath: string | null }>()
|
||||
const validationError = getPodmanOverrideValidationError(body)
|
||||
if (validationError) {
|
||||
return c.json({ error: validationError }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('OpenClaw podman override requested', {
|
||||
podmanPath: body.podmanPath,
|
||||
})
|
||||
const result = await getOpenClawService().applyPodmanOverrides({
|
||||
podmanPath: body.podmanPath,
|
||||
})
|
||||
return c.json(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error('Podman overrides apply failed', { error: message })
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,21 +3,24 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Compose-level abstraction over PodmanRuntime.
|
||||
* Manages a single compose project for the OpenClaw gateway container.
|
||||
* OpenClaw container lifecycle abstraction over PodmanRuntime.
|
||||
*/
|
||||
|
||||
import { copyFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
OPENCLAW_COMPOSE_PROJECT_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { LogFn, PodmanRuntime } from './podman-runtime'
|
||||
|
||||
const COMPOSE_FILE_NAME = 'docker-compose.yml'
|
||||
const ENV_FILE_NAME = '.env'
|
||||
const GATEWAY_CONTAINER_HOME = '/home/node'
|
||||
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
|
||||
|
||||
export type GatewayContainerSpec = {
|
||||
image: string
|
||||
port: number
|
||||
hostHome: string
|
||||
envFilePath: string
|
||||
gatewayToken?: string
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export class ContainerRuntime {
|
||||
constructor(
|
||||
@@ -41,35 +44,69 @@ export class ContainerRuntime {
|
||||
return this.podman.getMachineStatus()
|
||||
}
|
||||
|
||||
async composeUp(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['up', '-d'], onLog)
|
||||
if (code !== 0) throw new Error(`compose up failed with code ${code}`)
|
||||
async pullImage(image: string, onLog?: LogFn): Promise<void> {
|
||||
const code = await this.runPodmanCommand(['pull', image], onLog)
|
||||
if (code !== 0) throw new Error(`image pull failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeDown(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['down'], onLog)
|
||||
if (code !== 0) throw new Error(`compose down failed with code ${code}`)
|
||||
async startGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.ensureGatewayRemoved(onLog)
|
||||
const code = await this.runPodmanCommand(
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${input.port}:18789`,
|
||||
...this.buildGatewayContainerRuntimeArgs(input),
|
||||
'--health-cmd',
|
||||
'curl -sf http://127.0.0.1:18789/healthz',
|
||||
'--health-interval',
|
||||
'30s',
|
||||
'--health-timeout',
|
||||
'10s',
|
||||
'--health-retries',
|
||||
'3',
|
||||
input.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
'18789',
|
||||
'--allow-unconfigured',
|
||||
],
|
||||
onLog,
|
||||
)
|
||||
if (code !== 0) throw new Error(`gateway start failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeStop(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['stop'], onLog)
|
||||
if (code !== 0) throw new Error(`compose stop failed with code ${code}`)
|
||||
async stopGateway(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.removeGatewayContainer(onLog)
|
||||
if (code !== 0) {
|
||||
throw new Error(`gateway stop failed with code ${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
async composeRestart(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['restart'], onLog)
|
||||
if (code !== 0) throw new Error(`compose restart failed with code ${code}`)
|
||||
async restartGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.startGateway(input, onLog)
|
||||
}
|
||||
|
||||
async composePull(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['pull', '--quiet'], onLog)
|
||||
if (code !== 0) throw new Error(`compose pull failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeLogs(tail = 50): Promise<string[]> {
|
||||
async getGatewayLogs(tail = 50): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
|
||||
lines.push(line),
|
||||
await this.runPodmanCommand(
|
||||
['logs', '--tail', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
(line) => lines.push(line),
|
||||
)
|
||||
return lines
|
||||
}
|
||||
@@ -112,16 +149,6 @@ export class ContainerRuntime {
|
||||
return false
|
||||
}
|
||||
|
||||
async copyComposeFile(sourceTemplatePath: string): Promise<void> {
|
||||
await copyFile(sourceTemplatePath, join(this.projectDir, COMPOSE_FILE_NAME))
|
||||
}
|
||||
|
||||
async writeEnvFile(content: string): Promise<void> {
|
||||
await writeFile(join(this.projectDir, ENV_FILE_NAME), content, {
|
||||
mode: 0o600,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the Podman machine only if no non-BrowserOS containers are running.
|
||||
* Prevents killing the user's own Podman workloads.
|
||||
@@ -132,8 +159,8 @@ export class ContainerRuntime {
|
||||
|
||||
try {
|
||||
const containers = await this.podman.listRunningContainers()
|
||||
const allOurs = containers.every((name) =>
|
||||
name.startsWith(OPENCLAW_COMPOSE_PROJECT_NAME),
|
||||
const allOurs = containers.every(
|
||||
(name) => name === OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
|
||||
if (containers.length === 0 || allOurs) {
|
||||
@@ -153,6 +180,32 @@ export class ContainerRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
async runGatewaySetupCommand(
|
||||
command: string[],
|
||||
spec: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
|
||||
await this.runPodmanCommand(
|
||||
['rm', '-f', '--ignore', setupContainerName],
|
||||
onLog,
|
||||
)
|
||||
const setupArgs = command[0] === 'node' ? command.slice(1) : command
|
||||
return this.runPodmanCommand(
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--name',
|
||||
setupContainerName,
|
||||
...this.buildGatewayContainerRuntimeArgs(spec),
|
||||
spec.image,
|
||||
'node',
|
||||
...setupArgs,
|
||||
],
|
||||
onLog,
|
||||
)
|
||||
}
|
||||
|
||||
tailGatewayLogs(onLine: LogFn): () => void {
|
||||
return this.podman.tailContainerLogs(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
@@ -160,15 +213,17 @@ export class ContainerRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
private async compose(args: string[], onLog?: LogFn): Promise<number> {
|
||||
private async runPodmanCommand(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const lines: string[] = []
|
||||
const command = ['podman', 'compose', ...args].join(' ')
|
||||
logger.info('Running OpenClaw compose command', {
|
||||
const command = ['podman', ...args].join(' ')
|
||||
logger.info('Running OpenClaw podman command', {
|
||||
command,
|
||||
})
|
||||
const code = await this.podman.runCommand(['compose', ...args], {
|
||||
const code = await this.podman.runCommand(args, {
|
||||
cwd: this.projectDir,
|
||||
env: { COMPOSE_PROJECT_NAME: OPENCLAW_COMPOSE_PROJECT_NAME },
|
||||
onOutput: (line) => {
|
||||
lines.push(line)
|
||||
onLog?.(line)
|
||||
@@ -176,17 +231,58 @@ export class ContainerRuntime {
|
||||
})
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error('OpenClaw compose command failed', {
|
||||
logger.error('OpenClaw podman command failed', {
|
||||
command,
|
||||
exitCode: code,
|
||||
output: lines,
|
||||
})
|
||||
} else {
|
||||
logger.info('OpenClaw compose command succeeded', {
|
||||
logger.info('OpenClaw podman command succeeded', {
|
||||
command,
|
||||
})
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
private async ensureGatewayRemoved(onLog?: LogFn): Promise<void> {
|
||||
await this.removeGatewayContainer(onLog)
|
||||
}
|
||||
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<number> {
|
||||
return this.runPodmanCommand(
|
||||
['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
onLog,
|
||||
)
|
||||
}
|
||||
|
||||
private buildGatewayContainerRuntimeArgs(
|
||||
input: GatewayContainerSpec,
|
||||
): string[] {
|
||||
return [
|
||||
'--env-file',
|
||||
input.envFilePath,
|
||||
'-e',
|
||||
`HOME=${GATEWAY_CONTAINER_HOME}`,
|
||||
'-e',
|
||||
`OPENCLAW_HOME=${GATEWAY_CONTAINER_HOME}`,
|
||||
'-e',
|
||||
`OPENCLAW_STATE_DIR=${GATEWAY_STATE_DIR}`,
|
||||
'-e',
|
||||
'OPENCLAW_NO_RESPAWN=1',
|
||||
'-e',
|
||||
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
|
||||
'-e',
|
||||
'NODE_ENV=production',
|
||||
'-e',
|
||||
`TZ=${input.timezone}`,
|
||||
'-v',
|
||||
`${input.hostHome}:${GATEWAY_CONTAINER_HOME}`,
|
||||
'--add-host',
|
||||
'host.containers.internal:host-gateway',
|
||||
...(input.gatewayToken
|
||||
? ['-e', `OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,754 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* WebSocket client for the OpenClaw Gateway protocol.
|
||||
* Handles handshake (challenge → connect → hello-ok) with Ed25519 device
|
||||
* identity signing, JSON-RPC over WS, and auto-reconnect.
|
||||
* Used for agent CRUD and health — chat uses HTTP.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
const RPC_TIMEOUT_MS = 15_000
|
||||
const SCOPES = [
|
||||
'operator.read',
|
||||
'operator.write',
|
||||
'operator.admin',
|
||||
'operator.approvals',
|
||||
'operator.pairing',
|
||||
]
|
||||
|
||||
interface DeviceIdentity {
|
||||
deviceId: string
|
||||
publicKeyPem: string
|
||||
privateKeyPem: string
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface WsFrame {
|
||||
type: 'req' | 'res' | 'event'
|
||||
id?: string
|
||||
method?: string
|
||||
params?: Record<string, unknown>
|
||||
ok?: boolean
|
||||
payload?: Record<string, unknown>
|
||||
error?: { message: string; code?: string }
|
||||
event?: string
|
||||
}
|
||||
|
||||
export type GatewayClientConnectionState =
|
||||
| 'idle'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'closed'
|
||||
| 'failed'
|
||||
|
||||
export interface GatewayHandshakeError {
|
||||
code?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
| 'thinking'
|
||||
| 'tool-start'
|
||||
| 'tool-end'
|
||||
| 'tool-output'
|
||||
| 'lifecycle'
|
||||
| 'done'
|
||||
| 'error'
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface GatewayAgentEntry {
|
||||
agentId: string
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
// ── Device Identity Helpers ─────────────────────────────────────────
|
||||
|
||||
function rawPublicKeyFromPem(pem: string): Buffer {
|
||||
const der = Buffer.from(
|
||||
pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''),
|
||||
'base64',
|
||||
)
|
||||
return der.subarray(12)
|
||||
}
|
||||
|
||||
function signChallenge(
|
||||
device: DeviceIdentity,
|
||||
nonce: string,
|
||||
token: string,
|
||||
): { signature: string; signedAt: number; publicKey: string } {
|
||||
const signedAt = Date.now()
|
||||
const payload = `v3|${device.deviceId}|cli|cli|operator|${SCOPES.join(',')}|${signedAt}|${token}|${nonce}|${process.platform}|`
|
||||
const privateKey = crypto.createPrivateKey(device.privateKeyPem)
|
||||
const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), privateKey)
|
||||
|
||||
return {
|
||||
signature: sig.toString('base64url'),
|
||||
signedAt,
|
||||
publicKey: rawPublicKeyFromPem(device.publicKeyPem).toString('base64url'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a client Ed25519 identity and pre-seeds it into the gateway's
|
||||
* paired devices file so the gateway trusts it on next boot.
|
||||
* Must be called before compose up (or requires a restart after).
|
||||
*/
|
||||
export function ensureClientIdentity(openclawDir: string): DeviceIdentity {
|
||||
const identityPath = join(openclawDir, 'client-identity.json')
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(identityPath, 'utf-8'))
|
||||
} catch {
|
||||
// Generate new identity
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
||||
const publicKeyPem = publicKey
|
||||
.export({ type: 'spki', format: 'pem' })
|
||||
.toString()
|
||||
const privateKeyPem = privateKey
|
||||
.export({ type: 'pkcs8', format: 'pem' })
|
||||
.toString()
|
||||
|
||||
const rawPub = rawPublicKeyFromPem(publicKeyPem)
|
||||
const deviceId = crypto.createHash('sha256').update(rawPub).digest('hex')
|
||||
|
||||
const identity: DeviceIdentity = { deviceId, publicKeyPem, privateKeyPem }
|
||||
writeFileSync(identityPath, JSON.stringify(identity, null, 2), {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
seedPairedDevice(openclawDir, identity)
|
||||
logger.info('Generated client device identity and pre-seeded pairing')
|
||||
|
||||
return identity
|
||||
}
|
||||
|
||||
function seedPairedDevice(openclawDir: string, identity: DeviceIdentity): void {
|
||||
const devicesDir = join(openclawDir, 'devices')
|
||||
mkdirSync(devicesDir, { recursive: true })
|
||||
|
||||
const pairedPath = join(devicesDir, 'paired.json')
|
||||
let paired: Record<string, unknown> = {}
|
||||
try {
|
||||
paired = JSON.parse(readFileSync(pairedPath, 'utf-8'))
|
||||
} catch {
|
||||
// First time
|
||||
}
|
||||
|
||||
const rawPub = rawPublicKeyFromPem(identity.publicKeyPem)
|
||||
paired[identity.deviceId] = {
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: rawPub.toString('base64url'),
|
||||
platform: process.platform,
|
||||
clientId: 'cli',
|
||||
clientMode: 'cli',
|
||||
role: 'operator',
|
||||
roles: ['operator'],
|
||||
scopes: SCOPES,
|
||||
pairedAt: Date.now(),
|
||||
label: 'browseros-server',
|
||||
}
|
||||
|
||||
writeFileSync(pairedPath, JSON.stringify(paired, null, 2), { mode: 0o600 })
|
||||
}
|
||||
|
||||
// ── Gateway Client ──────────────────────────────────────────────────
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null
|
||||
private _connected = false
|
||||
private pendingRequests = new Map<string, PendingRequest>()
|
||||
private device: DeviceIdentity | null = null
|
||||
private connectionState: GatewayClientConnectionState = 'idle'
|
||||
private lastHandshakeError: GatewayHandshakeError | null = null
|
||||
|
||||
constructor(
|
||||
private readonly port: number,
|
||||
private readonly token: string,
|
||||
private readonly openclawDir: string,
|
||||
private readonly version = '1.0.0',
|
||||
) {
|
||||
try {
|
||||
const identityPath = join(this.openclawDir, 'client-identity.json')
|
||||
this.device = JSON.parse(readFileSync(identityPath, 'utf-8'))
|
||||
} catch {
|
||||
logger.warn('Client device identity not found, WS auth may fail')
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
get state(): GatewayClientConnectionState {
|
||||
return this.connectionState
|
||||
}
|
||||
|
||||
get lastError(): GatewayHandshakeError | null {
|
||||
return this.lastHandshakeError
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.connectionState = 'connecting'
|
||||
this.lastHandshakeError = null
|
||||
logger.info('Connecting to OpenClaw Gateway WS', {
|
||||
port: this.port,
|
||||
hasDeviceIdentity: !!this.device,
|
||||
})
|
||||
this.ws = new WebSocket(`ws://127.0.0.1:${this.port}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${this.port}` },
|
||||
} as unknown as string[])
|
||||
|
||||
let handshakeComplete = false
|
||||
let connectReqId: string | null = null
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const frame = GatewayClient.parseFrame(event.data)
|
||||
if (!frame) return
|
||||
|
||||
if (!handshakeComplete) {
|
||||
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
||||
const nonce = (frame.payload as Record<string, unknown>)
|
||||
?.nonce as string
|
||||
logger.info('Received OpenClaw Gateway challenge', {
|
||||
hasNonce: !!nonce,
|
||||
hasDeviceIdentity: !!this.device,
|
||||
})
|
||||
connectReqId = globalThis.crypto.randomUUID()
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'cli',
|
||||
version: this.version,
|
||||
platform: process.platform,
|
||||
mode: 'cli',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: SCOPES,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
auth: { token: this.token },
|
||||
locale: 'en-US',
|
||||
userAgent: `browseros-server/${this.version}`,
|
||||
}
|
||||
|
||||
if (this.device && nonce) {
|
||||
const signed = signChallenge(this.device, nonce, this.token)
|
||||
params.device = {
|
||||
id: this.device.deviceId,
|
||||
publicKey: signed.publicKey,
|
||||
signature: signed.signature,
|
||||
signedAt: signed.signedAt,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
this.ws?.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: connectReqId,
|
||||
method: 'connect',
|
||||
params,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.type === 'res' && frame.id === connectReqId) {
|
||||
if (frame.ok) {
|
||||
handshakeComplete = true
|
||||
this._connected = true
|
||||
this.connectionState = 'connected'
|
||||
logger.info('Gateway WS connected')
|
||||
resolve()
|
||||
} else {
|
||||
const msg = frame.error?.message ?? 'Handshake failed'
|
||||
this.connectionState = 'failed'
|
||||
this.lastHandshakeError = {
|
||||
message: msg,
|
||||
code: frame.error?.code,
|
||||
}
|
||||
logger.error('Gateway WS handshake rejected', {
|
||||
error: msg,
|
||||
code: frame.error?.code,
|
||||
})
|
||||
reject(new Error(msg))
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.resolvePendingRequest(frame)
|
||||
}
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
logger.error('Gateway WS socket error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
handshakeComplete,
|
||||
})
|
||||
if (!handshakeComplete) {
|
||||
this.connectionState = 'failed'
|
||||
reject(
|
||||
new Error(
|
||||
`WS connection error: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this._connected = false
|
||||
this.connectionState = 'closed'
|
||||
this.rejectAllPending('WebSocket closed')
|
||||
if (handshakeComplete) {
|
||||
logger.info('Gateway WS disconnected')
|
||||
}
|
||||
this.ws = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this._connected = false
|
||||
this.connectionState = 'closed'
|
||||
this.rejectAllPending('Client disconnecting')
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPC ──────────────────────────────────────────────────────────────
|
||||
|
||||
async rpc<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
if (!this._connected || !this.ws) {
|
||||
throw new Error('Gateway WS not connected')
|
||||
}
|
||||
const id = globalThis.crypto.randomUUID()
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(id)
|
||||
reject(new Error(`RPC timeout: ${method}`))
|
||||
}, RPC_TIMEOUT_MS)
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timer,
|
||||
})
|
||||
|
||||
this.ws?.send(JSON.stringify({ type: 'req', id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
// ── Agent Methods ────────────────────────────────────────────────────
|
||||
|
||||
async listAgents(): Promise<GatewayAgentEntry[]> {
|
||||
const result = await this.rpc<{
|
||||
agents: Array<{
|
||||
id: string
|
||||
name?: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}>
|
||||
}>('agents.list')
|
||||
|
||||
return (result.agents ?? []).map((a) => ({
|
||||
agentId: a.id,
|
||||
name: a.name ?? a.id,
|
||||
workspace: a.workspace,
|
||||
model: a.model,
|
||||
}))
|
||||
}
|
||||
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}): Promise<GatewayAgentEntry> {
|
||||
const result = await this.rpc<{
|
||||
agentId?: string
|
||||
id?: string
|
||||
name?: string
|
||||
workspace?: string
|
||||
model?: string
|
||||
}>('agents.create', input)
|
||||
|
||||
return {
|
||||
agentId: result.agentId ?? result.id ?? input.name,
|
||||
name: result.name ?? input.name,
|
||||
workspace: result.workspace ?? input.workspace,
|
||||
model: result.model ?? input.model,
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
await this.rpc('agents.delete', { id: agentId })
|
||||
}
|
||||
|
||||
// ── Health ───────────────────────────────────────────────────────────
|
||||
|
||||
async getHealth(): Promise<Record<string, unknown>> {
|
||||
return this.rpc('health')
|
||||
}
|
||||
|
||||
// ── Chat Stream ─────────────────────────────────────────────────────
|
||||
|
||||
chatStream(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
message: string,
|
||||
): ReadableStream<OpenClawStreamEvent> {
|
||||
if (!this._connected) {
|
||||
throw new Error('Gateway WS not connected')
|
||||
}
|
||||
|
||||
const fullSessionKey = `agent:${agentId}:browseros-${sessionKey}`
|
||||
const idempotencyKey = globalThis.crypto.randomUUID()
|
||||
const streamClient = new GatewayClient(
|
||||
this.port,
|
||||
this.token,
|
||||
this.openclawDir,
|
||||
this.version,
|
||||
)
|
||||
|
||||
return new ReadableStream<OpenClawStreamEvent>({
|
||||
start: async (controller) => {
|
||||
try {
|
||||
await streamClient.connect()
|
||||
} catch (error) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Gateway WS not connected',
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
const ws = streamClient.ws
|
||||
if (!ws) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: { message: 'Gateway WS not connected' },
|
||||
})
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
const subscribeId = globalThis.crypto.randomUUID()
|
||||
const agentReqId = globalThis.crypto.randomUUID()
|
||||
let finished = false
|
||||
|
||||
const finish = (event?: OpenClawStreamEvent) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
if (event) controller.enqueue(event)
|
||||
controller.close()
|
||||
streamClient.disconnect()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const frame = GatewayClient.parseFrame(event.data)
|
||||
if (!frame) return
|
||||
|
||||
if (
|
||||
this.handleChatStreamControlFrame(
|
||||
frame,
|
||||
subscribeId,
|
||||
agentReqId,
|
||||
finish,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.handleChatStreamEventFrame(frame, controller, finish)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (finished) return
|
||||
finish({
|
||||
type: 'error',
|
||||
data: { message: 'Gateway WS disconnected' },
|
||||
})
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (finished) return
|
||||
finish({
|
||||
type: 'error',
|
||||
data: { message: 'Gateway WS connection error' },
|
||||
})
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: subscribeId,
|
||||
method: 'sessions.subscribe',
|
||||
params: { sessionKey: fullSessionKey },
|
||||
}),
|
||||
)
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: agentReqId,
|
||||
method: 'agent',
|
||||
params: {
|
||||
message,
|
||||
sessionKey: fullSessionKey,
|
||||
idempotencyKey,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
cancel: () => {
|
||||
if (streamClient.ws?.readyState === WebSocket.OPEN) {
|
||||
streamClient.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
method: 'sessions.abort',
|
||||
params: { sessionKey: fullSessionKey },
|
||||
}),
|
||||
)
|
||||
}
|
||||
streamClient.disconnect()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
static agentWorkspace(name: string): string {
|
||||
return name === 'main'
|
||||
? `${OPENCLAW_CONTAINER_HOME}/workspace`
|
||||
: `${OPENCLAW_CONTAINER_HOME}/workspace-${name}`
|
||||
}
|
||||
|
||||
private static parseFrame(data: unknown): WsFrame | null {
|
||||
try {
|
||||
return JSON.parse(
|
||||
typeof data === 'string'
|
||||
? data
|
||||
: new TextDecoder().decode(data as ArrayBuffer),
|
||||
) as WsFrame
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private rejectAllPending(reason: string): void {
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(new Error(reason))
|
||||
this.pendingRequests.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePendingRequest(frame: WsFrame): void {
|
||||
if (frame.type !== 'res' || !frame.id) return
|
||||
|
||||
const pending = this.pendingRequests.get(frame.id)
|
||||
if (!pending) return
|
||||
|
||||
this.pendingRequests.delete(frame.id)
|
||||
clearTimeout(pending.timer)
|
||||
if (frame.ok) {
|
||||
pending.resolve(frame.payload)
|
||||
} else {
|
||||
pending.reject(new Error(frame.error?.message ?? 'RPC error'))
|
||||
}
|
||||
}
|
||||
|
||||
private handleChatStreamControlFrame(
|
||||
frame: WsFrame,
|
||||
subscribeId: string,
|
||||
agentReqId: string,
|
||||
finish: (event?: OpenClawStreamEvent) => void,
|
||||
): boolean {
|
||||
if (frame.type !== 'res' || !frame.id) return false
|
||||
if (frame.id !== subscribeId && frame.id !== agentReqId) return false
|
||||
|
||||
if (!frame.ok) {
|
||||
finish({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: frame.error?.message ?? 'RPC error',
|
||||
code: frame.error?.code,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private handleChatStreamEventFrame(
|
||||
frame: WsFrame,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
finish: (event?: OpenClawStreamEvent) => void,
|
||||
): void {
|
||||
if (frame.type !== 'event' || !frame.event || !frame.payload) return
|
||||
|
||||
switch (frame.event) {
|
||||
case 'agent':
|
||||
this.handleAgentStreamEvent(frame.payload, controller)
|
||||
return
|
||||
case 'session.tool':
|
||||
this.handleSessionToolStreamEvent(frame.payload, controller)
|
||||
return
|
||||
case 'session.message':
|
||||
this.handleSessionMessageStreamEvent(frame.payload, controller)
|
||||
return
|
||||
case 'chat':
|
||||
this.handleChatCompletionEvent(frame.payload, finish)
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentStreamEvent(
|
||||
payload: Record<string, unknown>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
): void {
|
||||
const streamType = payload.stream as string | undefined
|
||||
const data = payload.data as Record<string, unknown> | undefined
|
||||
|
||||
if (streamType === 'assistant' && data?.delta) {
|
||||
controller.enqueue({
|
||||
type: 'text-delta',
|
||||
data: { text: data.delta },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (streamType === 'item' && data) {
|
||||
const phase = data.phase as string | undefined
|
||||
if (phase === 'start') {
|
||||
controller.enqueue({
|
||||
type: 'tool-start',
|
||||
data: {
|
||||
toolCallId: data.toolCallId ?? data.id,
|
||||
toolName: data.name ?? data.title,
|
||||
kind: data.kind,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'end') {
|
||||
controller.enqueue({
|
||||
type: 'tool-end',
|
||||
data: {
|
||||
toolCallId: data.toolCallId ?? data.id,
|
||||
status: data.status,
|
||||
durationMs: data.durationMs,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (streamType === 'lifecycle') {
|
||||
controller.enqueue({
|
||||
type: 'lifecycle',
|
||||
data: { phase: data?.phase ?? payload.phase },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleSessionToolStreamEvent(
|
||||
payload: Record<string, unknown>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
): void {
|
||||
const toolData = (payload.data as Record<string, unknown>) ?? payload
|
||||
const phase = (toolData.phase as string) ?? (payload.phase as string)
|
||||
if (phase !== 'result') return
|
||||
|
||||
controller.enqueue({
|
||||
type: 'tool-output',
|
||||
data: {
|
||||
toolCallId: toolData.toolCallId,
|
||||
isError: toolData.isError ?? false,
|
||||
meta: toolData.meta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private handleSessionMessageStreamEvent(
|
||||
payload: Record<string, unknown>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
): void {
|
||||
const message = payload.message as Record<string, unknown> | undefined
|
||||
if (message?.role !== 'assistant') return
|
||||
|
||||
const content = message.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined
|
||||
if (!content) return
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type !== 'thinking') continue
|
||||
|
||||
const text =
|
||||
(block.thinking as string) ??
|
||||
(block.content as string) ??
|
||||
(block.text as string) ??
|
||||
''
|
||||
if (!text) continue
|
||||
|
||||
controller.enqueue({
|
||||
type: 'thinking',
|
||||
data: { text },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleChatCompletionEvent(
|
||||
payload: Record<string, unknown>,
|
||||
finish: (event?: OpenClawStreamEvent) => void,
|
||||
): void {
|
||||
if ((payload.state as string | undefined) !== 'final') return
|
||||
|
||||
finish({
|
||||
type: 'done',
|
||||
data: { text: (payload.text as string) ?? '' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
|
||||
type LogFn = (line: string) => void
|
||||
|
||||
interface ContainerExecutor {
|
||||
execInContainer(command: string[], onLog?: LogFn): Promise<number>
|
||||
}
|
||||
|
||||
export interface OpenClawConfigBatchEntry {
|
||||
path: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface RawAgentRecord {
|
||||
id: string
|
||||
name?: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface OpenClawAgentRecord {
|
||||
agentId: string
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export class OpenClawCliClient {
|
||||
constructor(private readonly executor: ContainerExecutor) {}
|
||||
|
||||
async runOnboard(
|
||||
input: {
|
||||
acceptRisk?: boolean
|
||||
authChoice?: string
|
||||
customBaseUrl?: string
|
||||
customCompatibility?: 'anthropic' | 'openai-completions'
|
||||
customModelId?: string
|
||||
customProviderId?: string
|
||||
gatewayAuth?: 'none' | 'password' | 'token'
|
||||
gatewayBind?: 'auto' | 'custom' | 'lan' | 'loopback' | 'tailnet'
|
||||
gatewayPort?: number
|
||||
gatewayToken?: string
|
||||
gatewayTokenRefEnv?: string
|
||||
installDaemon?: boolean
|
||||
mode?: 'local' | 'remote'
|
||||
nonInteractive?: boolean
|
||||
reset?: boolean
|
||||
resetScope?: 'config' | 'config+creds+sessions' | 'full'
|
||||
secretInputMode?: 'plain' | 'ref'
|
||||
skipHealth?: boolean
|
||||
workspace?: string
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const args = ['onboard']
|
||||
|
||||
if (input.nonInteractive) {
|
||||
args.push('--non-interactive')
|
||||
}
|
||||
if (input.mode) {
|
||||
args.push('--mode', input.mode)
|
||||
}
|
||||
if (input.workspace) {
|
||||
args.push('--workspace', input.workspace)
|
||||
}
|
||||
if (input.reset) {
|
||||
args.push('--reset')
|
||||
}
|
||||
if (input.resetScope) {
|
||||
args.push('--reset-scope', input.resetScope)
|
||||
}
|
||||
if (input.authChoice) {
|
||||
args.push('--auth-choice', input.authChoice)
|
||||
}
|
||||
if (input.secretInputMode) {
|
||||
args.push('--secret-input-mode', input.secretInputMode)
|
||||
}
|
||||
if (input.customBaseUrl) {
|
||||
args.push('--custom-base-url', input.customBaseUrl)
|
||||
}
|
||||
if (input.customModelId) {
|
||||
args.push('--custom-model-id', input.customModelId)
|
||||
}
|
||||
if (input.customProviderId) {
|
||||
args.push('--custom-provider-id', input.customProviderId)
|
||||
}
|
||||
if (input.customCompatibility) {
|
||||
args.push('--custom-compatibility', input.customCompatibility)
|
||||
}
|
||||
if (input.gatewayAuth) {
|
||||
args.push('--gateway-auth', input.gatewayAuth)
|
||||
}
|
||||
if (input.gatewayToken) {
|
||||
args.push('--gateway-token', input.gatewayToken)
|
||||
}
|
||||
if (input.gatewayTokenRefEnv) {
|
||||
args.push('--gateway-token-ref-env', input.gatewayTokenRefEnv)
|
||||
}
|
||||
if (input.gatewayPort) {
|
||||
args.push('--gateway-port', String(input.gatewayPort))
|
||||
}
|
||||
if (input.gatewayBind) {
|
||||
args.push('--gateway-bind', input.gatewayBind)
|
||||
}
|
||||
if (input.installDaemon === true) {
|
||||
args.push('--install-daemon')
|
||||
} else if (input.installDaemon === false) {
|
||||
args.push('--no-install-daemon')
|
||||
}
|
||||
if (input.skipHealth) {
|
||||
args.push('--skip-health')
|
||||
}
|
||||
if (input.acceptRisk) {
|
||||
args.push('--accept-risk')
|
||||
}
|
||||
|
||||
await this.runCommand(args)
|
||||
}
|
||||
|
||||
async setConfig(path: string, value: unknown): Promise<void> {
|
||||
await this.runCommand(['config', 'set', path, formatConfigValue(value)])
|
||||
}
|
||||
|
||||
async setConfigBatch(entries: OpenClawConfigBatchEntry[]): Promise<void> {
|
||||
await this.runCommand([
|
||||
'config',
|
||||
'set',
|
||||
'--batch-json',
|
||||
JSON.stringify(entries),
|
||||
])
|
||||
}
|
||||
|
||||
async getConfig(path: string): Promise<unknown> {
|
||||
const output = await this.runCommand(['config', 'get', path])
|
||||
return parseConfigValue(output)
|
||||
}
|
||||
|
||||
async validateConfig(): Promise<unknown> {
|
||||
const output = await this.runCommand(['config', 'validate', '--json'])
|
||||
return parseConfigValue(output)
|
||||
}
|
||||
|
||||
async setDefaultModel(model: string): Promise<void> {
|
||||
await this.runCommand(['models', 'set', model])
|
||||
}
|
||||
|
||||
async listAgents(): Promise<OpenClawAgentRecord[]> {
|
||||
const records = await this.runAgentListCommand()
|
||||
const agents = Array.isArray(records) ? records : (records.agents ?? [])
|
||||
return agents.map((record) => ({
|
||||
agentId: record.id,
|
||||
name: record.name ?? record.id,
|
||||
workspace: record.workspace,
|
||||
model: record.model,
|
||||
}))
|
||||
}
|
||||
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
model?: string
|
||||
}): Promise<OpenClawAgentRecord> {
|
||||
const workspace = this.agentWorkspace(input.name)
|
||||
const args = ['agents', 'add', input.name, '--workspace', workspace]
|
||||
|
||||
if (input.model) {
|
||||
args.push('--model', input.model)
|
||||
}
|
||||
|
||||
args.push('--non-interactive', '--json')
|
||||
await this.runCommand(args)
|
||||
|
||||
const agents = await this.listAgents()
|
||||
const agent = agents.find((entry) => entry.agentId === input.name)
|
||||
if (!agent) {
|
||||
throw new Error(`Created agent ${input.name} was not found in agent list`)
|
||||
}
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
await this.runCommand(['agents', 'delete', agentId, '--force', '--json'])
|
||||
}
|
||||
|
||||
async probe(): Promise<void> {
|
||||
await this.listAgents()
|
||||
}
|
||||
|
||||
private agentWorkspace(name: string): string {
|
||||
return name === 'main'
|
||||
? `${OPENCLAW_CONTAINER_HOME}/workspace`
|
||||
: `${OPENCLAW_CONTAINER_HOME}/workspace-${name}`
|
||||
}
|
||||
|
||||
private async runCommand(args: string[]): Promise<string> {
|
||||
const output: string[] = []
|
||||
const command = ['node', 'dist/index.js', ...args]
|
||||
const exitCode = await this.executor.execInContainer(command, (line) => {
|
||||
output.push(line)
|
||||
})
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const detail = output.join('\n').trim()
|
||||
throw new Error(
|
||||
detail || `OpenClaw command failed (${args.slice(0, 2).join(' ')})`,
|
||||
)
|
||||
}
|
||||
|
||||
return output.join('\n').trim()
|
||||
}
|
||||
|
||||
private async runAgentListCommand(): Promise<
|
||||
RawAgentRecord[] | { agents?: RawAgentRecord[] }
|
||||
> {
|
||||
const output = await this.runCommand(['agents', 'list', '--json'])
|
||||
return parseAgentListOutput(output)
|
||||
}
|
||||
}
|
||||
|
||||
function formatConfigValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function parseConfigValue(output: string): unknown {
|
||||
const parsed = selectConfigJson<unknown>(output)
|
||||
return parsed ?? output
|
||||
}
|
||||
|
||||
function parseAgentListOutput(
|
||||
output: string,
|
||||
): RawAgentRecord[] | { agents?: RawAgentRecord[] } {
|
||||
const parsed = parseFirstMatchingJson<
|
||||
RawAgentRecord[] | { agents?: RawAgentRecord[] }
|
||||
>(output, isAgentListPayload)
|
||||
if (parsed !== null) return parsed
|
||||
|
||||
throw new Error(
|
||||
`Failed to parse OpenClaw JSON output: ${output.slice(0, 200)}`,
|
||||
)
|
||||
}
|
||||
|
||||
function parseFirstMatchingJson<T>(
|
||||
output: string,
|
||||
predicate?: (value: unknown) => boolean,
|
||||
): T | null {
|
||||
const candidates = collectJsonCandidates(output)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parsed = tryParseJson<T>(candidate)
|
||||
if (parsed === null) continue
|
||||
if (predicate && !predicate(parsed)) continue
|
||||
return parsed
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function selectConfigJson<T>(output: string): T | null {
|
||||
const candidates = collectJsonCandidates(output)
|
||||
const parsedCandidates: Array<{ text: string; value: T }> = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parsed = tryParseJson<T>(candidate)
|
||||
if (parsed === null) continue
|
||||
if (isStructuredLogPayload(parsed)) continue
|
||||
parsedCandidates.push({ text: candidate, value: parsed })
|
||||
}
|
||||
|
||||
if (parsedCandidates.length === 0) return null
|
||||
|
||||
return parsedCandidates.reduce((best, candidate) =>
|
||||
candidate.text.length > best.text.length ? candidate : best,
|
||||
).value
|
||||
}
|
||||
|
||||
function collectJsonCandidates(output: string): string[] {
|
||||
const candidates = [output.trim()]
|
||||
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) candidates.push(trimmed)
|
||||
}
|
||||
|
||||
for (let index = 0; index < output.length; index += 1) {
|
||||
const char = output[index]
|
||||
if (char !== '[' && char !== '{') continue
|
||||
const extracted = extractJsonSubstring(output, index)
|
||||
if (extracted) {
|
||||
candidates.push(extracted)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function extractJsonSubstring(
|
||||
output: string,
|
||||
startIndex: number,
|
||||
): string | null {
|
||||
const opening = output[startIndex]
|
||||
const closing = opening === '{' ? '}' : ']'
|
||||
const stack: string[] = [closing]
|
||||
let inString = false
|
||||
let escaped = false
|
||||
|
||||
for (let index = startIndex + 1; index < output.length; index += 1) {
|
||||
const char = output[index]
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '{') {
|
||||
stack.push('}')
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '[') {
|
||||
stack.push(']')
|
||||
continue
|
||||
}
|
||||
|
||||
const expectedClosing = stack[stack.length - 1]
|
||||
if (char === expectedClosing) {
|
||||
stack.pop()
|
||||
if (stack.length === 0) {
|
||||
return output.slice(startIndex, index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function tryParseJson<T>(value: string): T | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isAgentListPayload(
|
||||
value: unknown,
|
||||
): value is RawAgentRecord[] | { agents?: RawAgentRecord[] } {
|
||||
if (Array.isArray(value)) {
|
||||
return value.every(isRawAgentRecord)
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) return false
|
||||
|
||||
if (!('agents' in value)) return false
|
||||
|
||||
const agents = (value as { agents?: unknown }).agents
|
||||
return (
|
||||
agents === undefined ||
|
||||
(Array.isArray(agents) && agents.every(isRawAgentRecord))
|
||||
)
|
||||
}
|
||||
|
||||
function isRawAgentRecord(value: unknown): value is RawAgentRecord {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.workspace === 'string' &&
|
||||
(value.name === undefined || typeof value.name === 'string') &&
|
||||
(value.model === undefined || typeof value.model === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isStructuredLogPayload(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false
|
||||
|
||||
return (
|
||||
typeof value.level === 'string' &&
|
||||
(typeof value.message === 'string' || typeof value.msg === 'string')
|
||||
)
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Pure functions for building OpenClaw bootstrap configuration.
|
||||
* Config is write-once at setup — agent CRUD uses WS RPC, not config edits.
|
||||
*/
|
||||
|
||||
import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_GATEWAY_PORT,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
|
||||
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
|
||||
|
||||
export const PROVIDER_ENV_MAP: Record<string, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
google: 'GEMINI_API_KEY',
|
||||
openrouter: 'OPENROUTER_API_KEY',
|
||||
moonshot: 'MOONSHOT_API_KEY',
|
||||
groq: 'GROQ_API_KEY',
|
||||
mistral: 'MISTRAL_API_KEY',
|
||||
}
|
||||
|
||||
export interface OpenClawProviderInput {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface BootstrapConfigInput {
|
||||
gatewayPort: number
|
||||
gatewayToken: string
|
||||
browserosServerPort?: number
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface EnvFileInput {
|
||||
image?: string
|
||||
port?: number
|
||||
token: string
|
||||
configDir: string
|
||||
timezone?: string
|
||||
providerKeys?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ResolvedProviderConfig {
|
||||
model?: string
|
||||
providerKeys: Record<string, string>
|
||||
models?: {
|
||||
mode: 'merge'
|
||||
providers: Record<string, Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
function hasBuiltinProvider(providerType?: string): providerType is string {
|
||||
return !!providerType && providerType in PROVIDER_ENV_MAP
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter's public slugs use dots for version numbers
|
||||
* (e.g. `anthropic/claude-haiku-4.5`), but openclaw's model registry expects
|
||||
* dashes (`claude-haiku-4-5`). Passing the dotted form makes openclaw fail
|
||||
* the registry lookup silently and the agent turn completes with zero
|
||||
* payloads. Rewrite dots to dashes for openrouter model ids only.
|
||||
*/
|
||||
function normalizeBuiltinModelId(
|
||||
providerType: string,
|
||||
modelId: string,
|
||||
): string {
|
||||
if (providerType !== 'openrouter') return modelId
|
||||
return modelId.replace(/\./g, '-')
|
||||
}
|
||||
|
||||
export function deriveOpenClawProviderId(providerInput: {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
}): string {
|
||||
const source =
|
||||
providerInput.providerName?.trim() ||
|
||||
providerInput.baseUrl?.trim() ||
|
||||
providerInput.providerType?.trim() ||
|
||||
'custom-provider'
|
||||
|
||||
const candidate = source
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
return candidate || 'custom-provider'
|
||||
}
|
||||
|
||||
export function deriveOpenClawApiKeyEnvVar(providerId: string): string {
|
||||
return `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
|
||||
}
|
||||
|
||||
export function resolveProviderConfig(
|
||||
input: OpenClawProviderInput,
|
||||
): ResolvedProviderConfig {
|
||||
if (!input.providerType) {
|
||||
return { providerKeys: {} }
|
||||
}
|
||||
|
||||
if (hasBuiltinProvider(input.providerType)) {
|
||||
const providerKeys: Record<string, string> = {}
|
||||
if (input.apiKey) {
|
||||
providerKeys[PROVIDER_ENV_MAP[input.providerType]] = input.apiKey
|
||||
}
|
||||
|
||||
const normalizedModelId = input.modelId
|
||||
? normalizeBuiltinModelId(input.providerType, input.modelId)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
providerKeys,
|
||||
model: normalizedModelId
|
||||
? `${input.providerType}/${normalizedModelId}`
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.baseUrl) {
|
||||
return { providerKeys: {} }
|
||||
}
|
||||
|
||||
const providerId = deriveOpenClawProviderId(input)
|
||||
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
|
||||
const providerKeys: Record<string, string> = {}
|
||||
|
||||
if (input.apiKey) {
|
||||
providerKeys[apiKeyEnvVar] = input.apiKey
|
||||
}
|
||||
|
||||
const providerConfig: Record<string, unknown> = {
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: `\${${apiKeyEnvVar}}`,
|
||||
api: 'openai-completions',
|
||||
}
|
||||
|
||||
if (input.modelId) {
|
||||
providerConfig.models = [{ id: input.modelId, name: input.modelId }]
|
||||
}
|
||||
|
||||
return {
|
||||
providerKeys,
|
||||
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
|
||||
models: {
|
||||
mode: 'merge',
|
||||
providers: {
|
||||
[providerId]: providerConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBootstrapConfig(
|
||||
input: BootstrapConfigInput,
|
||||
): Record<string, unknown> {
|
||||
const serverPort = input.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
const provider = resolveProviderConfig(input)
|
||||
|
||||
const defaults: Record<string, unknown> = {
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
timeoutSeconds: 4200,
|
||||
thinkingDefault: 'adaptive',
|
||||
}
|
||||
|
||||
if (provider.model) {
|
||||
defaults.model = { primary: provider.model }
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
gateway: {
|
||||
mode: 'local',
|
||||
port: input.gatewayPort,
|
||||
bind: 'lan',
|
||||
auth: { mode: 'token', token: input.gatewayToken },
|
||||
reload: { mode: 'restart' },
|
||||
controlUi: {
|
||||
allowInsecureAuth: true,
|
||||
allowedOrigins: [
|
||||
`http://127.0.0.1:${input.gatewayPort}`,
|
||||
`http://localhost:${input.gatewayPort}`,
|
||||
],
|
||||
},
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults },
|
||||
tools: {
|
||||
profile: 'full',
|
||||
web: {
|
||||
search: { provider: 'duckduckgo', enabled: true },
|
||||
},
|
||||
exec: {
|
||||
host: 'gateway',
|
||||
security: 'full',
|
||||
ask: 'off',
|
||||
},
|
||||
},
|
||||
cron: { enabled: true },
|
||||
hooks: {
|
||||
internal: {
|
||||
enabled: true,
|
||||
entries: {
|
||||
'boot-md': { enabled: true },
|
||||
'bootstrap-extra-files': { enabled: true },
|
||||
'session-memory': { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
servers: {
|
||||
browseros: {
|
||||
url: `http://host.containers.internal:${serverPort}/mcp`,
|
||||
transport: 'streamable-http',
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: { enabled: false },
|
||||
},
|
||||
skills: {
|
||||
install: { nodeManager: 'bun' },
|
||||
},
|
||||
}
|
||||
|
||||
if (provider.models) {
|
||||
config.models = provider.models
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
config.logging = { level: 'debug', consoleLevel: 'debug' }
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export function buildEnvFile(input: EnvFileInput): string {
|
||||
const lines: string[] = [
|
||||
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
|
||||
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
|
||||
`OPENCLAW_GATEWAY_TOKEN=${input.token}`,
|
||||
`OPENCLAW_CONFIG_DIR=${input.configDir}`,
|
||||
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
|
||||
]
|
||||
|
||||
if (input.providerKeys) {
|
||||
for (const [key, value] of Object.entries(input.providerKeys)) {
|
||||
lines.push(`${key}=${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
export function resolveProviderKeys(
|
||||
input: OpenClawProviderInput,
|
||||
): Record<string, string> {
|
||||
return resolveProviderConfig(input).providerKeys
|
||||
}
|
||||
|
||||
export function resolveProviderModel(
|
||||
input: OpenClawProviderInput,
|
||||
): string | undefined {
|
||||
return resolveProviderConfig(input).model
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
|
||||
const STATE_DIR_NAME = '.openclaw'
|
||||
|
||||
export function getOpenClawStateDir(openclawDir: string): string {
|
||||
return join(openclawDir, STATE_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getOpenClawStateConfigPath(openclawDir: string): string {
|
||||
return join(getOpenClawStateDir(openclawDir), 'openclaw.json')
|
||||
}
|
||||
|
||||
export function getOpenClawStateEnvPath(openclawDir: string): string {
|
||||
return join(getOpenClawStateDir(openclawDir), '.env')
|
||||
}
|
||||
|
||||
export function getHostWorkspaceDir(
|
||||
openclawDir: string,
|
||||
agentName: string,
|
||||
): string {
|
||||
return join(
|
||||
getOpenClawStateDir(openclawDir),
|
||||
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function mergeEnvContent(
|
||||
current: string,
|
||||
updates: Record<string, string>,
|
||||
): { changed: boolean; content: string } {
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return {
|
||||
changed: false,
|
||||
content: normalizeEnvContent(current),
|
||||
}
|
||||
}
|
||||
|
||||
const lines = current === '' ? [] : current.replace(/\r\n/g, '\n').split('\n')
|
||||
const nextLines = [...lines]
|
||||
let changed = false
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const replacement = `${key}=${value}`
|
||||
const index = nextLines.findIndex((line) => line.startsWith(`${key}=`))
|
||||
if (index === -1) {
|
||||
nextLines.push(replacement)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (nextLines[index] === replacement) {
|
||||
continue
|
||||
}
|
||||
nextLines[index] = replacement
|
||||
changed = true
|
||||
}
|
||||
|
||||
const content = normalizeEnvContent(nextLines.join('\n'))
|
||||
return {
|
||||
changed: changed || content !== normalizeEnvContent(current),
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEnvContent(content: string): string {
|
||||
const trimmed = content.trim()
|
||||
return trimmed ? `${trimmed}\n` : ''
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { createParser, type EventSourceMessage } from 'eventsource-parser'
|
||||
import type { OpenClawStreamEvent } from './openclaw-types'
|
||||
|
||||
export interface OpenClawChatRequest {
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
message: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export class OpenClawHttpChatClient {
|
||||
constructor(
|
||||
private readonly port: number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
) {}
|
||||
|
||||
async streamChat(
|
||||
input: OpenClawChatRequest,
|
||||
): Promise<ReadableStream<OpenClawStreamEvent>> {
|
||||
const response = await this.fetchChat(input)
|
||||
const body = response.body
|
||||
|
||||
if (!body) {
|
||||
throw new Error('OpenClaw chat response had no body')
|
||||
}
|
||||
|
||||
return createEventStream(body, input.signal)
|
||||
}
|
||||
|
||||
private async fetchChat(input: OpenClawChatRequest): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.port}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: resolveAgentModel(input.agentId),
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: input.message }],
|
||||
user: `browseros:${input.agentId}:${input.sessionKey}`,
|
||||
}),
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
return response
|
||||
}
|
||||
|
||||
const detail = await response.text()
|
||||
throw new Error(
|
||||
detail || `OpenClaw chat failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentModel(agentId: string): string {
|
||||
return agentId === 'main' ? 'openclaw' : `openclaw/${agentId}`
|
||||
}
|
||||
|
||||
function createEventStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): ReadableStream<OpenClawStreamEvent> {
|
||||
return new ReadableStream<OpenClawStreamEvent>({
|
||||
start(controller) {
|
||||
void pumpChatEvents(body, controller, signal)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function pumpChatEvents(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let text = ''
|
||||
let done = false
|
||||
const parser = createParser({
|
||||
onEvent(message) {
|
||||
if (done) return
|
||||
const nextText = updateAccumulatedText(message, text)
|
||||
done = handleMessage(message, controller, nextText, done)
|
||||
if (!done) {
|
||||
text = nextText
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
await reader.cancel()
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
const { done: streamDone, value } = await reader.read()
|
||||
if (streamDone) break
|
||||
parser.feed(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
} catch (error) {
|
||||
if (!done) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
}
|
||||
} finally {
|
||||
if (!done) {
|
||||
controller.close()
|
||||
}
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(
|
||||
message: EventSourceMessage,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
text: string,
|
||||
done: boolean,
|
||||
): boolean {
|
||||
if (message.data === '[DONE]') {
|
||||
return finishStream(controller, text, done)
|
||||
}
|
||||
|
||||
const chunk = parseChunk(message.data)
|
||||
if (!chunk) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
|
||||
})
|
||||
controller.close()
|
||||
return true
|
||||
}
|
||||
|
||||
for (const event of mapChunkToEvents(chunk)) {
|
||||
controller.enqueue(event)
|
||||
}
|
||||
|
||||
return hasFinishReason(chunk) ? finishStream(controller, text, done) : false
|
||||
}
|
||||
|
||||
function updateAccumulatedText(
|
||||
message: EventSourceMessage,
|
||||
text: string,
|
||||
): string {
|
||||
const chunk = parseChunk(message.data)
|
||||
if (!chunk) return text
|
||||
|
||||
let next = text
|
||||
for (const choice of readChoices(chunk)) {
|
||||
const delta = readDeltaText(choice)
|
||||
if (delta) {
|
||||
next += delta
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function finishStream(
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
text: string,
|
||||
done: boolean,
|
||||
): boolean {
|
||||
if (!done) {
|
||||
if (!text.trim()) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: "Agent couldn't generate a response. Please try again.",
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
return true
|
||||
}
|
||||
controller.enqueue({
|
||||
type: 'done',
|
||||
data: { text },
|
||||
})
|
||||
controller.close()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function mapChunkToEvents(
|
||||
chunk: Record<string, unknown>,
|
||||
): OpenClawStreamEvent[] {
|
||||
const events: OpenClawStreamEvent[] = []
|
||||
|
||||
for (const choice of readChoices(chunk)) {
|
||||
const delta = readDeltaText(choice)
|
||||
if (delta) {
|
||||
events.push({
|
||||
type: 'text-delta',
|
||||
data: { text: delta },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function hasFinishReason(chunk: Record<string, unknown>): boolean {
|
||||
return readChoices(chunk).some((choice) => !!readFinishReason(choice))
|
||||
}
|
||||
|
||||
function readChoices(
|
||||
chunk: Record<string, unknown>,
|
||||
): Array<Record<string, unknown>> {
|
||||
const choices = chunk.choices
|
||||
return Array.isArray(choices)
|
||||
? choices.filter(
|
||||
(choice): choice is Record<string, unknown> =>
|
||||
!!choice && typeof choice === 'object',
|
||||
)
|
||||
: []
|
||||
}
|
||||
|
||||
function readDeltaText(choice: Record<string, unknown>): string {
|
||||
const delta = choice.delta
|
||||
if (!delta || typeof delta !== 'object') return ''
|
||||
|
||||
const content = (delta as Record<string, unknown>).content
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
|
||||
function readFinishReason(choice: Record<string, unknown>): string | null {
|
||||
const reason = choice.finish_reason
|
||||
return typeof reason === 'string' && reason ? reason : null
|
||||
}
|
||||
|
||||
function parseChunk(data: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export const SUPPORTED_OPENCLAW_PROVIDERS = [
|
||||
'openrouter',
|
||||
'openai',
|
||||
'anthropic',
|
||||
'moonshot',
|
||||
] as const
|
||||
|
||||
export type SupportedOpenClawProvider =
|
||||
(typeof SUPPORTED_OPENCLAW_PROVIDERS)[number]
|
||||
|
||||
const PROVIDER_ENV_VARS: Record<SupportedOpenClawProvider, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
moonshot: 'MOONSHOT_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
openrouter: 'OPENROUTER_API_KEY',
|
||||
}
|
||||
|
||||
export class UnsupportedOpenClawProviderError extends Error {
|
||||
constructor(providerType: string) {
|
||||
super(`Unsupported OpenClaw provider: ${providerType}`)
|
||||
this.name = 'UnsupportedOpenClawProviderError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isUnsupportedOpenClawProviderError(
|
||||
error: unknown,
|
||||
): error is UnsupportedOpenClawProviderError {
|
||||
return (
|
||||
error instanceof UnsupportedOpenClawProviderError ||
|
||||
(error instanceof Error &&
|
||||
error.name === 'UnsupportedOpenClawProviderError')
|
||||
)
|
||||
}
|
||||
|
||||
export function isSupportedOpenClawProvider(
|
||||
providerType: string,
|
||||
): providerType is SupportedOpenClawProvider {
|
||||
return SUPPORTED_OPENCLAW_PROVIDERS.includes(
|
||||
providerType as SupportedOpenClawProvider,
|
||||
)
|
||||
}
|
||||
|
||||
export function assertSupportedOpenClawProvider(
|
||||
providerType?: string,
|
||||
): SupportedOpenClawProvider | undefined {
|
||||
if (!providerType) {
|
||||
return undefined
|
||||
}
|
||||
if (!isSupportedOpenClawProvider(providerType)) {
|
||||
throw new UnsupportedOpenClawProviderError(providerType)
|
||||
}
|
||||
return providerType
|
||||
}
|
||||
|
||||
export function buildOpenClawModelRef(
|
||||
providerType: SupportedOpenClawProvider,
|
||||
modelId?: string,
|
||||
): string | undefined {
|
||||
return modelId ? `${providerType}/${modelId}` : undefined
|
||||
}
|
||||
|
||||
export function getOpenClawProviderEnvVar(
|
||||
providerType: SupportedOpenClawProvider,
|
||||
): string {
|
||||
return PROVIDER_ENV_VARS[providerType]
|
||||
}
|
||||
|
||||
export function resolveSupportedOpenClawProvider(input: {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}): {
|
||||
envValues: Record<string, string>
|
||||
model?: string
|
||||
providerType?: SupportedOpenClawProvider
|
||||
} {
|
||||
const providerType = assertSupportedOpenClawProvider(input.providerType)
|
||||
if (!providerType) {
|
||||
return { envValues: {} }
|
||||
}
|
||||
|
||||
const envVar = getOpenClawProviderEnvVar(providerType)
|
||||
return {
|
||||
envValues: input.apiKey ? { [envVar]: input.apiKey } : {},
|
||||
model: buildOpenClawModelRef(providerType, input.modelId),
|
||||
providerType,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
| 'thinking'
|
||||
| 'tool-start'
|
||||
| 'tool-end'
|
||||
| 'tool-output'
|
||||
| 'lifecycle'
|
||||
| 'done'
|
||||
| 'error'
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Persistence for user-supplied Podman runtime overrides.
|
||||
* Temporary escape hatch so users can point BrowserOS at their own Podman
|
||||
* (e.g. `brew install podman`) when the bundled runtime doesn't resolve helpers.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface PodmanOverrides {
|
||||
podmanPath: string | null
|
||||
}
|
||||
|
||||
const OVERRIDES_FILE_NAME = 'podman-overrides.json'
|
||||
|
||||
export function getPodmanOverridesPath(openclawDir: string): string {
|
||||
return join(openclawDir, OVERRIDES_FILE_NAME)
|
||||
}
|
||||
|
||||
export async function loadPodmanOverrides(
|
||||
openclawDir: string,
|
||||
): Promise<PodmanOverrides> {
|
||||
const overridesPath = getPodmanOverridesPath(openclawDir)
|
||||
if (!existsSync(overridesPath)) return { podmanPath: null }
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(overridesPath, 'utf-8'),
|
||||
) as Partial<PodmanOverrides>
|
||||
return {
|
||||
podmanPath:
|
||||
typeof parsed.podmanPath === 'string' && parsed.podmanPath.length > 0
|
||||
? parsed.podmanPath
|
||||
: null,
|
||||
}
|
||||
} catch {
|
||||
return { podmanPath: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePodmanOverrides(
|
||||
openclawDir: string,
|
||||
overrides: PodmanOverrides,
|
||||
): Promise<void> {
|
||||
await mkdir(openclawDir, { recursive: true })
|
||||
await writeFile(
|
||||
getPodmanOverridesPath(openclawDir),
|
||||
`${JSON.stringify(overrides, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
@@ -99,9 +99,9 @@ export class PodmanRuntime {
|
||||
'machine',
|
||||
'init',
|
||||
'--cpus',
|
||||
'2',
|
||||
'8',
|
||||
'--memory',
|
||||
'2048',
|
||||
'8096',
|
||||
'--disk-size',
|
||||
'10',
|
||||
],
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import {
|
||||
type BROWSEROS_ROLE_TEMPLATES,
|
||||
getBrowserOSRoleTemplate,
|
||||
} from '@browseros/shared/constants/role-aware-agents'
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSAgentRoleSummary,
|
||||
BrowserOSCustomRoleInput,
|
||||
BrowserOSRoleTemplate,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
|
||||
type RoleTemplate = (typeof BROWSEROS_ROLE_TEMPLATES)[number]
|
||||
interface BootstrapRenderableRole {
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
recommendedApps: string[]
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
bootstrap: BrowserOSRoleTemplate['bootstrap']
|
||||
}
|
||||
|
||||
export interface RoleBootstrapFiles {
|
||||
'AGENTS.md': string
|
||||
'SOUL.md': string
|
||||
'TOOLS.md': string
|
||||
'.browseros-role.json': string
|
||||
}
|
||||
|
||||
export function resolveRoleTemplate(
|
||||
roleId: BrowserOSAgentRoleId,
|
||||
): RoleTemplate {
|
||||
const role = getBrowserOSRoleTemplate(roleId)
|
||||
if (!role) {
|
||||
throw new Error(`Unknown BrowserOS role: ${roleId}`)
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
export function buildRoleBootstrapFiles(input: {
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput
|
||||
agentName: string
|
||||
}): RoleBootstrapFiles {
|
||||
const normalizedRole = normalizeRoleForBootstrap(input.role)
|
||||
const roleId = 'id' in input.role ? input.role.id : undefined
|
||||
return {
|
||||
'AGENTS.md': normalizedRole.bootstrap.agentsMd,
|
||||
'SOUL.md': normalizedRole.bootstrap.soulMd,
|
||||
'TOOLS.md': normalizedRole.bootstrap.toolsMd,
|
||||
'.browseros-role.json': `${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
roleSource: roleId ? 'builtin' : 'custom',
|
||||
roleId,
|
||||
roleName: normalizedRole.name,
|
||||
shortDescription: normalizedRole.shortDescription,
|
||||
createdBy: 'browseros',
|
||||
agentName: input.agentName,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
}
|
||||
}
|
||||
|
||||
export function toRoleSummary(
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
|
||||
): BrowserOSAgentRoleSummary {
|
||||
const normalizedRole = normalizeRoleForBootstrap(role)
|
||||
return {
|
||||
roleSource: 'id' in role ? 'builtin' : 'custom',
|
||||
roleId: 'id' in role ? role.id : undefined,
|
||||
roleName: normalizedRole.name,
|
||||
shortDescription: normalizedRole.shortDescription,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCustomRole(
|
||||
role: BrowserOSCustomRoleInput,
|
||||
): BootstrapRenderableRole {
|
||||
const recommendedApps = Array.isArray(role.recommendedApps)
|
||||
? role.recommendedApps.filter(
|
||||
(app): app is string => typeof app === 'string',
|
||||
)
|
||||
: []
|
||||
const boundaries = Array.isArray(role.boundaries) ? role.boundaries : []
|
||||
|
||||
return {
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
recommendedApps,
|
||||
boundaries,
|
||||
bootstrap: {
|
||||
agentsMd:
|
||||
role.bootstrap?.agentsMd?.trim() ||
|
||||
buildAgentsMd({
|
||||
name: role.name,
|
||||
longDescription: role.longDescription,
|
||||
boundaries,
|
||||
}),
|
||||
soulMd:
|
||||
role.bootstrap?.soulMd?.trim() ||
|
||||
buildSoulMd({
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
}),
|
||||
toolsMd:
|
||||
role.bootstrap?.toolsMd?.trim() ||
|
||||
buildToolsMd({
|
||||
boundaries,
|
||||
recommendedApps,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoleForBootstrap(
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
|
||||
): BootstrapRenderableRole {
|
||||
return 'id' in role ? role : normalizeCustomRole(role)
|
||||
}
|
||||
|
||||
function buildAgentsMd(input: {
|
||||
name: string
|
||||
longDescription: string
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
}): string {
|
||||
const boundaryLines = input.boundaries
|
||||
.map(
|
||||
(boundary) =>
|
||||
`- ${boundary.label}: ${boundary.description} Default mode: ${boundary.defaultMode}.`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `# ${input.name}
|
||||
|
||||
You are the ${input.name} specialist for this workspace.
|
||||
|
||||
## Core Purpose
|
||||
${input.longDescription}
|
||||
|
||||
## Operating Rules
|
||||
${boundaryLines}
|
||||
|
||||
## Default Output Style
|
||||
- concise
|
||||
- action-oriented
|
||||
- explicit about blockers and approvals
|
||||
`
|
||||
}
|
||||
|
||||
function buildSoulMd(input: {
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
}): string {
|
||||
return `# Operating Style
|
||||
|
||||
You act like a trusted ${input.name}.
|
||||
|
||||
## Working Posture
|
||||
- calm
|
||||
- structured
|
||||
- direct
|
||||
- explicit about tradeoffs
|
||||
|
||||
## Role Framing
|
||||
${input.shortDescription}
|
||||
|
||||
${input.longDescription}
|
||||
`
|
||||
}
|
||||
|
||||
function buildToolsMd(input: {
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
recommendedApps: string[]
|
||||
}): string {
|
||||
const boundaryLines = input.boundaries
|
||||
.map((boundary) => `- ${boundary.label}: ${boundary.defaultMode}`)
|
||||
.join('\n')
|
||||
|
||||
const appsLine =
|
||||
input.recommendedApps.length > 0
|
||||
? input.recommendedApps.join(', ')
|
||||
: 'No specific apps configured yet.'
|
||||
|
||||
return `# Tooling Guidelines
|
||||
|
||||
- Use BrowserOS MCP for browser and connected SaaS tasks.
|
||||
- Prefer read, summarize, and draft flows.
|
||||
- Keep outputs in the workspace when possible so work remains inspectable.
|
||||
|
||||
## Recommended Apps
|
||||
${appsLine}
|
||||
|
||||
## Boundary Defaults
|
||||
${boundaryLines}
|
||||
`
|
||||
}
|
||||
@@ -517,15 +517,45 @@ export class Browser {
|
||||
return null
|
||||
}
|
||||
|
||||
private async resolveWindowIdForNewPage(opts?: {
|
||||
hidden?: boolean
|
||||
windowId?: number
|
||||
}): Promise<number | undefined> {
|
||||
if (!opts?.hidden) {
|
||||
return opts?.windowId
|
||||
}
|
||||
|
||||
if (opts.windowId !== undefined) {
|
||||
const windows = await this.listWindows()
|
||||
const targetWindow = windows.find(
|
||||
(window) => window.windowId === opts.windowId,
|
||||
)
|
||||
if (targetWindow && !targetWindow.isVisible) {
|
||||
return targetWindow.windowId
|
||||
}
|
||||
if (targetWindow?.isVisible) {
|
||||
logger.warn(
|
||||
'Requested hidden page target window is visible, creating a new hidden window instead',
|
||||
{
|
||||
requestedWindowId: opts.windowId,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenWindow = await this.createWindow({ hidden: true })
|
||||
return hiddenWindow.windowId
|
||||
}
|
||||
|
||||
async newPage(
|
||||
url: string,
|
||||
opts?: { hidden?: boolean; background?: boolean; windowId?: number },
|
||||
): Promise<number> {
|
||||
const windowId = await this.resolveWindowIdForNewPage(opts)
|
||||
const createResult = await this.cdp.Browser.createTab({
|
||||
url,
|
||||
...(opts?.hidden !== undefined && { hidden: opts.hidden }),
|
||||
...(opts?.background !== undefined && { background: opts.background }),
|
||||
...(opts?.windowId !== undefined && { windowId: opts.windowId }),
|
||||
...(windowId !== undefined && { windowId }),
|
||||
})
|
||||
|
||||
const tabId = (createResult.tab as TabInfo).tabId
|
||||
@@ -553,7 +583,7 @@ export class Browser {
|
||||
loadProgress: tabInfo.loadProgress,
|
||||
isPinned: tabInfo.isPinned,
|
||||
isHidden: tabInfo.isHidden,
|
||||
windowId: tabInfo.windowId,
|
||||
windowId: tabInfo.windowId ?? windowId,
|
||||
index: tabInfo.index,
|
||||
groupId: tabInfo.groupId,
|
||||
})
|
||||
|
||||
@@ -6,8 +6,19 @@ import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-config'
|
||||
import { logger } from './logger'
|
||||
|
||||
const DEV_BROWSEROS_DIR_NAME = '.browseros-dev'
|
||||
|
||||
export function getBrowserosDir(): string {
|
||||
return join(homedir(), PATHS.BROWSEROS_DIR_NAME)
|
||||
const dirName =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? DEV_BROWSEROS_DIR_NAME
|
||||
: PATHS.BROWSEROS_DIR_NAME
|
||||
return join(homedir(), dirName)
|
||||
}
|
||||
|
||||
export function logDevelopmentBrowserosDir(): void {
|
||||
if (process.env.NODE_ENV !== 'development') return
|
||||
logger.info(`Using development BrowserOS directory: ${getBrowserosDir()}`)
|
||||
}
|
||||
|
||||
export function getMemoryDir(): string {
|
||||
@@ -57,6 +68,7 @@ export function removeServerConfigSync(): void {
|
||||
}
|
||||
|
||||
export async function ensureBrowserosDir(): Promise<void> {
|
||||
logDevelopmentBrowserosDir()
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
await mkdir(getSkillsDir(), { recursive: true })
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
|
||||
@@ -13,7 +13,11 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
|
||||
import { createHttpServer } from './api/server'
|
||||
import { getOpenClawService } from './api/services/openclaw/openclaw-service'
|
||||
import {
|
||||
configureOpenClawService,
|
||||
getOpenClawService,
|
||||
} from './api/services/openclaw/openclaw-service'
|
||||
import { loadPodmanOverrides } from './api/services/openclaw/podman-overrides'
|
||||
import { configurePodmanRuntime } from './api/services/openclaw/podman-runtime'
|
||||
import { CdpBackend } from './browser/backends/cdp'
|
||||
import { Browser } from './browser/browser'
|
||||
@@ -22,6 +26,7 @@ import { INLINED_ENV } from './env'
|
||||
import {
|
||||
cleanOldSessions,
|
||||
ensureBrowserosDir,
|
||||
getOpenClawDir,
|
||||
removeServerConfigSync,
|
||||
writeServerConfig,
|
||||
} from './lib/browseros-dir'
|
||||
@@ -56,9 +61,17 @@ export class Application {
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
})
|
||||
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
const podmanOverrides = await loadPodmanOverrides(getOpenClawDir())
|
||||
configurePodmanRuntime({
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
resourcesDir,
|
||||
podmanPath: podmanOverrides.podmanPath ?? undefined,
|
||||
})
|
||||
if (podmanOverrides.podmanPath) {
|
||||
logger.info('Using user-overridden Podman binary', {
|
||||
podmanPath: podmanOverrides.podmanPath,
|
||||
})
|
||||
}
|
||||
await this.initCoreServices()
|
||||
|
||||
if (!this.config.cdpPort) {
|
||||
@@ -123,7 +136,10 @@ export class Application {
|
||||
this.logStartupSummary()
|
||||
startSkillSync()
|
||||
|
||||
getOpenClawService(this.config.serverPort)
|
||||
configureOpenClawService({
|
||||
browserosServerPort: this.config.serverPort,
|
||||
resourcesDir,
|
||||
})
|
||||
.tryAutoStart()
|
||||
.catch((err) =>
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const projectRoot = resolve(import.meta.dir, '..', '..')
|
||||
const testsRoot = resolve(projectRoot, 'tests')
|
||||
const cleanupScript = resolve(testsRoot, '__helpers__/cleanup.sh')
|
||||
const preferredDirectoryGroups = [
|
||||
'agent',
|
||||
'api',
|
||||
'skills',
|
||||
'tools',
|
||||
'browser',
|
||||
'sdk',
|
||||
]
|
||||
const ignoredDirectories = new Set(['__fixtures__', '__helpers__'])
|
||||
const rootGroupExclusions = new Set(['server.integration.test.ts'])
|
||||
const testFilePattern = /\.(test|spec)\.[cm]?[jt]sx?$/
|
||||
|
||||
function compareGroupNames(left: string, right: string): number {
|
||||
const leftIndex = preferredDirectoryGroups.indexOf(left)
|
||||
const rightIndex = preferredDirectoryGroups.indexOf(right)
|
||||
const leftRank =
|
||||
leftIndex === -1 ? preferredDirectoryGroups.length : leftIndex
|
||||
const rightRank =
|
||||
rightIndex === -1 ? preferredDirectoryGroups.length : rightIndex
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank
|
||||
}
|
||||
return left.localeCompare(right)
|
||||
}
|
||||
|
||||
function listDirectoryGroups(): string[] {
|
||||
return readdirSync(testsRoot, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) => entry.isDirectory() && !ignoredDirectories.has(entry.name),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort(compareGroupNames)
|
||||
}
|
||||
|
||||
function listRootTestTargets(): string[] {
|
||||
return readdirSync(testsRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && testFilePattern.test(entry.name))
|
||||
.filter((entry) => !rootGroupExclusions.has(entry.name))
|
||||
.map((entry) => `./tests/${entry.name}`)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function listAllGroups(): string[] {
|
||||
const groups = [...listDirectoryGroups()]
|
||||
if (existsSync(resolve(testsRoot, 'server.integration.test.ts'))) {
|
||||
groups.push('integration')
|
||||
}
|
||||
if (listRootTestTargets().length > 0) {
|
||||
groups.push('root')
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function listAvailableGroupNames(): string[] {
|
||||
return ['all', 'core', 'cdp', ...listAllGroups()].sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
)
|
||||
}
|
||||
|
||||
function getCompositeGroupMembers(group: string): string[] | null {
|
||||
if (group === 'all') {
|
||||
return listAllGroups()
|
||||
}
|
||||
if (group === 'core') {
|
||||
return ['agent', 'api', 'skills', 'root']
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getAtomicGroupTargets(group: string): string[] {
|
||||
if (group === 'cdp') {
|
||||
return getAtomicGroupTargets('browser')
|
||||
}
|
||||
if (group === 'integration') {
|
||||
return existsSync(resolve(testsRoot, 'server.integration.test.ts'))
|
||||
? ['./tests/server.integration.test.ts']
|
||||
: []
|
||||
}
|
||||
if (group === 'root') {
|
||||
return listRootTestTargets()
|
||||
}
|
||||
if (existsSync(resolve(testsRoot, group))) {
|
||||
return [`./tests/${group}`]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function runCommand(cmd: string[], label: string): number {
|
||||
console.log(`\n==> ${label}`)
|
||||
const result = spawnSync(cmd[0], cmd.slice(1), {
|
||||
cwd: projectRoot,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
return result.status ?? 1
|
||||
}
|
||||
|
||||
function runAtomicGroup(group: string): number {
|
||||
const targets = getAtomicGroupTargets(group)
|
||||
if (targets.length === 0) {
|
||||
throw new Error(
|
||||
`Unknown test group "${group}". Available groups: ${listAvailableGroupNames().join(', ')}`,
|
||||
)
|
||||
}
|
||||
runCommand(['bash', cleanupScript], `Cleaning up test resources for ${group}`)
|
||||
const junitPath = process.env.BROWSEROS_JUNIT_PATH?.trim()
|
||||
const cmd = [process.execPath, '--env-file=.env.development', 'test']
|
||||
if (junitPath) {
|
||||
const outputPath = resolve(projectRoot, junitPath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
|
||||
}
|
||||
cmd.push(...targets)
|
||||
return runCommand(cmd, `Running ${group} tests`)
|
||||
}
|
||||
|
||||
function runGroup(group: string): number {
|
||||
const compositeMembers = getCompositeGroupMembers(group)
|
||||
if (compositeMembers) {
|
||||
let exitCode = 0
|
||||
for (const member of compositeMembers) {
|
||||
const status = runGroup(member)
|
||||
if (status !== 0 && exitCode === 0) {
|
||||
exitCode = status
|
||||
}
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
return runAtomicGroup(group)
|
||||
}
|
||||
|
||||
const requestedGroup = process.argv[2] ?? 'all'
|
||||
|
||||
process.exit(runGroup(requestedGroup))
|
||||
@@ -1168,8 +1168,9 @@ describe('compaction E2E — pruning and output reduction', () => {
|
||||
{ role: 'user', content: 'x'.repeat(3000) },
|
||||
]
|
||||
const estimated = estimateTokensForThreshold(messages, config)
|
||||
// 3000 chars / 3 = 1000 tokens, * 1.3 = 1300, + 12000 = 13300
|
||||
expect(estimated).toBe(Math.ceil(1000 * 1.3) + 12_000)
|
||||
expect(estimated).toBe(
|
||||
Math.ceil(1000 * config.safetyMultiplier) + config.fixedOverhead,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('createKlavisRoutes', () => {
|
||||
it('normalizes string integrations into authenticated entries', async () => {
|
||||
it('normalizes string integrations into unauthenticated entries', async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
Response.json({
|
||||
integrations: ['Google Docs', 'Slack'],
|
||||
@@ -32,8 +32,8 @@ describe('createKlavisRoutes', () => {
|
||||
assert.strictEqual(response.status, 200)
|
||||
assert.deepStrictEqual(body, {
|
||||
integrations: [
|
||||
{ name: 'Google Docs', is_authenticated: true },
|
||||
{ name: 'Slack', is_authenticated: true },
|
||||
{ name: 'Google Docs', is_authenticated: false },
|
||||
{ name: 'Slack', is_authenticated: false },
|
||||
],
|
||||
count: 2,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { UnsupportedOpenClawProviderError } from '../../../src/api/services/openclaw/openclaw-provider-map'
|
||||
|
||||
describe('createOpenClawRoutes', () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('preserves BrowserOS SSE framing and session headers for chat', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const chatStream = mock(
|
||||
async () =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'text-delta',
|
||||
data: { text: 'Hello' },
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'done',
|
||||
data: { text: 'Hello' },
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
chatStream,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/agents/research/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'hi',
|
||||
sessionKey: 'session-123',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
|
||||
expect(response.headers.get('X-Session-Key')).toBe('session-123')
|
||||
expect(chatStream).toHaveBeenCalledWith('research', 'session-123', 'hi')
|
||||
expect(await response.text()).toBe(
|
||||
'data: {"type":"text-delta","data":{"text":"Hello"}}\n\n' +
|
||||
'data: {"type":"done","data":{"text":"Hello"}}\n\n' +
|
||||
'data: [DONE]\n\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 400 for unsupported provider payloads', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const updateProviderKeys = mock(async () => {
|
||||
throw new UnsupportedOpenClawProviderError('google')
|
||||
})
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
updateProviderKeys,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/providers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
providerType: 'google',
|
||||
apiKey: 'google-key',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(updateProviderKeys).toHaveBeenCalledWith({
|
||||
providerType: 'google',
|
||||
apiKey: 'google-key',
|
||||
})
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Unsupported OpenClaw provider: google',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a non-restarting response when only the default model changes', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const updateProviderKeys = mock(async () => ({
|
||||
restarted: false,
|
||||
modelUpdated: true,
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
updateProviderKeys,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/providers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(updateProviderKeys).toHaveBeenCalledWith({
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
})
|
||||
expect(await response.json()).toEqual({
|
||||
status: 'updated',
|
||||
message: 'Provider updated without a restart',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose a roles route', async () => {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/roles')
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns the current podman overrides on GET', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const getPodmanOverrides = mock(async () => ({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
effectivePodmanPath: '/opt/homebrew/bin/podman',
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ getPodmanOverrides }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides')
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
effectivePodmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a relative podman path on POST', async () => {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: 'podman' }),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'podmanPath must be an absolute path',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a nonexistent podman path on POST', async () => {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: '/does/not/exist/podman' }),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'File does not exist: /does/not/exist/podman',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a non-executable podman path on POST', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaw-route-'))
|
||||
const nonExec = join(tempDir, 'podman')
|
||||
writeFileSync(nonExec, 'not a binary')
|
||||
chmodSync(nonExec, 0o644)
|
||||
try {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: nonExec }),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: `File is not executable: ${nonExec}`,
|
||||
})
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('applies and echoes when POST clears the override', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const applyPodmanOverrides = mock(async () => ({
|
||||
podmanPath: null,
|
||||
effectivePodmanPath: 'podman',
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ applyPodmanOverrides }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: null }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(applyPodmanOverrides).toHaveBeenCalledWith({ podmanPath: null })
|
||||
expect(await response.json()).toEqual({
|
||||
podmanPath: null,
|
||||
effectivePodmanPath: 'podman',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores role fields when creating agents', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const createAgent = mock(async () => ({
|
||||
agentId: 'research',
|
||||
name: 'research',
|
||||
workspace: '/home/node/.openclaw/workspace-research',
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
createAgent,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'research',
|
||||
roleId: 'chief-of-staff',
|
||||
customRole: {
|
||||
name: 'Ignored',
|
||||
shortDescription: 'Ignored',
|
||||
longDescription: 'Ignored',
|
||||
recommendedApps: [],
|
||||
boundaries: [],
|
||||
},
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(createAgent).toHaveBeenCalledWith({
|
||||
name: 'research',
|
||||
providerType: 'openai',
|
||||
providerName: undefined,
|
||||
baseUrl: undefined,
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
parseTerminalClientMessage,
|
||||
serializeTerminalServerMessage,
|
||||
@@ -53,7 +54,7 @@ describe('terminal protocol', () => {
|
||||
expect(
|
||||
buildTerminalExecCommand(
|
||||
'podman',
|
||||
'browseros-openclaw-openclaw-gateway-1',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
TERMINAL_HOME_DIR,
|
||||
),
|
||||
).toEqual([
|
||||
@@ -62,7 +63,7 @@ describe('terminal protocol', () => {
|
||||
'-it',
|
||||
'-w',
|
||||
'/home/node/.openclaw',
|
||||
'browseros-openclaw-openclaw-gateway-1',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'/bin/sh',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
|
||||
|
||||
const PROJECT_DIR = '/tmp/openclaw'
|
||||
const defaultSpec = {
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
port: 18789,
|
||||
hostHome: '/tmp/openclaw',
|
||||
envFilePath: '/tmp/openclaw/.openclaw/.env',
|
||||
gatewayToken: 'token-123',
|
||||
timezone: 'America/Los_Angeles',
|
||||
}
|
||||
|
||||
function createRuntime(
|
||||
runCommand: (
|
||||
args: string[],
|
||||
options?: { cwd?: string; onOutput?: (line: string) => void },
|
||||
) => Promise<number>,
|
||||
listRunningContainers: () => Promise<string[]> = async () => [],
|
||||
stopMachine: () => Promise<void> = async () => {},
|
||||
): ContainerRuntime {
|
||||
return new ContainerRuntime(
|
||||
{
|
||||
ensureReady: async () => {},
|
||||
isPodmanAvailable: async () => true,
|
||||
getMachineStatus: async () => ({ initialized: true, running: true }),
|
||||
runCommand,
|
||||
tailContainerLogs: () => () => {},
|
||||
listRunningContainers,
|
||||
stopMachine,
|
||||
} as never,
|
||||
PROJECT_DIR,
|
||||
)
|
||||
}
|
||||
|
||||
function expectedGatewayRuntimeArgs(spec: typeof defaultSpec): string[] {
|
||||
return [
|
||||
'--env-file',
|
||||
spec.envFilePath,
|
||||
'-e',
|
||||
'HOME=/home/node',
|
||||
'-e',
|
||||
'OPENCLAW_HOME=/home/node',
|
||||
'-e',
|
||||
'OPENCLAW_STATE_DIR=/home/node/.openclaw',
|
||||
'-e',
|
||||
'OPENCLAW_NO_RESPAWN=1',
|
||||
'-e',
|
||||
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
|
||||
'-e',
|
||||
'NODE_ENV=production',
|
||||
'-e',
|
||||
`TZ=${spec.timezone}`,
|
||||
'-v',
|
||||
`${spec.hostHome}:/home/node`,
|
||||
'--add-host',
|
||||
'host.containers.internal:host-gateway',
|
||||
'-e',
|
||||
`OPENCLAW_GATEWAY_TOKEN=${spec.gatewayToken}`,
|
||||
]
|
||||
}
|
||||
|
||||
function expectedStartGatewayRunArgs(spec: typeof defaultSpec): string[] {
|
||||
return [
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${spec.port}:18789`,
|
||||
...expectedGatewayRuntimeArgs(spec),
|
||||
'--health-cmd',
|
||||
'curl -sf http://127.0.0.1:18789/healthz',
|
||||
'--health-interval',
|
||||
'30s',
|
||||
'--health-timeout',
|
||||
'10s',
|
||||
'--health-retries',
|
||||
'3',
|
||||
spec.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
'18789',
|
||||
'--allow-unconfigured',
|
||||
]
|
||||
}
|
||||
|
||||
describe('ContainerRuntime', () => {
|
||||
it('pullImage runs podman pull for the requested image', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
args: ['pull', 'ghcr.io/openclaw/openclaw:2026.4.12'],
|
||||
cwd: PROJECT_DIR,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('startGateway removes any existing gateway and runs a fresh container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.startGateway(defaultSpec)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]).toEqual({
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
})
|
||||
expect(calls[1]).toEqual({
|
||||
cwd: PROJECT_DIR,
|
||||
args: expectedStartGatewayRunArgs(defaultSpec),
|
||||
})
|
||||
})
|
||||
|
||||
it('runGatewaySetupCommand in direct mode builds a one-off podman run command', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.runGatewaySetupCommand(
|
||||
['node', 'dist/index.js', 'agents', 'list', '--json'],
|
||||
defaultSpec,
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: [
|
||||
'rm',
|
||||
'-f',
|
||||
'--ignore',
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
],
|
||||
},
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: [
|
||||
'run',
|
||||
'--rm',
|
||||
'--name',
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
...expectedGatewayRuntimeArgs(defaultSpec),
|
||||
defaultSpec.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'agents',
|
||||
'list',
|
||||
'--json',
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopGateway removes the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.stopGateway()
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopGateway is idempotent when the managed container is already absent', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
options?.onOutput?.(
|
||||
`Error: no container with name "${OPENCLAW_GATEWAY_CONTAINER_NAME}" found`,
|
||||
)
|
||||
return 0
|
||||
})
|
||||
|
||||
await expect(runtime.stopGateway()).resolves.toBeUndefined()
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('getGatewayLogs tails logs from the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
options?.onOutput?.('first')
|
||||
options?.onOutput?.('second')
|
||||
return 0
|
||||
})
|
||||
|
||||
const logs = await runtime.getGatewayLogs(25)
|
||||
|
||||
expect(logs).toEqual(['first', 'second'])
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['logs', '--tail', '25', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('restartGateway recreates and launches the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.restartGateway(defaultSpec)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: expectedStartGatewayRunArgs(defaultSpec),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopMachineIfSafe allows the managed gateway container', async () => {
|
||||
let stopCalls = 0
|
||||
const runtime = createRuntime(
|
||||
async () => 0,
|
||||
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
async () => {
|
||||
stopCalls += 1
|
||||
},
|
||||
)
|
||||
|
||||
await runtime.stopMachineIfSafe()
|
||||
|
||||
expect(stopCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('stopMachineIfSafe does not stop machine if non-BrowserOS containers are running', async () => {
|
||||
let stopCalls = 0
|
||||
const runtime = createRuntime(
|
||||
async () => 0,
|
||||
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME, 'postgres-dev'],
|
||||
async () => {
|
||||
stopCalls += 1
|
||||
},
|
||||
)
|
||||
|
||||
await runtime.stopMachineIfSafe()
|
||||
|
||||
expect(stopCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('execInContainer targets the shared gateway container name', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.execInContainer(['node', '--version'])
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: undefined,
|
||||
args: ['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, 'node', '--version'],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('tailGatewayLogs targets the shared gateway container name', () => {
|
||||
const names: string[] = []
|
||||
const runtime = new ContainerRuntime(
|
||||
{
|
||||
ensureReady: async () => {},
|
||||
isPodmanAvailable: async () => true,
|
||||
getMachineStatus: async () => ({ initialized: true, running: true }),
|
||||
runCommand: async () => 0,
|
||||
tailContainerLogs: (containerName: string) => {
|
||||
names.push(containerName)
|
||||
return () => {}
|
||||
},
|
||||
listRunningContainers: async () => [],
|
||||
stopMachine: async () => {},
|
||||
} as never,
|
||||
PROJECT_DIR,
|
||||
)
|
||||
|
||||
const stop = runtime.tailGatewayLogs(() => {})
|
||||
stop()
|
||||
|
||||
expect(names).toEqual([OPENCLAW_GATEWAY_CONTAINER_NAME])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it, mock } from 'bun:test'
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
import { OpenClawCliClient } from '../../../../src/api/services/openclaw/openclaw-cli-client'
|
||||
|
||||
describe('OpenClawCliClient', () => {
|
||||
it('passes real non-interactive onboarding flags through to the upstream cli', async () => {
|
||||
const execInContainer = mock(async (command: string[]) => {
|
||||
expect(command).toEqual([
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'onboard',
|
||||
'--non-interactive',
|
||||
'--mode',
|
||||
'local',
|
||||
'--auth-choice',
|
||||
'skip',
|
||||
'--gateway-auth',
|
||||
'token',
|
||||
'--gateway-port',
|
||||
'18789',
|
||||
'--gateway-bind',
|
||||
'lan',
|
||||
'--no-install-daemon',
|
||||
'--skip-health',
|
||||
'--accept-risk',
|
||||
])
|
||||
return 0
|
||||
})
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
await client.runOnboard({
|
||||
nonInteractive: true,
|
||||
mode: 'local',
|
||||
authChoice: 'skip',
|
||||
gatewayAuth: 'token',
|
||||
gatewayPort: 18789,
|
||||
gatewayBind: 'lan',
|
||||
installDaemon: false,
|
||||
skipHealth: true,
|
||||
acceptRisk: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses batch mode for grouped config writes', async () => {
|
||||
const execInContainer = mock(async (command: string[]) => {
|
||||
expect(command).toEqual([
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'config',
|
||||
'set',
|
||||
'--batch-json',
|
||||
'[{"path":"gateway.mode","value":"local"},{"path":"gateway.http.endpoints.chatCompletions.enabled","value":true}]',
|
||||
])
|
||||
return 0
|
||||
})
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
await client.setConfigBatch([
|
||||
{
|
||||
path: 'gateway.mode',
|
||||
value: 'local',
|
||||
},
|
||||
{
|
||||
path: 'gateway.http.endpoints.chatCompletions.enabled',
|
||||
value: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('runs upstream CLI commands without appending a gateway token flag', async () => {
|
||||
const execInContainer = mock(
|
||||
async (command: string[], onLog?: (line: string) => void) => {
|
||||
if (command[2] === 'agents' && command[3] === 'list') {
|
||||
onLog?.(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
model: 'openrouter/anthropic/claude-sonnet-4.5',
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const agents = await client.listAgents()
|
||||
|
||||
expect(execInContainer.mock.calls[0]?.[0]).toEqual([
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'agents',
|
||||
'list',
|
||||
'--json',
|
||||
])
|
||||
expect(agents[0]?.model).toBe('openrouter/anthropic/claude-sonnet-4.5')
|
||||
})
|
||||
|
||||
it('derives the workspace when creating an agent', async () => {
|
||||
let callIndex = 0
|
||||
const execInContainer = mock(
|
||||
async (command: string[], onLog?: (line: string) => void) => {
|
||||
callIndex += 1
|
||||
if (callIndex === 1) {
|
||||
expect(command).toEqual([
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'agents',
|
||||
'add',
|
||||
'research',
|
||||
'--workspace',
|
||||
`${OPENCLAW_CONTAINER_HOME}/workspace-research`,
|
||||
'--model',
|
||||
'openai/gpt-5.4-mini',
|
||||
'--non-interactive',
|
||||
'--json',
|
||||
])
|
||||
return 0
|
||||
}
|
||||
|
||||
onLog?.(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
},
|
||||
{
|
||||
id: 'research',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
},
|
||||
]),
|
||||
)
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const agent = await client.createAgent({
|
||||
name: 'research',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
})
|
||||
|
||||
expect(execInContainer).toHaveBeenCalledTimes(2)
|
||||
expect(agent).toEqual({
|
||||
agentId: 'research',
|
||||
name: 'research',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses agent lists from mixed log and JSON output', async () => {
|
||||
const execInContainer = mock(
|
||||
async (_command: string[], onLog?: (line: string) => void) => {
|
||||
onLog?.('starting agent listing')
|
||||
onLog?.(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
},
|
||||
]),
|
||||
)
|
||||
onLog?.('done')
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const agents = await client.listAgents()
|
||||
|
||||
expect(agents).toEqual([
|
||||
{
|
||||
agentId: 'main',
|
||||
name: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('parses pretty-printed JSON surrounded by logs', async () => {
|
||||
const execInContainer = mock(
|
||||
async (_command: string[], onLog?: (line: string) => void) => {
|
||||
onLog?.('starting agent listing')
|
||||
onLog?.('[')
|
||||
onLog?.(' {')
|
||||
onLog?.(' "id": "main",')
|
||||
onLog?.(` "workspace": "${OPENCLAW_CONTAINER_HOME}/workspace",`)
|
||||
onLog?.(' "model": "openrouter/anthropic/claude-sonnet-4.5"')
|
||||
onLog?.(' }')
|
||||
onLog?.(']')
|
||||
onLog?.('done')
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const agents = await client.listAgents()
|
||||
|
||||
expect(agents).toEqual([
|
||||
{
|
||||
agentId: 'main',
|
||||
name: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
model: 'openrouter/anthropic/claude-sonnet-4.5',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('skips structured JSON logs before the real agent list payload', async () => {
|
||||
const execInContainer = mock(
|
||||
async (_command: string[], onLog?: (line: string) => void) => {
|
||||
onLog?.(
|
||||
JSON.stringify({
|
||||
level: 'info',
|
||||
message: 'agent list requested',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
}),
|
||||
)
|
||||
onLog?.(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
model: 'openrouter/anthropic/claude-sonnet-4.5',
|
||||
},
|
||||
]),
|
||||
)
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const agents = await client.listAgents()
|
||||
|
||||
expect(agents).toEqual([
|
||||
{
|
||||
agentId: 'main',
|
||||
name: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
model: 'openrouter/anthropic/claude-sonnet-4.5',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves exit details when the CLI fails', async () => {
|
||||
const execInContainer = mock(
|
||||
async (_command: string[], onLog?: (line: string) => void) => {
|
||||
onLog?.('agent already exists')
|
||||
return 1
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
|
||||
await expect(client.listAgents()).rejects.toThrow('agent already exists')
|
||||
})
|
||||
|
||||
it('parses config get output from mixed logs and pretty-printed JSON', async () => {
|
||||
const execInContainer = mock(
|
||||
async (command: string[], onLog?: (line: string) => void) => {
|
||||
if (command[2] === 'config' && command[3] === 'get') {
|
||||
onLog?.('reading config')
|
||||
onLog?.('{')
|
||||
onLog?.(' "gateway": {')
|
||||
onLog?.(' "mode": "local"')
|
||||
onLog?.(' }')
|
||||
onLog?.('}')
|
||||
onLog?.('done')
|
||||
}
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const config = await client.getConfig('gateway')
|
||||
|
||||
expect(config).toEqual({
|
||||
gateway: {
|
||||
mode: 'local',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('skips structured JSON log lines before config get payloads', async () => {
|
||||
const execInContainer = mock(
|
||||
async (command: string[], onLog?: (line: string) => void) => {
|
||||
if (command[2] === 'config' && command[3] === 'get') {
|
||||
onLog?.(
|
||||
JSON.stringify({
|
||||
level: 'info',
|
||||
message: 'reading config',
|
||||
}),
|
||||
)
|
||||
onLog?.('{')
|
||||
onLog?.(' "gateway": {')
|
||||
onLog?.(' "mode": "local"')
|
||||
onLog?.(' }')
|
||||
onLog?.('}')
|
||||
}
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const config = await client.getConfig('gateway')
|
||||
|
||||
expect(config).toEqual({
|
||||
gateway: {
|
||||
mode: 'local',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('skips structured JSON log lines before config validate payloads', async () => {
|
||||
const execInContainer = mock(
|
||||
async (command: string[], onLog?: (line: string) => void) => {
|
||||
if (command[2] === 'config' && command[3] === 'validate') {
|
||||
onLog?.(
|
||||
JSON.stringify({
|
||||
level: 'info',
|
||||
message: 'validating config',
|
||||
}),
|
||||
)
|
||||
onLog?.(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
warnings: [],
|
||||
}),
|
||||
)
|
||||
}
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const result = await client.validateConfig()
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
warnings: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the config get payload when a structured JSON log follows it', async () => {
|
||||
const execInContainer = mock(
|
||||
async (command: string[], onLog?: (line: string) => void) => {
|
||||
if (command[2] === 'config' && command[3] === 'get') {
|
||||
onLog?.('{')
|
||||
onLog?.(' "gateway": {')
|
||||
onLog?.(' "mode": "local"')
|
||||
onLog?.(' }')
|
||||
onLog?.('}')
|
||||
onLog?.(
|
||||
JSON.stringify({
|
||||
level: 'info',
|
||||
message: 'config fetched',
|
||||
}),
|
||||
)
|
||||
}
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const config = await client.getConfig('gateway')
|
||||
|
||||
expect(config).toEqual({
|
||||
gateway: {
|
||||
mode: 'local',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the config validate payload when a structured JSON log follows it', async () => {
|
||||
const execInContainer = mock(
|
||||
async (command: string[], onLog?: (line: string) => void) => {
|
||||
if (command[2] === 'config' && command[3] === 'validate') {
|
||||
onLog?.(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
warnings: [],
|
||||
}),
|
||||
)
|
||||
onLog?.(
|
||||
JSON.stringify({
|
||||
level: 'info',
|
||||
message: 'config validated',
|
||||
}),
|
||||
)
|
||||
}
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const result = await client.validateConfig()
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
warnings: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mergeEnvContent } from '../../../../src/api/services/openclaw/openclaw-env'
|
||||
|
||||
describe('mergeEnvContent', () => {
|
||||
it('appends new env keys and normalizes trailing newline', () => {
|
||||
expect(
|
||||
mergeEnvContent('OPENAI_API_KEY=sk-old', {
|
||||
ANTHROPIC_API_KEY: 'ant-key',
|
||||
}),
|
||||
).toEqual({
|
||||
changed: true,
|
||||
content: 'OPENAI_API_KEY=sk-old\nANTHROPIC_API_KEY=ant-key\n',
|
||||
})
|
||||
})
|
||||
|
||||
it('overwrites existing keys when values change', () => {
|
||||
expect(
|
||||
mergeEnvContent('OPENAI_API_KEY=sk-old\n', {
|
||||
OPENAI_API_KEY: 'sk-new',
|
||||
}),
|
||||
).toEqual({
|
||||
changed: true,
|
||||
content: 'OPENAI_API_KEY=sk-new\n',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports unchanged when incoming values match existing content', () => {
|
||||
expect(
|
||||
mergeEnvContent('OPENAI_API_KEY=sk-test\n', {
|
||||
OPENAI_API_KEY: 'sk-test',
|
||||
}),
|
||||
).toEqual({
|
||||
changed: false,
|
||||
content: 'OPENAI_API_KEY=sk-test\n',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { OpenClawHttpChatClient } from '../../../../src/api/services/openclaw/openclaw-http-chat-client'
|
||||
|
||||
describe('OpenClawHttpChatClient', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('maps chat completion deltas into BrowserOS stream events', async () => {
|
||||
const fetchMock = mock((_url: string | URL, _init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpChatClient(
|
||||
18789,
|
||||
async () => 'gateway-token',
|
||||
)
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
const events = await readEvents(stream)
|
||||
const call = fetchMock.mock.calls[0]
|
||||
|
||||
expect(call?.[0]).toBe('http://127.0.0.1:18789/v1/chat/completions')
|
||||
expect(call?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer gateway-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
|
||||
model: 'openclaw/research',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
user: 'browseros:research:session-123',
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{ type: 'text-delta', data: { text: 'Hello' } },
|
||||
{ type: 'text-delta', data: { text: ' world' } },
|
||||
{ type: 'done', data: { text: 'Hello world' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses openclaw for the main agent', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpChatClient(
|
||||
18789,
|
||||
async () => 'gateway-token',
|
||||
)
|
||||
|
||||
await client.streamChat({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as {
|
||||
model: string
|
||||
}
|
||||
expect(body.model).toBe('openclaw')
|
||||
})
|
||||
|
||||
it('throws on non-success HTTP responses', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('Unauthorized', { status: 401 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpChatClient(
|
||||
18789,
|
||||
async () => 'gateway-token',
|
||||
)
|
||||
|
||||
await expect(
|
||||
client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
}),
|
||||
).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('surfaces an error when OpenClaw finishes without assistant text', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpChatClient(
|
||||
18789,
|
||||
async () => 'gateway-token',
|
||||
)
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([
|
||||
{
|
||||
type: 'error',
|
||||
data: {
|
||||
message: "Agent couldn't generate a response. Please try again.",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stops processing batched SSE events after a malformed chunk closes the stream', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n' +
|
||||
'data: not-json\n\n' +
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpChatClient(
|
||||
18789,
|
||||
async () => 'gateway-token',
|
||||
)
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([
|
||||
{ type: 'text-delta', data: { text: 'Hello' } },
|
||||
{
|
||||
type: 'error',
|
||||
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
async function readEvents(
|
||||
stream: ReadableStream<{ type: string; data: Record<string, unknown> }>,
|
||||
): Promise<Array<{ type: string; data: Record<string, unknown> }>> {
|
||||
const reader = stream.getReader()
|
||||
const events: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
events.push(value)
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
resolveSupportedOpenClawProvider,
|
||||
UnsupportedOpenClawProviderError,
|
||||
} from '../../../../src/api/services/openclaw/openclaw-provider-map'
|
||||
import { OpenClawService } from '../../../../src/api/services/openclaw/openclaw-service'
|
||||
|
||||
type MutableOpenClawService = OpenClawService & {
|
||||
openclawDir: string
|
||||
token: string
|
||||
restart: ReturnType<typeof mock>
|
||||
runtime: {
|
||||
ensureReady?: () => Promise<void>
|
||||
isPodmanAvailable?: () => Promise<boolean>
|
||||
getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }>
|
||||
isReady: () => Promise<boolean>
|
||||
pullImage?: (
|
||||
_image: string,
|
||||
_onLog?: (_line: string) => void,
|
||||
) => Promise<void>
|
||||
startGateway?: (
|
||||
_input: unknown,
|
||||
_onLog?: (_line: string) => void,
|
||||
) => Promise<void>
|
||||
restartGateway?: (
|
||||
_input: unknown,
|
||||
_onLog?: (_line: string) => void,
|
||||
) => Promise<void>
|
||||
stopGateway?: (_onLog?: (_line: string) => void) => Promise<void>
|
||||
getGatewayLogs?: (_tail?: number) => Promise<string[]>
|
||||
waitForReady?: () => Promise<boolean>
|
||||
stopMachineIfSafe?: () => Promise<void>
|
||||
}
|
||||
cliClient: {
|
||||
probe?: ReturnType<typeof mock>
|
||||
createAgent?: ReturnType<typeof mock>
|
||||
getConfig?: ReturnType<typeof mock>
|
||||
listAgents?: ReturnType<typeof mock>
|
||||
}
|
||||
bootstrapCliClient: {
|
||||
runOnboard?: ReturnType<typeof mock>
|
||||
setConfigBatch?: ReturnType<typeof mock>
|
||||
setDefaultModel?: ReturnType<typeof mock>
|
||||
validateConfig?: ReturnType<typeof mock>
|
||||
}
|
||||
}
|
||||
|
||||
describe('OpenClawService', () => {
|
||||
let tempDir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = null
|
||||
}
|
||||
})
|
||||
|
||||
it('creates agents through the cli client without role bootstrap files', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const createAgent = mock(async () => ({
|
||||
agentId: 'ops',
|
||||
name: 'ops',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`,
|
||||
model: 'openclaw/default',
|
||||
}))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
createAgent,
|
||||
}
|
||||
|
||||
const agent = await service.createAgent({
|
||||
name: 'ops',
|
||||
})
|
||||
|
||||
expect(createAgent).toHaveBeenCalledWith({
|
||||
name: 'ops',
|
||||
model: undefined,
|
||||
})
|
||||
expect(agent).toEqual({
|
||||
agentId: 'ops',
|
||||
name: 'ops',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`,
|
||||
model: 'openclaw/default',
|
||||
})
|
||||
expect(
|
||||
existsSync(
|
||||
join(tempDir, '.openclaw', 'workspace-ops', '.browseros-role.json'),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('lists plain agent entries without role metadata', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw', 'workspace-ops'), {
|
||||
recursive: true,
|
||||
})
|
||||
await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}')
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'workspace-ops', '.browseros-role.json'),
|
||||
'{"roleId":"chief-of-staff"}\n',
|
||||
'utf-8',
|
||||
)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
getConfig: mock(async () => 'cli-token'),
|
||||
listAgents: mock(async () => [
|
||||
{
|
||||
agentId: 'ops',
|
||||
name: 'ops',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
},
|
||||
]),
|
||||
}
|
||||
|
||||
await expect(service.listAgents()).resolves.toEqual([
|
||||
{
|
||||
agentId: 'ops',
|
||||
name: 'ops',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('maps successful cli client probes into connected status', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}')
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isPodmanAvailable: async () => true,
|
||||
getMachineStatus: async () => ({ initialized: true, running: true }),
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
getConfig: mock(async () => 'cli-token'),
|
||||
listAgents: mock(async () => [
|
||||
{
|
||||
agentId: 'main',
|
||||
name: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
},
|
||||
{
|
||||
agentId: 'ops',
|
||||
name: 'ops',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`,
|
||||
},
|
||||
]),
|
||||
}
|
||||
|
||||
const status = await service.getStatus()
|
||||
|
||||
expect(status).toEqual({
|
||||
status: 'running',
|
||||
podmanAvailable: true,
|
||||
machineReady: true,
|
||||
port: 18789,
|
||||
agentCount: 2,
|
||||
error: null,
|
||||
controlPlaneStatus: 'connected',
|
||||
lastGatewayError: null,
|
||||
lastRecoveryReason: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('creates the main agent during setup when the gateway starts without one', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const steps: string[] = []
|
||||
const runOnboard = mock(async () => {
|
||||
steps.push('onboard')
|
||||
})
|
||||
const setConfigBatch = mock(async () => {
|
||||
steps.push('batch')
|
||||
})
|
||||
const setDefaultModel = mock(async () => {})
|
||||
const validateConfig = mock(async () => {
|
||||
steps.push('validate')
|
||||
return { ok: true }
|
||||
})
|
||||
const createAgent = mock(async () => ({
|
||||
agentId: 'main',
|
||||
name: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
}))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
const pullImage = mock(async () => {
|
||||
steps.push('pull')
|
||||
})
|
||||
const restartGateway = mock(async () => {
|
||||
steps.push('restart')
|
||||
})
|
||||
const startGateway = mock(async () => {
|
||||
steps.push('start')
|
||||
})
|
||||
service.runtime = {
|
||||
isPodmanAvailable: async () => true,
|
||||
ensureReady: async () => {},
|
||||
isReady: async () => true,
|
||||
pullImage,
|
||||
restartGateway,
|
||||
startGateway,
|
||||
waitForReady: mock(async () => {
|
||||
steps.push('ready')
|
||||
return true
|
||||
}),
|
||||
}
|
||||
service.cliClient = {
|
||||
probe: mock(async () => {}),
|
||||
listAgents: mock(async () => []),
|
||||
createAgent,
|
||||
}
|
||||
service.bootstrapCliClient = {
|
||||
runOnboard,
|
||||
setConfigBatch,
|
||||
setDefaultModel,
|
||||
validateConfig,
|
||||
}
|
||||
|
||||
await service.setup({})
|
||||
|
||||
expect(runOnboard).toHaveBeenCalledWith({
|
||||
acceptRisk: true,
|
||||
authChoice: 'skip',
|
||||
gatewayAuth: 'token',
|
||||
gatewayBind: 'lan',
|
||||
gatewayPort: 18789,
|
||||
installDaemon: false,
|
||||
mode: 'local',
|
||||
nonInteractive: true,
|
||||
skipHealth: true,
|
||||
})
|
||||
expect(setConfigBatch).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'mcp.servers.browseros.url',
|
||||
value: 'http://host.containers.internal:9100/mcp',
|
||||
},
|
||||
{
|
||||
path: 'mcp.servers.browseros.transport',
|
||||
value: 'streamable-http',
|
||||
},
|
||||
{
|
||||
path: 'gateway.http.endpoints.chatCompletions.enabled',
|
||||
value: true,
|
||||
},
|
||||
]),
|
||||
)
|
||||
expect(validateConfig).toHaveBeenCalled()
|
||||
expect(createAgent).toHaveBeenCalledWith({
|
||||
name: 'main',
|
||||
model: undefined,
|
||||
})
|
||||
expect(steps).toEqual([
|
||||
'pull',
|
||||
'onboard',
|
||||
'batch',
|
||||
'validate',
|
||||
'start',
|
||||
'ready',
|
||||
])
|
||||
expect(pullImage).toHaveBeenCalledWith(
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
port: 18789,
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: undefined,
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(restartGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies setup-time config in one batch before the gateway starts', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const runOnboard = mock(async () => {})
|
||||
const setConfigBatch = mock(async () => {})
|
||||
const validateConfig = mock(async () => ({ ok: true }))
|
||||
const createAgent = mock(async () => ({
|
||||
agentId: 'main',
|
||||
name: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
}))
|
||||
const waitForReady = mock(async () => true)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
const restartGateway = mock(async () => {})
|
||||
const startGateway = mock(async () => {})
|
||||
service.runtime = {
|
||||
isPodmanAvailable: async () => true,
|
||||
ensureReady: async () => {},
|
||||
isReady: async () => true,
|
||||
pullImage: async () => {},
|
||||
restartGateway,
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
service.cliClient = {
|
||||
probe: mock(async () => {}),
|
||||
listAgents: mock(async () => []),
|
||||
createAgent,
|
||||
}
|
||||
service.bootstrapCliClient = {
|
||||
runOnboard,
|
||||
setConfigBatch,
|
||||
setDefaultModel: mock(async () => {}),
|
||||
validateConfig,
|
||||
}
|
||||
|
||||
await expect(service.setup({})).resolves.toBeUndefined()
|
||||
|
||||
expect(setConfigBatch).toHaveBeenCalledTimes(1)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(createAgent).toHaveBeenCalledWith({
|
||||
name: 'main',
|
||||
model: undefined,
|
||||
})
|
||||
expect(startGateway).toHaveBeenCalledTimes(1)
|
||||
expect(restartGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads the persisted gateway token from the mounted config before control plane calls', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.token = 'random-token'
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
listAgents: mock(async () => {
|
||||
expect(service.token).toBe('cli-token')
|
||||
return []
|
||||
}),
|
||||
}
|
||||
|
||||
await service.listAgents()
|
||||
})
|
||||
|
||||
it('caches the loaded gateway token from config across steady-state control plane calls', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const listAgents = mock(async () => [])
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
listAgents,
|
||||
}
|
||||
|
||||
await service.listAgents()
|
||||
await service.listAgents()
|
||||
|
||||
expect(listAgents).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('writes provider credentials into the mounted state env file during setup', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isPodmanAvailable: async () => true,
|
||||
ensureReady: async () => {},
|
||||
isReady: async () => true,
|
||||
pullImage: async () => {},
|
||||
restartGateway: async () => {},
|
||||
startGateway: async () => {},
|
||||
waitForReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
getConfig: mock(async (path: string) =>
|
||||
path === 'gateway.auth.token' ? 'cli-token' : null,
|
||||
),
|
||||
probe: mock(async () => {}),
|
||||
listAgents: mock(async () => [
|
||||
{
|
||||
agentId: 'main',
|
||||
name: 'main',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
},
|
||||
]),
|
||||
createAgent: mock(async () => {
|
||||
throw new Error('createAgent should not be called when main exists')
|
||||
}),
|
||||
}
|
||||
service.bootstrapCliClient = {
|
||||
runOnboard: mock(async () => {}),
|
||||
setConfigBatch: mock(async () => {}),
|
||||
setDefaultModel: mock(async () => {}),
|
||||
validateConfig: mock(async () => ({ ok: true })),
|
||||
}
|
||||
|
||||
await service.setup({
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
})
|
||||
|
||||
expect(
|
||||
await readFile(join(tempDir, '.openclaw', '.env'), 'utf-8'),
|
||||
).toContain('OPENAI_API_KEY=sk-test')
|
||||
})
|
||||
|
||||
it('start uses the direct runtime startGateway flow', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const startGateway = mock(async () => {})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => true,
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
|
||||
await service.start()
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
port: 18789,
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restart uses the direct runtime restartGateway flow', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const restartGateway = mock(async () => {})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
restartGateway,
|
||||
waitForReady,
|
||||
}
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
|
||||
await service.restart()
|
||||
|
||||
expect(restartGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
port: 18789,
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stop calls runtime.stopGateway', async () => {
|
||||
const stopGateway = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
stopGateway,
|
||||
}
|
||||
|
||||
await service.stop()
|
||||
|
||||
expect(stopGateway).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('getLogs proxies to runtime.getGatewayLogs with tail', async () => {
|
||||
const getGatewayLogs = mock(async (tail = 50) => [`tail:${tail}`])
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
getGatewayLogs,
|
||||
}
|
||||
|
||||
await expect(service.getLogs(25)).resolves.toEqual(['tail:25'])
|
||||
expect(getGatewayLogs).toHaveBeenCalledWith(25)
|
||||
})
|
||||
|
||||
it('shutdown stops gateway and then stops machine when safe', async () => {
|
||||
const stopGateway = mock(async () => {})
|
||||
const stopMachineIfSafe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
stopGateway,
|
||||
stopMachineIfSafe,
|
||||
}
|
||||
|
||||
await service.shutdown()
|
||||
|
||||
expect(stopGateway).toHaveBeenCalledTimes(1)
|
||||
expect(stopMachineIfSafe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shutdown still stops machine when stopGateway fails', async () => {
|
||||
const stopGateway = mock(async () => {
|
||||
throw new Error('stop failed')
|
||||
})
|
||||
const stopMachineIfSafe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
stopGateway,
|
||||
stopMachineIfSafe,
|
||||
}
|
||||
|
||||
await expect(service.shutdown()).resolves.toBeUndefined()
|
||||
|
||||
expect(stopGateway).toHaveBeenCalledTimes(1)
|
||||
expect(stopMachineIfSafe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tryAutoStart uses direct-runtime startGateway when gateway is not ready', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const isReady = mock(async () => false)
|
||||
const startGateway = mock(async () => {})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isPodmanAvailable: async () => true,
|
||||
ensureReady,
|
||||
isReady,
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
|
||||
await service.tryAutoStart()
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
port: 18789,
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
expect(isReady).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps openrouter model refs verbatim without rewriting dots', () => {
|
||||
const provider = resolveSupportedOpenClawProvider({
|
||||
providerType: 'openrouter',
|
||||
apiKey: 'or-key',
|
||||
modelId: 'anthropic/claude-haiku-4.5',
|
||||
})
|
||||
|
||||
expect(provider).toEqual({
|
||||
envValues: {
|
||||
OPENROUTER_API_KEY: 'or-key',
|
||||
},
|
||||
model: 'openrouter/anthropic/claude-haiku-4.5',
|
||||
providerType: 'openrouter',
|
||||
})
|
||||
})
|
||||
|
||||
it('only resolves env vars for the supported bootstrap providers', () => {
|
||||
expect(
|
||||
resolveSupportedOpenClawProvider({
|
||||
providerType: 'anthropic',
|
||||
apiKey: 'ant-key',
|
||||
modelId: 'claude-sonnet-4.5',
|
||||
}),
|
||||
).toEqual({
|
||||
envValues: {
|
||||
ANTHROPIC_API_KEY: 'ant-key',
|
||||
},
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
providerType: 'anthropic',
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveSupportedOpenClawProvider({
|
||||
providerType: 'moonshot',
|
||||
apiKey: 'moon-key',
|
||||
modelId: 'kimi-k2',
|
||||
}),
|
||||
).toEqual({
|
||||
envValues: {
|
||||
MOONSHOT_API_KEY: 'moon-key',
|
||||
},
|
||||
model: 'moonshot/kimi-k2',
|
||||
providerType: 'moonshot',
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
resolveSupportedOpenClawProvider({
|
||||
providerType: 'google',
|
||||
apiKey: 'google-key',
|
||||
modelId: 'gemini-2.5-pro',
|
||||
}),
|
||||
).toThrow(new UnsupportedOpenClawProviderError('google'))
|
||||
|
||||
expect(() =>
|
||||
resolveSupportedOpenClawProvider({
|
||||
providerType: 'custom-api-key',
|
||||
baseUrl: 'https://example.test/v1',
|
||||
apiKey: 'custom-key',
|
||||
modelId: 'custom-model',
|
||||
}),
|
||||
).toThrow(new UnsupportedOpenClawProviderError('custom-api-key'))
|
||||
})
|
||||
|
||||
it('rejects unsupported providers before mutating env or creating agents', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const createAgent = mock(async () => ({
|
||||
agentId: 'ops',
|
||||
name: 'ops',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`,
|
||||
}))
|
||||
const restart = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.restart = restart
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
createAgent,
|
||||
}
|
||||
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'ops',
|
||||
providerType: 'google',
|
||||
apiKey: 'google-key',
|
||||
modelId: 'gemini-2.5-pro',
|
||||
}),
|
||||
).rejects.toThrow('Unsupported OpenClaw provider')
|
||||
|
||||
expect(createAgent).not.toHaveBeenCalled()
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(existsSync(join(tempDir, '.openclaw', '.env'))).toBe(false)
|
||||
})
|
||||
|
||||
it('passes openrouter model refs through verbatim into agent creation', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', '.env'),
|
||||
'OPENROUTER_API_KEY=or-key\n',
|
||||
'utf-8',
|
||||
)
|
||||
const createAgent = mock(async () => ({
|
||||
agentId: 'research',
|
||||
name: 'research',
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`,
|
||||
model: 'openrouter/anthropic/claude-haiku-4.5',
|
||||
}))
|
||||
const restart = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.restart = restart
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
createAgent,
|
||||
}
|
||||
|
||||
await service.createAgent({
|
||||
name: 'research',
|
||||
providerType: 'openrouter',
|
||||
apiKey: 'or-key',
|
||||
modelId: 'anthropic/claude-haiku-4.5',
|
||||
})
|
||||
|
||||
expect(createAgent).toHaveBeenCalledWith({
|
||||
name: 'research',
|
||||
model: 'openrouter/anthropic/claude-haiku-4.5',
|
||||
})
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updateProviderKeys rejects unsupported providers without restarting', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const restart = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.restart = restart
|
||||
|
||||
await expect(
|
||||
service.updateProviderKeys({
|
||||
providerType: 'google',
|
||||
apiKey: 'google-key',
|
||||
modelId: 'gemini-2.5-pro',
|
||||
}),
|
||||
).rejects.toThrow('Unsupported OpenClaw provider')
|
||||
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not restart when provider env content is unchanged', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', '.env'),
|
||||
'OPENAI_API_KEY=sk-test\n',
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const restart = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.restart = restart
|
||||
|
||||
await service.updateProviderKeys({
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-test',
|
||||
})
|
||||
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(await readFile(join(tempDir, '.openclaw', '.env'), 'utf-8')).toBe(
|
||||
'OPENAI_API_KEY=sk-test\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('applies the default model when provider keys are unchanged', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', '.env'),
|
||||
'OPENAI_API_KEY=sk-test\n',
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const restart = mock(async () => {})
|
||||
const setDefaultModel = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.restart = restart
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
waitForReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
setDefaultModel,
|
||||
}
|
||||
|
||||
await expect(
|
||||
service.updateProviderKeys({
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
modelUpdated: true,
|
||||
restarted: false,
|
||||
})
|
||||
|
||||
expect(setDefaultModel).toHaveBeenCalledWith('openai/gpt-5.4-mini')
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(await readFile(join(tempDir, '.openclaw', '.env'), 'utf-8')).toBe(
|
||||
'OPENAI_API_KEY=sk-test\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not persist env updates when setting the default model fails', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
|
||||
const setDefaultModel = mock(async () => {
|
||||
throw new Error('container unavailable')
|
||||
})
|
||||
const restart = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.restart = restart
|
||||
service.cliClient = {
|
||||
setDefaultModel,
|
||||
}
|
||||
|
||||
await expect(
|
||||
service.updateProviderKeys({
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
}),
|
||||
).rejects.toThrow('container unavailable')
|
||||
|
||||
expect(setDefaultModel).toHaveBeenCalledWith('openai/gpt-5.4-mini')
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(existsSync(join(tempDir, '.openclaw', '.env'))).toBe(false)
|
||||
})
|
||||
|
||||
it('applyPodmanOverrides persists the override and refreshes the runtime', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
const result = await service.applyPodmanOverrides({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
|
||||
expect(result.podmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
expect(result.effectivePodmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
|
||||
const persisted = JSON.parse(
|
||||
await readFile(join(tempDir, 'podman-overrides.json'), 'utf-8'),
|
||||
)
|
||||
expect(persisted).toEqual({ podmanPath: '/opt/homebrew/bin/podman' })
|
||||
|
||||
const reloaded = await service.getPodmanOverrides()
|
||||
expect(reloaded.podmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
expect(reloaded.effectivePodmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
})
|
||||
|
||||
it('applyPodmanOverrides with null clears the override and falls back', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService({
|
||||
resourcesDir: tempDir,
|
||||
}) as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
await service.applyPodmanOverrides({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
const cleared = await service.applyPodmanOverrides({ podmanPath: null })
|
||||
|
||||
expect(cleared.podmanPath).toBeNull()
|
||||
// resourcesDir has no bundled binary, so the runtime falls through to 'podman'
|
||||
expect(cleared.effectivePodmanPath).toBe('podman')
|
||||
|
||||
const persisted = JSON.parse(
|
||||
await readFile(join(tempDir, 'podman-overrides.json'), 'utf-8'),
|
||||
)
|
||||
expect(persisted).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('applyPodmanOverrides rebuilds ContainerRuntime so it picks up the new Podman reference', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
const before = service.runtime
|
||||
await service.applyPodmanOverrides({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
|
||||
expect(service.runtime).not.toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
getPodmanOverridesPath,
|
||||
loadPodmanOverrides,
|
||||
savePodmanOverrides,
|
||||
} from '../../../../src/api/services/openclaw/podman-overrides'
|
||||
|
||||
describe('podman overrides', () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browseros-podman-ovr-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns null podmanPath when the overrides file is missing', async () => {
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('round-trips save and load', async () => {
|
||||
await savePodmanOverrides(tempDir, {
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the overrides file is malformed JSON', async () => {
|
||||
fs.writeFileSync(getPodmanOverridesPath(tempDir), '{not json')
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('treats empty string and wrong types as null', async () => {
|
||||
fs.writeFileSync(
|
||||
getPodmanOverridesPath(tempDir),
|
||||
JSON.stringify({ podmanPath: '' }),
|
||||
)
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
|
||||
fs.writeFileSync(
|
||||
getPodmanOverridesPath(tempDir),
|
||||
JSON.stringify({ podmanPath: 42 }),
|
||||
)
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('persists an explicit null', async () => {
|
||||
await savePodmanOverrides(tempDir, { podmanPath: null })
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
expect(fs.existsSync(getPodmanOverridesPath(tempDir))).toBe(true)
|
||||
})
|
||||
|
||||
it('creates the openclaw directory if it does not exist', async () => {
|
||||
const nested = path.join(tempDir, 'does-not-exist')
|
||||
await savePodmanOverrides(nested, { podmanPath: '/usr/local/bin/podman' })
|
||||
expect(fs.existsSync(getPodmanOverridesPath(nested))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import {
|
||||
getBrowserosDir,
|
||||
logDevelopmentBrowserosDir,
|
||||
} from '../src/lib/browseros-dir'
|
||||
import { logger } from '../src/lib/logger'
|
||||
|
||||
describe('getBrowserosDir', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.NODE_ENV
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
return
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
})
|
||||
|
||||
it('uses a separate home directory in development', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
expect(getBrowserosDir()).toBe(join(homedir(), '.browseros-dev'))
|
||||
})
|
||||
|
||||
it('uses the standard home directory outside development', () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
expect(getBrowserosDir()).toBe(join(homedir(), PATHS.BROWSEROS_DIR_NAME))
|
||||
})
|
||||
|
||||
it('logs the resolved development directory path', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
const originalInfo = logger.info
|
||||
const info = mock(() => {})
|
||||
logger.info = info
|
||||
|
||||
try {
|
||||
logDevelopmentBrowserosDir()
|
||||
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
`Using development BrowserOS directory: ${join(homedir(), '.browseros-dev')}`,
|
||||
)
|
||||
} finally {
|
||||
logger.info = originalInfo
|
||||
}
|
||||
})
|
||||
|
||||
it('does not log a development directory outside development', () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
const originalInfo = logger.info
|
||||
const info = mock(() => {})
|
||||
logger.info = info
|
||||
|
||||
try {
|
||||
logDevelopmentBrowserosDir()
|
||||
|
||||
expect(info).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
logger.info = originalInfo
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
|
||||
|
||||
const config = {
|
||||
cdpPort: 9222,
|
||||
@@ -19,87 +19,85 @@ const config = {
|
||||
describe('Application.start', () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
mock.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with the CDP backend only', async () => {
|
||||
const createHttpServer = mock(async () => ({}))
|
||||
const apiServer = await import('../src/api/server')
|
||||
const browserModule = await import('../src/browser/browser')
|
||||
const cdpModule = await import('../src/browser/backends/cdp')
|
||||
const browserosDir = await import('../src/lib/browseros-dir')
|
||||
const dbModule = await import('../src/lib/db')
|
||||
const identityModule = await import('../src/lib/identity')
|
||||
const loggerModule = await import('../src/lib/logger')
|
||||
const metricsModule = await import('../src/lib/metrics')
|
||||
const sentryModule = await import('../src/lib/sentry')
|
||||
const soulModule = await import('../src/lib/soul')
|
||||
const openclawService = await import(
|
||||
'../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const podmanRuntime = await import(
|
||||
'../src/api/services/openclaw/podman-runtime'
|
||||
)
|
||||
const migrateModule = await import('../src/skills/migrate')
|
||||
const remoteSyncModule = await import('../src/skills/remote-sync')
|
||||
|
||||
const createHttpServer = spyOn(apiServer, 'createHttpServer')
|
||||
createHttpServer.mockImplementation(async () => ({}) as never)
|
||||
|
||||
const cdpConnect = mock(async () => {})
|
||||
const browserCtor = mock(() => {})
|
||||
const loggerInfo = mock(() => {})
|
||||
const loggerWarn = mock(() => {})
|
||||
const loggerDebug = mock(() => {})
|
||||
const loggerError = mock(() => {})
|
||||
mock.module('../src/api/server', () => ({
|
||||
createHttpServer,
|
||||
}))
|
||||
mock.module('../src/browser/backends/cdp', () => ({
|
||||
CdpBackend: class {
|
||||
async connect(): Promise<void> {
|
||||
await cdpConnect()
|
||||
}
|
||||
},
|
||||
}))
|
||||
mock.module('../src/browser/browser', () => ({
|
||||
Browser: class {
|
||||
constructor(cdp: unknown) {
|
||||
browserCtor(cdp)
|
||||
}
|
||||
},
|
||||
}))
|
||||
mock.module('../src/lib/browseros-dir', () => ({
|
||||
cleanOldSessions: mock(async () => {}),
|
||||
ensureBrowserosDir: mock(async () => {}),
|
||||
removeServerConfigSync: mock(() => {}),
|
||||
writeServerConfig: mock(async () => {}),
|
||||
}))
|
||||
mock.module('../src/lib/db', () => ({
|
||||
initializeDb: mock(() => ({})),
|
||||
}))
|
||||
mock.module('../src/lib/identity', () => ({
|
||||
identity: {
|
||||
initialize: mock(() => {}),
|
||||
getBrowserOSId: mock(() => 'browseros-id'),
|
||||
},
|
||||
}))
|
||||
mock.module('../src/lib/logger', () => ({
|
||||
logger: {
|
||||
setLogFile: mock(() => {}),
|
||||
info: loggerInfo,
|
||||
warn: loggerWarn,
|
||||
debug: loggerDebug,
|
||||
error: loggerError,
|
||||
},
|
||||
}))
|
||||
mock.module('../src/lib/metrics', () => ({
|
||||
metrics: {
|
||||
initialize: mock(() => {}),
|
||||
isEnabled: mock(() => true),
|
||||
log: mock(() => {}),
|
||||
},
|
||||
}))
|
||||
mock.module('../src/lib/sentry', () => ({
|
||||
Sentry: {
|
||||
setContext: mock(() => {}),
|
||||
setUser: mock(() => {}),
|
||||
captureException: mock(() => {}),
|
||||
},
|
||||
}))
|
||||
mock.module('../src/lib/soul', () => ({
|
||||
seedSoulTemplate: mock(async () => {}),
|
||||
}))
|
||||
mock.module('../src/skills/migrate', () => ({
|
||||
migrateBuiltinSkills: mock(async () => {}),
|
||||
}))
|
||||
mock.module('../src/skills/remote-sync', () => ({
|
||||
startSkillSync: mock(() => {}),
|
||||
stopSkillSync: mock(() => {}),
|
||||
syncBuiltinSkills: mock(async () => {}),
|
||||
}))
|
||||
mock.module('../src/tools/registry', () => ({
|
||||
registry: {
|
||||
names: () => ['test_tool'],
|
||||
},
|
||||
}))
|
||||
spyOn(cdpModule.CdpBackend.prototype, 'connect').mockImplementation(
|
||||
cdpConnect,
|
||||
)
|
||||
|
||||
spyOn(browserosDir, 'cleanOldSessions').mockImplementation(async () => {})
|
||||
spyOn(browserosDir, 'ensureBrowserosDir').mockImplementation(async () => {})
|
||||
spyOn(browserosDir, 'writeServerConfig').mockImplementation(async () => {})
|
||||
spyOn(browserosDir, 'removeServerConfigSync').mockImplementation(() => {})
|
||||
|
||||
spyOn(dbModule, 'initializeDb').mockImplementation(() => ({}) as never)
|
||||
spyOn(identityModule.identity, 'initialize').mockImplementation(() => {})
|
||||
spyOn(identityModule.identity, 'getBrowserOSId').mockImplementation(
|
||||
() => 'browseros-id',
|
||||
)
|
||||
|
||||
const loggerInfo = spyOn(loggerModule.logger, 'info').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
const loggerWarn = spyOn(loggerModule.logger, 'warn').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
spyOn(loggerModule.logger, 'debug').mockImplementation(() => {})
|
||||
const loggerError = spyOn(loggerModule.logger, 'error').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
spyOn(loggerModule.logger, 'setLogFile').mockImplementation(() => {})
|
||||
|
||||
spyOn(metricsModule.metrics, 'initialize').mockImplementation(() => {})
|
||||
spyOn(metricsModule.metrics, 'isEnabled').mockImplementation(() => true)
|
||||
spyOn(metricsModule.metrics, 'log').mockImplementation(() => {})
|
||||
|
||||
spyOn(sentryModule.Sentry, 'setContext').mockImplementation(() => {})
|
||||
spyOn(sentryModule.Sentry, 'setUser').mockImplementation(() => {})
|
||||
spyOn(sentryModule.Sentry, 'captureException').mockImplementation(() => {})
|
||||
|
||||
spyOn(soulModule, 'seedSoulTemplate').mockImplementation(async () => {})
|
||||
spyOn(migrateModule, 'migrateBuiltinSkills').mockImplementation(
|
||||
async () => {},
|
||||
)
|
||||
spyOn(remoteSyncModule, 'syncBuiltinSkills').mockImplementation(
|
||||
async () => {},
|
||||
)
|
||||
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
|
||||
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
|
||||
|
||||
spyOn(podmanRuntime, 'configurePodmanRuntime').mockImplementation(() => {})
|
||||
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
tryAutoStart: async () => {},
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const { Application } = await import('../src/main')
|
||||
const app = new Application(config)
|
||||
@@ -107,9 +105,14 @@ describe('Application.start', () => {
|
||||
await app.start()
|
||||
|
||||
expect(cdpConnect).toHaveBeenCalledTimes(1)
|
||||
expect(browserCtor).toHaveBeenCalledTimes(1)
|
||||
expect(createHttpServer).toHaveBeenCalledTimes(1)
|
||||
expect(createHttpServer.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
browser: expect.any(browserModule.Browser),
|
||||
}),
|
||||
)
|
||||
expect(createHttpServer.mock.calls[0]?.[0]).not.toHaveProperty('controller')
|
||||
expect(loggerInfo).toHaveBeenCalled()
|
||||
expect(loggerWarn).not.toHaveBeenCalled()
|
||||
expect(loggerError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it } from 'bun:test'
|
||||
import { afterAll, describe, it } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import type { Browser } from '../../src/browser/browser'
|
||||
import { disposeSemanticPipeline } from '../../src/tools/acl/acl-embeddings'
|
||||
import { executeTool, type ToolContext } from '../../src/tools/framework'
|
||||
import {
|
||||
check,
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
} from '../../src/tools/input'
|
||||
import { close_page, navigate_page, new_page } from '../../src/tools/navigation'
|
||||
import { evaluate_script, take_snapshot } from '../../src/tools/snapshot'
|
||||
import { withBrowser } from '../__helpers__/with-browser'
|
||||
import { cleanupWithBrowser, withBrowser } from '../__helpers__/with-browser'
|
||||
|
||||
process.env.ACL_EMBEDDING_DISABLE = 'true'
|
||||
|
||||
function textOf(result: {
|
||||
content: { type: string; text?: string }[]
|
||||
@@ -52,6 +55,72 @@ function findElementId(snapshotText: string, label: string): number {
|
||||
return Number.parseInt(match[1], 10)
|
||||
}
|
||||
|
||||
async function pointInsideElement(
|
||||
ctx: ToolContext,
|
||||
pageId: number,
|
||||
elementDomId: string,
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const pointResult = await executeTool(
|
||||
evaluate_script,
|
||||
{
|
||||
page: pageId,
|
||||
expression: `(() => {
|
||||
const el = document.getElementById(${JSON.stringify(elementDomId)});
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const insetX = Math.max(1, Math.min(10, Math.floor(rect.width / 4)));
|
||||
const insetY = Math.max(1, Math.min(10, Math.floor(rect.height / 4)));
|
||||
const candidates = [
|
||||
{
|
||||
x: Math.round(rect.left + rect.width / 2),
|
||||
y: Math.round(rect.top + rect.height / 2),
|
||||
},
|
||||
{
|
||||
x: Math.round(rect.left + insetX),
|
||||
y: Math.round(rect.top + insetY),
|
||||
},
|
||||
{
|
||||
x: Math.round(rect.right - insetX),
|
||||
y: Math.round(rect.top + insetY),
|
||||
},
|
||||
{
|
||||
x: Math.round(rect.left + insetX),
|
||||
y: Math.round(rect.bottom - insetY),
|
||||
},
|
||||
{
|
||||
x: Math.round(rect.right - insetX),
|
||||
y: Math.round(rect.bottom - insetY),
|
||||
},
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const target = document.elementFromPoint(candidate.x, candidate.y);
|
||||
if (target && (target === el || el.contains(target))) {
|
||||
return { ...candidate, matched: true, hitId: target.id || null };
|
||||
}
|
||||
}
|
||||
const fallback = candidates[0];
|
||||
const fallbackTarget = document.elementFromPoint(fallback.x, fallback.y);
|
||||
return {
|
||||
...fallback,
|
||||
matched: false,
|
||||
hitId: fallbackTarget instanceof Element ? fallbackTarget.id || null : null,
|
||||
};
|
||||
})()`,
|
||||
},
|
||||
ctx,
|
||||
AbortSignal.timeout(30_000),
|
||||
)
|
||||
const point = structuredOf<{
|
||||
value: { x: number; y: number; matched: boolean; hitId: string | null }
|
||||
} | null>(pointResult)?.value
|
||||
assert.ok(point, `Expected a point for #${elementDomId}`)
|
||||
assert.ok(
|
||||
point.matched,
|
||||
`Expected coordinates inside #${elementDomId}, got ${point.hitId ?? 'null'}`,
|
||||
)
|
||||
return { x: point.x, y: point.y }
|
||||
}
|
||||
|
||||
const FORM_PAGE = `data:text/html,${encodeURIComponent(`<!DOCTYPE html>
|
||||
<html><body>
|
||||
<h1>Test Form</h1>
|
||||
@@ -89,6 +158,11 @@ const FORM_PAGE = `data:text/html,${encodeURIComponent(`<!DOCTYPE html>
|
||||
</script>
|
||||
</body></html>`)}`
|
||||
|
||||
afterAll(async () => {
|
||||
await disposeSemanticPipeline()
|
||||
await cleanupWithBrowser()
|
||||
})
|
||||
|
||||
describe('input tools', () => {
|
||||
it('fill types text into an input', async () => {
|
||||
await withBrowser(async ({ execute }) => {
|
||||
@@ -410,7 +484,7 @@ describe('input tools', () => {
|
||||
{
|
||||
id: 'submit-rule',
|
||||
sitePattern: '*',
|
||||
description: 'submit',
|
||||
textMatch: 'Submit',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
@@ -437,7 +511,7 @@ describe('input tools', () => {
|
||||
{
|
||||
id: 'submit-rule',
|
||||
sitePattern: '*',
|
||||
description: 'submit',
|
||||
textMatch: 'Submit',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
@@ -457,24 +531,7 @@ describe('input tools', () => {
|
||||
)
|
||||
const pageId = pageIdOf(newResult)
|
||||
|
||||
const buttonCenter = await executeTool(
|
||||
evaluate_script,
|
||||
{
|
||||
page: pageId,
|
||||
expression: `(() => {
|
||||
const rect = document.getElementById('submit-btn').getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(rect.left + rect.width / 2),
|
||||
y: Math.round(rect.top + rect.height / 2),
|
||||
};
|
||||
})()`,
|
||||
},
|
||||
ctx,
|
||||
AbortSignal.timeout(30_000),
|
||||
)
|
||||
const buttonPoint = structuredOf<{ value: { x: number; y: number } }>(
|
||||
buttonCenter,
|
||||
).value
|
||||
const buttonPoint = await pointInsideElement(ctx, pageId, 'submit-btn')
|
||||
|
||||
const blockedClick = await executeTool(
|
||||
click_at,
|
||||
@@ -492,24 +549,7 @@ describe('input tools', () => {
|
||||
},
|
||||
]
|
||||
|
||||
const inputCenter = await executeTool(
|
||||
evaluate_script,
|
||||
{
|
||||
page: pageId,
|
||||
expression: `(() => {
|
||||
const rect = document.getElementById('name').getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(rect.left + rect.width / 2),
|
||||
y: Math.round(rect.top + rect.height / 2),
|
||||
};
|
||||
})()`,
|
||||
},
|
||||
ctx,
|
||||
AbortSignal.timeout(30_000),
|
||||
)
|
||||
const inputPoint = structuredOf<{ value: { x: number; y: number } }>(
|
||||
inputCenter,
|
||||
).value
|
||||
const inputPoint = await pointInsideElement(ctx, pageId, 'name')
|
||||
|
||||
const blockedType = await executeTool(
|
||||
type_at,
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
},
|
||||
"apps/server": {
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.85",
|
||||
"version": "0.0.88",
|
||||
"bin": {
|
||||
"browseros-server": "./src/index.ts",
|
||||
},
|
||||
|
||||
@@ -27,10 +27,17 @@
|
||||
"build:agent": "bun run codegen:agent && bun run --filter @browseros/agent build",
|
||||
"build:agent-sdk": "bun run --filter @browseros-ai/agent-sdk build",
|
||||
"codegen:agent": "bun run --filter @browseros/agent codegen",
|
||||
"test": "bun run test:tools && bun run test:integration",
|
||||
"test": "bun run test:all",
|
||||
"test:all": "bun run test:server && bun run test:agent && bun run test:eval && bun run test:agent-sdk && bun run test:build",
|
||||
"test:server": "bun run --filter @browseros/server test",
|
||||
"test:tools": "bun run --filter @browseros/server test:tools",
|
||||
"test:cdp": "bun run --filter @browseros/server test:cdp",
|
||||
"test:integration": "bun run --filter @browseros/server test:integration",
|
||||
"test:sdk": "echo 'SDK tests disabled: test environment does not provide the extract/verify LLM service'",
|
||||
"test:sdk": "bun run --filter @browseros/server test:sdk",
|
||||
"test:agent": "bun run ./scripts/run-bun-test.ts ./apps/agent",
|
||||
"test:eval": "bun run ./scripts/run-bun-test.ts ./apps/eval/tests",
|
||||
"test:agent-sdk": "bun run ./scripts/run-bun-test.ts ./packages/agent-sdk",
|
||||
"test:build": "bun run ./scripts/run-bun-test.ts ./scripts/build",
|
||||
"typecheck": "bun run --filter '*' typecheck",
|
||||
"lint": "bunx biome check",
|
||||
"lint:fix": "bunx biome check --write --unsafe",
|
||||
|
||||
@@ -1,59 +1,5 @@
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"name": "Bun Runtime - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/bun/bun-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/bun",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Bun Runtime - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/bun/bun-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/bun",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Bun Runtime - Linux ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/bun/bun-linux-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/bun",
|
||||
"os": ["linux"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Bun Runtime - Linux x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/bun/bun-linux-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/bun",
|
||||
"os": ["linux"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Bun Runtime - Windows x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/bun/bun-windows-x64.exe"
|
||||
},
|
||||
"destination": "resources/bin/third_party/bun.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"name": "Podman CLI - macOS ARM64",
|
||||
"source": {
|
||||
@@ -183,60 +129,6 @@
|
||||
"destination": "resources/bin/third_party/podman/win-sshproxy.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"name": "ripgrep - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/ripgrep/rg-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/rg",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "ripgrep - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/ripgrep/rg-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/rg",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "ripgrep - Linux ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/ripgrep/rg-linux-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/rg",
|
||||
"os": ["linux"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "ripgrep - Linux x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/ripgrep/rg-linux-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/rg",
|
||||
"os": ["linux"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "ripgrep - Windows x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/ripgrep/rg-windows-x64.exe"
|
||||
},
|
||||
"destination": "resources/bin/third_party/rg.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@ function validateRule(rule: ResourceRule): void {
|
||||
if (!rule.name || rule.name.trim().length === 0) {
|
||||
throw new Error('Manifest rule is missing name')
|
||||
}
|
||||
if (!rule.source.key || !rule.destination) {
|
||||
const hasSourcePath =
|
||||
(rule.source.type === 'r2' && rule.source.key) ||
|
||||
(rule.source.type === 'local' && rule.source.path)
|
||||
if (!hasSourcePath || !rule.destination) {
|
||||
throw new Error(
|
||||
`Manifest rule ${rule.name} is missing source key or destination`,
|
||||
`Manifest rule ${rule.name} is missing source path or destination`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,16 +27,21 @@ function parseSource(raw: unknown): ResourceRule['source'] {
|
||||
throw new Error('Manifest source must be an object')
|
||||
}
|
||||
const source = raw as Record<string, unknown>
|
||||
if (source.type !== 'r2') {
|
||||
throw new Error(
|
||||
`Unsupported source type in manifest: ${String(source.type)}`,
|
||||
)
|
||||
if (source.type === 'r2') {
|
||||
const key = source.key
|
||||
if (typeof key !== 'string' || key.length === 0) {
|
||||
throw new Error('Manifest source key is required')
|
||||
}
|
||||
return { type: 'r2', key }
|
||||
}
|
||||
const key = source.key
|
||||
if (typeof key !== 'string' || key.length === 0) {
|
||||
throw new Error('Manifest source key is required')
|
||||
if (source.type === 'local') {
|
||||
const path = source.path
|
||||
if (typeof path !== 'string' || path.length === 0) {
|
||||
throw new Error('Manifest source path is required')
|
||||
}
|
||||
return { type: 'local', path }
|
||||
}
|
||||
return { type: 'r2', key }
|
||||
throw new Error(`Unsupported source type in manifest: ${String(source.type)}`)
|
||||
}
|
||||
|
||||
function parseRule(raw: unknown): ResourceRule {
|
||||
|
||||
@@ -34,17 +34,28 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
|
||||
{ ci: args.ci },
|
||||
)
|
||||
|
||||
const manifestPath = resolve(rootDir, args.manifestPath)
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`Manifest not found: ${manifestPath}`)
|
||||
}
|
||||
const manifest = loadManifest(manifestPath)
|
||||
|
||||
if (args.ci) {
|
||||
const distRoot = getDistProdRoot()
|
||||
const localArtifacts = []
|
||||
|
||||
for (const binary of compiled) {
|
||||
log.step(`Packaging ${binary.target.name}`)
|
||||
const rules = getTargetRules(manifest, binary.target).filter(
|
||||
(rule) => rule.source.type === 'local',
|
||||
)
|
||||
const staged = await stageCompiledArtifact(
|
||||
distRoot,
|
||||
binary.binaryPath,
|
||||
binary.target,
|
||||
buildConfig.version,
|
||||
rules,
|
||||
rootDir,
|
||||
)
|
||||
localArtifacts.push(staged)
|
||||
log.success(`Packaged ${binary.target.id}`)
|
||||
@@ -58,12 +69,6 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const manifestPath = resolve(rootDir, args.manifestPath)
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`Manifest not found: ${manifestPath}`)
|
||||
}
|
||||
|
||||
const manifest = loadManifest(manifestPath)
|
||||
const distRoot = getDistProdRoot()
|
||||
const r2 = buildConfig.r2
|
||||
if (!r2) {
|
||||
@@ -76,13 +81,14 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
|
||||
for (const binary of compiled) {
|
||||
const rules = getTargetRules(manifest, binary.target)
|
||||
log.step(
|
||||
`Staging ${binary.target.name} (${rules.length} download rule(s))`,
|
||||
`Staging ${binary.target.name} (${rules.length} resource rule(s))`,
|
||||
)
|
||||
const staged = await stageTargetArtifact(
|
||||
distRoot,
|
||||
binary.binaryPath,
|
||||
binary.target,
|
||||
rules,
|
||||
rootDir,
|
||||
client,
|
||||
r2,
|
||||
buildConfig.version,
|
||||
|
||||
26
packages/browseros-agent/scripts/build/server/stage.test.ts
Normal file
26
packages/browseros-agent/scripts/build/server/stage.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { loadManifest } from './manifest'
|
||||
|
||||
describe('server artifact staging', () => {
|
||||
let tempDir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = null
|
||||
}
|
||||
})
|
||||
|
||||
it('loads empty local-resource rules from the manifest', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'browseros-stage-test-'))
|
||||
const manifestPath = join(tempDir, 'manifest.json')
|
||||
await writeFile(manifestPath, JSON.stringify({ resources: [] }))
|
||||
|
||||
expect(loadManifest(manifestPath)).toEqual({
|
||||
resources: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { chmod, cp, mkdir, rm } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative } from 'node:path'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
|
||||
import type { S3Client } from '@aws-sdk/client-s3'
|
||||
|
||||
@@ -75,13 +75,40 @@ function resolveDestination(rootDir: string, destination: string): string {
|
||||
|
||||
async function stageRule(
|
||||
rootDir: string,
|
||||
sourceRoot: string,
|
||||
rule: ResourceRule,
|
||||
target: BuildTarget,
|
||||
client: S3Client,
|
||||
r2: R2Config,
|
||||
): Promise<void> {
|
||||
const destinationPath = resolveDestination(rootDir, rule.destination)
|
||||
await downloadObjectToFile(client, r2, rule.source.key, destinationPath)
|
||||
await mkdir(dirname(destinationPath), { recursive: true })
|
||||
|
||||
if (rule.source.type === 'local') {
|
||||
await stageLocalRule(destinationPath, sourceRoot, rule, target)
|
||||
} else {
|
||||
await downloadObjectToFile(client, r2, rule.source.key, destinationPath)
|
||||
if (rule.executable && target.os !== 'windows') {
|
||||
await chmod(destinationPath, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stageLocalRule(
|
||||
destinationPath: string,
|
||||
sourceRoot: string,
|
||||
rule: ResourceRule,
|
||||
target: BuildTarget,
|
||||
): Promise<void> {
|
||||
if (rule.source.type !== 'local') {
|
||||
throw new Error(`Expected local source rule, got ${rule.source.type}`)
|
||||
}
|
||||
|
||||
await mkdir(dirname(destinationPath), { recursive: true })
|
||||
const sourcePath = isAbsolute(rule.source.path)
|
||||
? rule.source.path
|
||||
: resolve(sourceRoot, rule.source.path)
|
||||
await cp(sourcePath, destinationPath)
|
||||
|
||||
if (rule.executable && target.os !== 'windows') {
|
||||
await chmod(destinationPath, 0o755)
|
||||
@@ -93,6 +120,7 @@ export async function stageTargetArtifact(
|
||||
compiledBinaryPath: string,
|
||||
target: BuildTarget,
|
||||
rules: ResourceRule[],
|
||||
sourceRoot: string,
|
||||
client: S3Client,
|
||||
r2: R2Config,
|
||||
version: string,
|
||||
@@ -100,7 +128,7 @@ export async function stageTargetArtifact(
|
||||
const rootDir = await createArtifactRoot(distRoot, compiledBinaryPath, target)
|
||||
|
||||
for (const rule of rules) {
|
||||
await stageRule(rootDir, rule, target, client, r2)
|
||||
await stageRule(rootDir, sourceRoot, rule, target, client, r2)
|
||||
}
|
||||
|
||||
return finalizeArtifact(rootDir, target, version)
|
||||
@@ -111,7 +139,22 @@ export async function stageCompiledArtifact(
|
||||
compiledBinaryPath: string,
|
||||
target: BuildTarget,
|
||||
version: string,
|
||||
rules: ResourceRule[] = [],
|
||||
sourceRoot = process.cwd(),
|
||||
): Promise<StagedArtifact> {
|
||||
const rootDir = await createArtifactRoot(distRoot, compiledBinaryPath, target)
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.source.type !== 'local') {
|
||||
continue
|
||||
}
|
||||
await stageLocalRule(
|
||||
resolveDestination(rootDir, rule.destination),
|
||||
sourceRoot,
|
||||
rule,
|
||||
target,
|
||||
)
|
||||
}
|
||||
|
||||
return finalizeArtifact(rootDir, target, version)
|
||||
}
|
||||
|
||||
@@ -40,11 +40,18 @@ export interface BuildConfig {
|
||||
r2?: R2Config
|
||||
}
|
||||
|
||||
export interface ResourceSource {
|
||||
export interface R2ResourceSource {
|
||||
type: 'r2'
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface LocalResourceSource {
|
||||
type: 'local'
|
||||
path: string
|
||||
}
|
||||
|
||||
export type ResourceSource = R2ResourceSource | LocalResourceSource
|
||||
|
||||
export interface ResourceRule {
|
||||
name: string
|
||||
source: ResourceSource
|
||||
|
||||
29
packages/browseros-agent/scripts/run-bun-test.ts
Normal file
29
packages/browseros-agent/scripts/run-bun-test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const projectRoot = resolve(import.meta.dir, '..')
|
||||
const junitPath = process.env.BROWSEROS_JUNIT_PATH?.trim()
|
||||
const testArgs = process.argv.slice(2)
|
||||
|
||||
const cmd = [process.execPath, 'test']
|
||||
|
||||
if (junitPath) {
|
||||
const outputPath = resolve(projectRoot, junitPath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
|
||||
}
|
||||
|
||||
cmd.push(...testArgs)
|
||||
|
||||
const result = spawnSync(cmd[0], cmd.slice(1), {
|
||||
cwd: projectRoot,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"browseros-dev/proc"
|
||||
|
||||
@@ -33,7 +34,9 @@ func runCleanup(cmd *cobra.Command, args []string) error {
|
||||
if doPorts {
|
||||
ports := proc.DefaultLocalPorts()
|
||||
proc.LogMsgf(proc.TagInfo, "Killing processes on ports %d, %d, %d...", ports.CDP, ports.Server, ports.Extension)
|
||||
proc.KillPorts(ports)
|
||||
if err := proc.KillPortsAndWait(ports, 3*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
proc.LogMsg(proc.TagInfo, "Ports cleared")
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"browseros-dev/browser"
|
||||
"browseros-dev/proc"
|
||||
@@ -62,7 +63,9 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("creating user-data dir: %w", err)
|
||||
}
|
||||
proc.LogMsg(proc.TagInfo, "Killing processes on preferred ports...")
|
||||
proc.KillPorts(defaultPorts)
|
||||
if err := proc.KillPortsAndWait(defaultPorts, 3*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
proc.LogMsg(proc.TagInfo, "Ports cleared")
|
||||
|
||||
p, reservations, err = proc.ResolveWatchPorts(false)
|
||||
@@ -159,6 +162,9 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
||||
Env: env,
|
||||
Restart: true,
|
||||
Cmd: []string{"bun", "--watch", "--env-file=.env.development", "src/index.ts"},
|
||||
BeforeStart: func() error {
|
||||
return proc.KillPortAndWait(p.Server, 3*time.Second)
|
||||
},
|
||||
}))
|
||||
|
||||
<-sigCh
|
||||
|
||||
@@ -11,11 +11,12 @@ import (
|
||||
)
|
||||
|
||||
type ProcConfig struct {
|
||||
Tag Tag
|
||||
Dir string
|
||||
Env []string
|
||||
Restart bool
|
||||
Cmd []string
|
||||
Tag Tag
|
||||
Dir string
|
||||
Env []string
|
||||
Restart bool
|
||||
Cmd []string
|
||||
BeforeStart func() error
|
||||
}
|
||||
|
||||
type ManagedProc struct {
|
||||
@@ -49,6 +50,17 @@ func (mp *ManagedProc) run(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if mp.Cfg.BeforeStart != nil {
|
||||
if err := mp.Cfg.BeforeStart(); err != nil {
|
||||
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Pre-start failed: %v", err))
|
||||
if !mp.Cfg.Restart || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
LogMsgf(mp.Cfg.Tag, "Starting: %s", DimColor.Sprint(strings.Join(mp.Cfg.Cmd, " ")))
|
||||
|
||||
cmd := exec.Command(mp.Cfg.Cmd[0], mp.Cfg.Cmd[1:]...)
|
||||
|
||||
60
packages/browseros-agent/tools/dev/proc/managed_test.go
Normal file
60
packages/browseros-agent/tools/dev/proc/managed_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStartManagedRunsBeforeStartOnEachRetry(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var count atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
|
||||
StartManaged(ctx, &wg, ProcConfig{
|
||||
Tag: TagInfo,
|
||||
Dir: t.TempDir(),
|
||||
Restart: true,
|
||||
Cmd: []string{"sh", "-c", "exit 1"},
|
||||
BeforeStart: func() error {
|
||||
count.Add(1)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if count.Load() < 2 {
|
||||
t.Fatalf("expected BeforeStart to run on retries, got %d calls", count.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartManagedSkipsLaunchWhenBeforeStartFails(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sentinel := filepath.Join(t.TempDir(), "started")
|
||||
var wg sync.WaitGroup
|
||||
|
||||
StartManaged(ctx, &wg, ProcConfig{
|
||||
Tag: TagInfo,
|
||||
Dir: t.TempDir(),
|
||||
Restart: false,
|
||||
Cmd: []string{"sh", "-c", "touch " + sentinel},
|
||||
BeforeStart: func() error {
|
||||
return context.DeadlineExceeded
|
||||
},
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected process launch to be skipped, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,29 @@ func KillPort(port int) {
|
||||
exec.Command("sh", "-c", fmt.Sprintf("lsof -ti:%d | xargs kill -9 2>/dev/null || true", port)).Run()
|
||||
}
|
||||
|
||||
func KillPortAndWait(port int, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
KillPort(port)
|
||||
if IsPortAvailable(port) {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("port %d is still in use after kill -9 cleanup", port)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func KillPortsAndWait(p Ports, timeout time.Duration) error {
|
||||
for _, port := range []int{p.CDP, p.Server, p.Extension} {
|
||||
if err := KillPortAndWait(port, timeout); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildEnv(p Ports, nodeEnv string) []string {
|
||||
env := os.Environ()
|
||||
env = append(env,
|
||||
|
||||
58
packages/browseros/build/common/server_binaries.py
generated
Normal file
58
packages/browseros/build/common/server_binaries.py
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared sign metadata for BrowserOS Server binaries.
|
||||
|
||||
Consumed by both the Chromium-build signing path (build/modules/sign/) and the
|
||||
OTA release path (build/modules/ota/). Adding a new third-party binary here
|
||||
means both paths pick it up automatically.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignSpec:
|
||||
"""Per-binary codesign metadata.
|
||||
|
||||
``entitlements`` is the filename of the plist under
|
||||
``resources/entitlements/``; ``None`` means no extra entitlements.
|
||||
"""
|
||||
|
||||
identifier_suffix: str
|
||||
options: str
|
||||
entitlements: Optional[str] = None
|
||||
|
||||
|
||||
MACOS_SERVER_BINARIES: Dict[str, SignSpec] = {
|
||||
"browseros_server": SignSpec(
|
||||
"browseros_server", "runtime", "browseros-executable-entitlements.plist"
|
||||
),
|
||||
"bun": SignSpec("bun", "runtime", "browseros-executable-entitlements.plist"),
|
||||
"rg": SignSpec("rg", "runtime"),
|
||||
"podman": SignSpec("podman", "runtime"),
|
||||
"gvproxy": SignSpec("gvproxy", "runtime"),
|
||||
"vfkit": SignSpec("vfkit", "runtime", "podman-vfkit-entitlements.plist"),
|
||||
"krunkit": SignSpec("krunkit", "runtime", "podman-krunkit-entitlements.plist"),
|
||||
"podman-mac-helper": SignSpec("podman_mac_helper", "runtime"),
|
||||
}
|
||||
|
||||
|
||||
WINDOWS_SERVER_BINARIES: List[str] = [
|
||||
"browseros_server.exe",
|
||||
"third_party/bun.exe",
|
||||
"third_party/rg.exe",
|
||||
"third_party/podman/podman.exe",
|
||||
"third_party/podman/gvproxy.exe",
|
||||
"third_party/podman/win-sshproxy.exe",
|
||||
]
|
||||
|
||||
|
||||
def macos_sign_spec_for(binary_path: Path) -> Optional[SignSpec]:
|
||||
"""Look up sign metadata by file stem (e.g., ``podman-mac-helper``)."""
|
||||
return MACOS_SERVER_BINARIES.get(binary_path.stem)
|
||||
|
||||
|
||||
def expected_windows_binary_paths(server_bin_dir: Path) -> List[Path]:
|
||||
"""Resolve the Windows relative-path list against a ``resources/bin`` dir."""
|
||||
return [server_bin_dir / rel for rel in WINDOWS_SERVER_BINARIES]
|
||||
63
packages/browseros/build/common/server_binaries_test.py
generated
Normal file
63
packages/browseros/build/common/server_binaries_test.py
generated
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the shared server-binary sign table."""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from .server_binaries import (
|
||||
MACOS_SERVER_BINARIES,
|
||||
WINDOWS_SERVER_BINARIES,
|
||||
expected_windows_binary_paths,
|
||||
macos_sign_spec_for,
|
||||
)
|
||||
|
||||
ENTITLEMENTS_DIR = Path(__file__).resolve().parents[2] / "resources" / "entitlements"
|
||||
|
||||
|
||||
class MacosServerBinariesTest(unittest.TestCase):
|
||||
def test_every_entry_has_identifier_and_options(self):
|
||||
for stem, spec in MACOS_SERVER_BINARIES.items():
|
||||
self.assertTrue(spec.identifier_suffix, f"{stem} missing identifier_suffix")
|
||||
self.assertTrue(spec.options, f"{stem} missing options")
|
||||
|
||||
def test_every_entitlements_plist_exists_on_disk(self):
|
||||
for stem, spec in MACOS_SERVER_BINARIES.items():
|
||||
if spec.entitlements is None:
|
||||
continue
|
||||
plist = ENTITLEMENTS_DIR / spec.entitlements
|
||||
self.assertTrue(plist.exists(), f"{stem}: entitlements {plist} missing")
|
||||
|
||||
def test_macos_sign_spec_for_resolves_by_stem(self):
|
||||
spec = macos_sign_spec_for(Path("/x/podman-mac-helper"))
|
||||
assert spec is not None
|
||||
self.assertEqual(spec.identifier_suffix, "podman_mac_helper")
|
||||
self.assertIsNone(macos_sign_spec_for(Path("/x/not_a_known_binary")))
|
||||
|
||||
def test_matches_podman_bundle_layout(self):
|
||||
required = {"podman", "gvproxy", "vfkit", "krunkit", "podman-mac-helper"}
|
||||
self.assertTrue(required.issubset(MACOS_SERVER_BINARIES.keys()))
|
||||
|
||||
|
||||
class WindowsServerBinariesTest(unittest.TestCase):
|
||||
def test_no_duplicates(self):
|
||||
self.assertEqual(
|
||||
len(WINDOWS_SERVER_BINARIES), len(set(WINDOWS_SERVER_BINARIES))
|
||||
)
|
||||
|
||||
def test_paths_within_expected_layout(self):
|
||||
for rel in WINDOWS_SERVER_BINARIES:
|
||||
self.assertTrue(
|
||||
rel == "browseros_server.exe" or rel.startswith("third_party/"),
|
||||
f"{rel} outside expected layout",
|
||||
)
|
||||
|
||||
def test_expected_windows_binary_paths_joins_root(self):
|
||||
root = Path("/tmp/fake/resources/bin")
|
||||
resolved = expected_windows_binary_paths(root)
|
||||
self.assertEqual(len(resolved), len(WINDOWS_SERVER_BINARIES))
|
||||
for rel, abs_path in zip(WINDOWS_SERVER_BINARIES, resolved):
|
||||
self.assertEqual(abs_path, root / rel)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1 +1 @@
|
||||
143
|
||||
144
|
||||
|
||||
@@ -7,52 +7,16 @@
|
||||
<language>en</language>
|
||||
|
||||
<item>
|
||||
<sparkle:version>0.0.74</sparkle:version>
|
||||
<pubDate>Thu, 12 Mar 2026 21:20:48 +0000</pubDate>
|
||||
<sparkle:version>0.0.86</sparkle:version>
|
||||
<pubDate>Thu, 16 Apr 2026 18:58:59 +0000</pubDate>
|
||||
|
||||
<!-- macOS arm64 -->
|
||||
<enclosure
|
||||
url="https://cdn.browseros.com/server/browseros_server_0.0.74_darwin_arm64.zip"
|
||||
url="https://cdn.browseros.com/server/browseros_server_0.0.86_darwin_arm64.zip"
|
||||
sparkle:os="macos"
|
||||
sparkle:arch="arm64"
|
||||
sparkle:edSignature="aPuQG3dtQj5v857CNSZ+Ahz3bxUOM7+tSEskW0mIbJV6969a3j1kAqOQ20D1FcxlEyYqquFOaeHpoGaDi6LsDg=="
|
||||
length="22191352"
|
||||
type="application/zip"/>
|
||||
|
||||
<!-- macOS x86_64 -->
|
||||
<enclosure
|
||||
url="https://cdn.browseros.com/server/browseros_server_0.0.74_darwin_x64.zip"
|
||||
sparkle:os="macos"
|
||||
sparkle:arch="x86_64"
|
||||
sparkle:edSignature="X+FCQFH2HpBG43UiJjE0FkheyfOAUW2dhtmKn9HKRrJkqMGsaw+bhjdze1lP02oz71b8Q9AkC2NYwSUN0m0FAQ=="
|
||||
length="24641802"
|
||||
type="application/zip"/>
|
||||
|
||||
<!-- Linux arm64 -->
|
||||
<enclosure
|
||||
url="https://cdn.browseros.com/server/browseros_server_0.0.74_linux_arm64.zip"
|
||||
sparkle:os="linux"
|
||||
sparkle:arch="arm64"
|
||||
sparkle:edSignature="1tnET+iFDYEc9kdwV9U3mo4rExX0JBnlJOrcEQOGBwR/478NxbOsPx3AI/H7216HlylayNj7bYLVJY/FJqY2Dg=="
|
||||
length="37751728"
|
||||
type="application/zip"/>
|
||||
|
||||
<!-- Linux x86_64 -->
|
||||
<enclosure
|
||||
url="https://cdn.browseros.com/server/browseros_server_0.0.74_linux_x64.zip"
|
||||
sparkle:os="linux"
|
||||
sparkle:arch="x86_64"
|
||||
sparkle:edSignature="/OUrTZmgYWIWWWu71XAzN0B6hgs2WD9MOiZsXMvsv22TZwlEP1RdQsEO84JgFMb9if37MZX47utA2UWpSfFtAg=="
|
||||
length="39041390"
|
||||
type="application/zip"/>
|
||||
|
||||
<!-- Windows x86_64 -->
|
||||
<enclosure
|
||||
url="https://cdn.browseros.com/server/browseros_server_0.0.74_windows_x64.zip"
|
||||
sparkle:os="windows"
|
||||
sparkle:arch="x86_64"
|
||||
sparkle:edSignature="qd7XYvoa59QA1bSUkaXbtBCti8DQGh3mWWfPG1qtgk5InLXJ07Y0ve/Y6ZAn8fyz6XGLEgMVhUa6eblmVuUODw=="
|
||||
length="40986233"
|
||||
sparkle:edSignature="kkM3dFanJr9TQgRPV7NOs7GwYpVfLHH+Db6oUWLHTWQFODBy8wx46fD6sioQdsB4k+9Ra9QCBm0WRSvKDkljDQ=="
|
||||
length="101284695"
|
||||
type="application/zip"/>
|
||||
</item>
|
||||
|
||||
|
||||
55
packages/browseros/build/config/release.macos.arm64.yaml
Normal file
55
packages/browseros/build/config/release.macos.arm64.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
# BrowserOS macOS Release Build Configuration (arm64 only)
|
||||
#
|
||||
# Single-architecture arm64 release build. Skips the universal_build
|
||||
# pipeline (no x64, no lipo merge) — follows the standard per-arch flow
|
||||
# like release.windows.yaml / release.linux.yaml.
|
||||
#
|
||||
# Environment Variables:
|
||||
# Use !env tag to reference environment variables:
|
||||
# Example: chromium_src: !env CHROMIUM_SRC
|
||||
|
||||
build:
|
||||
type: release
|
||||
architecture: arm64
|
||||
|
||||
gn_flags:
|
||||
file: build/config/gn/flags.macos.release.gn
|
||||
|
||||
# Explicit module execution order
|
||||
modules:
|
||||
# Phase 1: Setup
|
||||
- clean
|
||||
- git_setup
|
||||
- sparkle_setup
|
||||
|
||||
# Phase 2: Patches & Resources
|
||||
- download_resources
|
||||
- resources
|
||||
- bundled_extensions
|
||||
- chromium_replace
|
||||
- string_replaces
|
||||
- series_patches
|
||||
- patches
|
||||
|
||||
# Phase 3: Build
|
||||
- configure
|
||||
- compile
|
||||
|
||||
# Phase 4: Sign & Package
|
||||
- sign_macos
|
||||
- package_macos
|
||||
|
||||
# Phase 5: Upload
|
||||
- upload
|
||||
|
||||
# Required environment variables
|
||||
# Note: CHROMIUM_SRC can be provided via --chromium-src CLI flag, YAML config, or env var
|
||||
required_envs:
|
||||
- MACOS_CERTIFICATE_NAME
|
||||
- PROD_MACOS_NOTARIZATION_APPLE_ID
|
||||
- PROD_MACOS_NOTARIZATION_TEAM_ID
|
||||
- PROD_MACOS_NOTARIZATION_PWD
|
||||
|
||||
# Notification settings
|
||||
notifications:
|
||||
slack: true
|
||||
12
packages/browseros/build/modules/ota/__init__.py
generated
12
packages/browseros/build/modules/ota/__init__.py
generated
@@ -9,12 +9,16 @@ from .common import (
|
||||
SignedArtifact,
|
||||
SERVER_PLATFORMS,
|
||||
APPCAST_TEMPLATE,
|
||||
find_server_binary,
|
||||
find_server_resources_dir,
|
||||
create_server_bundle_zip,
|
||||
)
|
||||
from .sign_binary import (
|
||||
sign_macos_binary,
|
||||
notarize_macos_binary,
|
||||
notarize_macos_zip,
|
||||
sign_windows_binary,
|
||||
sign_server_bundle_macos,
|
||||
sign_server_bundle_windows,
|
||||
)
|
||||
from .server import ServerOTAModule
|
||||
|
||||
@@ -30,10 +34,14 @@ __all__ = [
|
||||
"parse_existing_appcast",
|
||||
"ExistingAppcast",
|
||||
"SignedArtifact",
|
||||
"find_server_binary",
|
||||
"find_server_resources_dir",
|
||||
"create_server_bundle_zip",
|
||||
"sign_macos_binary",
|
||||
"notarize_macos_binary",
|
||||
"notarize_macos_zip",
|
||||
"sign_windows_binary",
|
||||
"sign_server_bundle_macos",
|
||||
"sign_server_bundle_windows",
|
||||
"SERVER_PLATFORMS",
|
||||
"APPCAST_TEMPLATE",
|
||||
]
|
||||
|
||||
93
packages/browseros/build/modules/ota/bundle_test.py
generated
Normal file
93
packages/browseros/build/modules/ota/bundle_test.py
generated
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for OTA bundle-zip creation."""
|
||||
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from .common import create_server_bundle_zip, find_server_resources_dir
|
||||
|
||||
|
||||
def _write_exec(path: Path, content: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
path.chmod(path.stat().st_mode | 0o755)
|
||||
|
||||
|
||||
class CreateServerBundleZipTest(unittest.TestCase):
|
||||
def test_bundles_full_resources_tree(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
staging = Path(tmp) / "darwin-arm64"
|
||||
resources = staging / "resources"
|
||||
_write_exec(resources / "bin" / "browseros_server", b"server")
|
||||
_write_exec(resources / "bin" / "third_party" / "bun", b"bun")
|
||||
_write_exec(resources / "bin" / "third_party" / "rg", b"rg")
|
||||
_write_exec(resources / "bin" / "third_party" / "podman" / "podman", b"pd")
|
||||
_write_exec(
|
||||
resources / "bin" / "third_party" / "podman" / "gvproxy", b"gv"
|
||||
)
|
||||
|
||||
zip_path = Path(tmp) / "bundle.zip"
|
||||
self.assertTrue(create_server_bundle_zip(resources, zip_path))
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = set(zf.namelist())
|
||||
|
||||
self.assertEqual(
|
||||
names,
|
||||
{
|
||||
"resources/bin/browseros_server",
|
||||
"resources/bin/third_party/bun",
|
||||
"resources/bin/third_party/rg",
|
||||
"resources/bin/third_party/podman/podman",
|
||||
"resources/bin/third_party/podman/gvproxy",
|
||||
},
|
||||
)
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "file mode check is meaningless on Windows")
|
||||
def test_preserves_executable_bits(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
resources = Path(tmp) / "darwin-arm64" / "resources"
|
||||
_write_exec(resources / "bin" / "browseros_server", b"server")
|
||||
|
||||
zip_path = Path(tmp) / "bundle.zip"
|
||||
self.assertTrue(create_server_bundle_zip(resources, zip_path))
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
info = zf.getinfo("resources/bin/browseros_server")
|
||||
|
||||
mode = (info.external_attr >> 16) & 0o777
|
||||
self.assertTrue(mode & stat.S_IXUSR)
|
||||
|
||||
def test_missing_resources_dir_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
missing = Path(tmp) / "does-not-exist"
|
||||
zip_path = Path(tmp) / "bundle.zip"
|
||||
self.assertFalse(create_server_bundle_zip(missing, zip_path))
|
||||
|
||||
|
||||
class FindServerResourcesDirTest(unittest.TestCase):
|
||||
def test_returns_resources_dir_when_present(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "darwin-arm64" / "resources" / "bin").mkdir(parents=True)
|
||||
found = find_server_resources_dir(
|
||||
root, {"name": "darwin_arm64", "target": "darwin-arm64"}
|
||||
)
|
||||
self.assertEqual(found, root / "darwin-arm64" / "resources")
|
||||
|
||||
def test_returns_none_when_absent(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
self.assertIsNone(
|
||||
find_server_resources_dir(
|
||||
root, {"name": "darwin_arm64", "target": "darwin-arm64"}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
88
packages/browseros/build/modules/ota/common.py
generated
88
packages/browseros/build/modules/ota/common.py
generated
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Common utilities for OTA update modules"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
@@ -13,8 +11,9 @@ from dataclasses import dataclass
|
||||
|
||||
from ...common.utils import log_error, log_info, log_success
|
||||
|
||||
# Re-export sparkle_sign_file from common module
|
||||
from ...common.sparkle import sparkle_sign_file
|
||||
# Re-exported so callers (and ota/__init__.py) can get sparkle_sign_file
|
||||
# from ota.common alongside the other OTA helpers.
|
||||
from ...common.sparkle import sparkle_sign_file as sparkle_sign_file
|
||||
|
||||
# Sparkle XML namespace
|
||||
SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle"
|
||||
@@ -76,33 +75,15 @@ class ExistingAppcast:
|
||||
artifacts: Dict[str, SignedArtifact]
|
||||
|
||||
|
||||
def find_server_binary(binaries_dir: Path, platform: dict) -> Optional[Path]:
|
||||
"""Find server binary in either flat or artifact-extracted directory structure.
|
||||
def find_server_resources_dir(binaries_dir: Path, platform: dict) -> Optional[Path]:
|
||||
"""Return the extracted ``resources/`` dir for a platform, or ``None``.
|
||||
|
||||
Supports two layouts:
|
||||
Flat: {binaries_dir}/{binary_name} (e.g., browseros-server-darwin-arm64)
|
||||
Artifact: {binaries_dir}/{target}/resources/bin/browseros_server[.exe]
|
||||
|
||||
Args:
|
||||
binaries_dir: Root directory containing server binaries
|
||||
platform: Platform dict from SERVER_PLATFORMS
|
||||
|
||||
Returns:
|
||||
Path to binary if found, None otherwise
|
||||
``binaries_dir`` is the temp root created by ``_download_artifacts``; each
|
||||
platform lives at ``<binaries_dir>/<target>/resources/``.
|
||||
"""
|
||||
# Flat structure (used with --binaries pointing to mono build output)
|
||||
flat_path = binaries_dir / platform["binary"]
|
||||
if flat_path.exists():
|
||||
return flat_path
|
||||
|
||||
# Artifact-extracted structure (used after download_resources)
|
||||
target = platform.get("target", platform["name"].replace("_", "-"))
|
||||
bin_name = "browseros_server.exe" if platform["os"] == "windows" else "browseros_server"
|
||||
artifact_path = binaries_dir / target / "resources" / "bin" / bin_name
|
||||
if artifact_path.exists():
|
||||
return artifact_path
|
||||
|
||||
return None
|
||||
resources = binaries_dir / target / "resources"
|
||||
return resources if resources.is_dir() else None
|
||||
|
||||
|
||||
def parse_existing_appcast(appcast_path: Path) -> Optional[ExistingAppcast]:
|
||||
@@ -254,46 +235,31 @@ def generate_server_appcast(
|
||||
)
|
||||
|
||||
|
||||
def create_server_zip(
|
||||
binary_path: Path,
|
||||
output_zip: Path,
|
||||
is_windows: bool = False,
|
||||
) -> bool:
|
||||
"""Create zip with proper structure: resources/bin/browseros_server
|
||||
def create_server_bundle_zip(resources_dir: Path, output_zip: Path) -> bool:
|
||||
"""Zip an extracted ``resources/`` tree into a Sparkle payload.
|
||||
|
||||
Args:
|
||||
binary_path: Path to the binary to package
|
||||
output_zip: Path for output zip file
|
||||
is_windows: Whether this is Windows binary (affects target name)
|
||||
|
||||
Returns:
|
||||
True on success, False on failure
|
||||
Produces entries like ``resources/bin/browseros_server``,
|
||||
``resources/bin/third_party/podman/podman`` — mirroring what the agent
|
||||
build staged and what the Chromium build bakes into the installed app.
|
||||
File modes are preserved by ``ZipFile.write`` so executable bits survive.
|
||||
"""
|
||||
staging_dir = output_zip.parent / f"staging_{output_zip.stem}"
|
||||
if not resources_dir.is_dir():
|
||||
log_error(f"Resources dir not found: {resources_dir}")
|
||||
return False
|
||||
|
||||
bundle_root = resources_dir.parent
|
||||
try:
|
||||
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
bin_dir = staging_dir / "resources" / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target_name = "browseros_server.exe" if is_windows else "browseros_server"
|
||||
shutil.copy2(binary_path, bin_dir / target_name)
|
||||
|
||||
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, _, files in os.walk(staging_dir):
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
arcname = file_path.relative_to(staging_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in sorted(resources_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
arcname = path.relative_to(bundle_root).as_posix()
|
||||
zf.write(path, arcname)
|
||||
log_success(f"Created {output_zip.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log_error(f"Failed to create zip: {e}")
|
||||
log_error(f"Failed to create bundle zip: {e}")
|
||||
return False
|
||||
finally:
|
||||
if staging_dir.exists():
|
||||
shutil.rmtree(staging_dir)
|
||||
|
||||
|
||||
def get_appcast_path(channel: str = "alpha") -> Path:
|
||||
|
||||
127
packages/browseros/build/modules/ota/server.py
generated
127
packages/browseros/build/modules/ota/server.py
generated
@@ -10,7 +10,6 @@ from ...common.module import CommandModule, ValidationError
|
||||
from ...common.context import Context
|
||||
from ...common.utils import (
|
||||
log_info,
|
||||
log_error,
|
||||
log_success,
|
||||
log_warning,
|
||||
IS_MACOS,
|
||||
@@ -23,15 +22,14 @@ from .common import (
|
||||
sparkle_sign_file,
|
||||
generate_server_appcast,
|
||||
parse_existing_appcast,
|
||||
create_server_zip,
|
||||
create_server_bundle_zip,
|
||||
get_appcast_path,
|
||||
find_server_binary,
|
||||
find_server_resources_dir,
|
||||
)
|
||||
from .sign_binary import (
|
||||
sign_macos_binary,
|
||||
notarize_macos_binary,
|
||||
sign_windows_binary,
|
||||
get_entitlements_path,
|
||||
notarize_macos_zip,
|
||||
sign_server_bundle_macos,
|
||||
sign_server_bundle_windows,
|
||||
)
|
||||
from ..storage import get_r2_client, upload_file_to_r2, download_file_from_r2
|
||||
from ..storage.download import extract_artifact_zip
|
||||
@@ -89,11 +87,8 @@ class ServerOTAModule(CommandModule):
|
||||
return [p for p in SERVER_PLATFORMS if p["name"] in requested]
|
||||
return SERVER_PLATFORMS
|
||||
|
||||
def _download_artifacts(self, ctx: Context) -> Path:
|
||||
"""Download server artifact zips from R2 latest/ and extract them."""
|
||||
download_dir = Path(tempfile.mkdtemp(prefix="ota_artifacts_"))
|
||||
self._download_dir = download_dir
|
||||
|
||||
def _download_artifacts(self, ctx: Context, download_dir: Path) -> None:
|
||||
"""Download and extract server artifact zips from R2 into ``download_dir``."""
|
||||
r2_client = get_r2_client(ctx.env)
|
||||
if not r2_client:
|
||||
raise RuntimeError("Failed to create R2 client")
|
||||
@@ -117,69 +112,85 @@ class ServerOTAModule(CommandModule):
|
||||
zip_path.unlink()
|
||||
|
||||
log_success(f"Downloaded {len(platforms)} artifact(s)")
|
||||
return download_dir
|
||||
|
||||
def execute(self, context: Context) -> None:
|
||||
ctx = context
|
||||
log_info(f"\n🚀 BrowserOS Server OTA v{self.version} ({self.channel})")
|
||||
log_info("=" * 70)
|
||||
|
||||
# Download artifacts from R2
|
||||
binaries_dir = self._download_artifacts(ctx)
|
||||
with tempfile.TemporaryDirectory(prefix="ota_artifacts_") as dl, \
|
||||
tempfile.TemporaryDirectory(prefix="ota_staging_") as st:
|
||||
binaries_dir = Path(dl)
|
||||
temp_dir = Path(st)
|
||||
log_info(f"Temp directory: {temp_dir}")
|
||||
|
||||
platforms = self._get_platforms()
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
log_info(f"Temp directory: {temp_dir}")
|
||||
self._download_artifacts(ctx, binaries_dir)
|
||||
signed_artifacts = self._build_platform_artifacts(
|
||||
ctx, binaries_dir, temp_dir
|
||||
)
|
||||
self._finalize_release(ctx, signed_artifacts)
|
||||
|
||||
def _build_platform_artifacts(
|
||||
self, ctx: Context, binaries_dir: Path, temp_dir: Path
|
||||
) -> List[SignedArtifact]:
|
||||
"""Sign + zip + Sparkle-sign each platform; fail fast on any error.
|
||||
|
||||
Any per-platform failure raises ``RuntimeError`` so a broken
|
||||
credential or unregistered binary cannot silently omit a platform
|
||||
from a published release.
|
||||
"""
|
||||
signed_artifacts: List[SignedArtifact] = []
|
||||
|
||||
for platform in platforms:
|
||||
for platform in self._get_platforms():
|
||||
log_info(f"\n📦 Processing {platform['name']}...")
|
||||
|
||||
source_binary = find_server_binary(binaries_dir, platform)
|
||||
if not source_binary:
|
||||
log_warning(f"Binary not found for {platform['name']}, skipping")
|
||||
continue
|
||||
source_resources = find_server_resources_dir(binaries_dir, platform)
|
||||
if not source_resources:
|
||||
raise RuntimeError(
|
||||
f"Resources dir not found for {platform['name']}"
|
||||
)
|
||||
|
||||
# Copy binary to temp to preserve original
|
||||
temp_binary = temp_dir / platform["binary"]
|
||||
shutil.copy2(source_binary, temp_binary)
|
||||
staging_resources = temp_dir / platform["name"] / "resources"
|
||||
shutil.copytree(source_resources, staging_resources)
|
||||
|
||||
if not self._sign_binary(temp_binary, platform, ctx):
|
||||
log_warning(f"Skipping {platform['name']} due to signing failure")
|
||||
continue
|
||||
if not self._sign_bundle(staging_resources, platform, ctx):
|
||||
raise RuntimeError(f"Signing failed for {platform['name']}")
|
||||
|
||||
zip_name = f"browseros_server_{self.version}_{platform['name']}.zip"
|
||||
zip_path = temp_dir / zip_name
|
||||
is_windows = platform["os"] == "windows"
|
||||
|
||||
if not create_server_zip(temp_binary, zip_path, is_windows):
|
||||
log_error(f"Failed to create zip for {platform['name']}")
|
||||
continue
|
||||
if not create_server_bundle_zip(staging_resources, zip_path):
|
||||
raise RuntimeError(f"Failed to create bundle for {platform['name']}")
|
||||
|
||||
if platform["os"] == "macos" and IS_MACOS():
|
||||
if not notarize_macos_zip(zip_path, ctx.env):
|
||||
raise RuntimeError(
|
||||
f"Notarization failed for {platform['name']}"
|
||||
)
|
||||
|
||||
log_info(f"Signing {zip_name} with Sparkle...")
|
||||
signature, length = sparkle_sign_file(zip_path, ctx.env)
|
||||
|
||||
if not signature:
|
||||
log_error(f"Failed to sign zip for {platform['name']}")
|
||||
continue
|
||||
raise RuntimeError(f"Sparkle signing failed for {platform['name']}")
|
||||
|
||||
log_success(f" {platform['name']}: {length} bytes")
|
||||
|
||||
artifact = SignedArtifact(
|
||||
signed_artifacts.append(SignedArtifact(
|
||||
platform=platform["name"],
|
||||
zip_path=zip_path,
|
||||
signature=signature,
|
||||
length=length,
|
||||
os=platform["os"],
|
||||
arch=platform["arch"],
|
||||
)
|
||||
signed_artifacts.append(artifact)
|
||||
))
|
||||
|
||||
if not signed_artifacts:
|
||||
log_error("No artifacts were processed successfully")
|
||||
raise RuntimeError("OTA failed - no artifacts")
|
||||
raise RuntimeError("OTA failed - no artifacts processed")
|
||||
return signed_artifacts
|
||||
|
||||
def _finalize_release(
|
||||
self, ctx: Context, signed_artifacts: List[SignedArtifact]
|
||||
) -> None:
|
||||
"""Write the appcast, upload every signed zip to R2, and surface URLs."""
|
||||
log_info("\n📝 Generating appcast...")
|
||||
appcast_path = get_appcast_path(self.channel)
|
||||
existing_appcast = parse_existing_appcast(appcast_path)
|
||||
@@ -219,27 +230,27 @@ class ServerOTAModule(CommandModule):
|
||||
log_info(f"\nAppcast saved to: {appcast_path}")
|
||||
log_info("\n📋 Next step: Run 'browseros ota server release-appcast' to make the release live")
|
||||
|
||||
def _sign_binary(self, binary_path: Path, platform: dict, ctx: Context) -> bool:
|
||||
"""Sign binary based on platform"""
|
||||
def _sign_bundle(
|
||||
self, staging_resources: Path, platform: dict, ctx: Context
|
||||
) -> bool:
|
||||
"""Codesign every binary in the staged resources tree for a platform.
|
||||
|
||||
macOS notarization happens separately, on the outer Sparkle zip.
|
||||
"""
|
||||
os_type = platform["os"]
|
||||
|
||||
if os_type == "macos":
|
||||
if not IS_MACOS():
|
||||
log_warning(f"macOS signing requires macOS - skipping {platform['name']}")
|
||||
log_warning(
|
||||
f"macOS signing requires macOS - leaving {platform['name']} unsigned"
|
||||
)
|
||||
return True
|
||||
return sign_server_bundle_macos(
|
||||
staging_resources, ctx.env, ctx.get_entitlements_dir()
|
||||
)
|
||||
|
||||
entitlements = get_entitlements_path(ctx.root_dir)
|
||||
if not sign_macos_binary(binary_path, ctx.env, entitlements):
|
||||
return False
|
||||
|
||||
log_info("Notarizing...")
|
||||
return notarize_macos_binary(binary_path, ctx.env)
|
||||
|
||||
elif os_type == "windows":
|
||||
return sign_windows_binary(binary_path, ctx.env)
|
||||
|
||||
elif os_type == "linux":
|
||||
log_info(f"No code signing for Linux binaries")
|
||||
return True
|
||||
if os_type == "windows":
|
||||
return sign_server_bundle_windows(staging_resources, ctx.env)
|
||||
|
||||
log_info("No code signing for Linux binaries")
|
||||
return True
|
||||
|
||||
273
packages/browseros/build/modules/ota/sign_binary.py
generated
273
packages/browseros/build/modules/ota/sign_binary.py
generated
@@ -1,12 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Platform-specific binary signing for OTA binaries"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from ...common.env import EnvConfig
|
||||
from ...common.server_binaries import (
|
||||
expected_windows_binary_paths,
|
||||
macos_sign_spec_for,
|
||||
)
|
||||
from ...common.utils import (
|
||||
log_info,
|
||||
log_error,
|
||||
@@ -21,16 +27,17 @@ def sign_macos_binary(
|
||||
binary_path: Path,
|
||||
env: Optional[EnvConfig] = None,
|
||||
entitlements_path: Optional[Path] = None,
|
||||
*,
|
||||
identifier: Optional[str] = None,
|
||||
options: str = "runtime",
|
||||
) -> bool:
|
||||
"""Sign a macOS binary with codesign
|
||||
"""Sign a macOS binary with codesign.
|
||||
|
||||
Args:
|
||||
binary_path: Path to binary to sign
|
||||
env: Environment config with certificate name
|
||||
entitlements_path: Optional path to entitlements plist
|
||||
|
||||
Returns:
|
||||
True on success, False on failure
|
||||
``identifier`` defaults to ``com.browseros.<stem>`` to preserve the
|
||||
previous single-binary signature shape. Callers that have a shared sign
|
||||
table (see ``common/server_binaries.py``) should pass identifier and
|
||||
options derived from that table so OTA-signed and Chromium-build-signed
|
||||
binaries share the same code identifier.
|
||||
"""
|
||||
if not IS_MACOS():
|
||||
log_error("macOS signing requires macOS")
|
||||
@@ -46,13 +53,14 @@ def sign_macos_binary(
|
||||
|
||||
log_info(f"Signing {binary_path.name}...")
|
||||
|
||||
resolved_identifier = identifier or f"com.browseros.{binary_path.stem}"
|
||||
cmd = [
|
||||
"codesign",
|
||||
"--sign", certificate_name,
|
||||
"--force",
|
||||
"--timestamp",
|
||||
"--identifier", f"com.browseros.{binary_path.stem}",
|
||||
"--options", "runtime",
|
||||
"--identifier", resolved_identifier,
|
||||
"--options", options,
|
||||
]
|
||||
|
||||
if entitlements_path and entitlements_path.exists():
|
||||
@@ -91,48 +99,91 @@ def verify_macos_signature(binary_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_notarization_credentials(
|
||||
env: Optional[EnvConfig],
|
||||
) -> Optional[EnvConfig]:
|
||||
if env is None:
|
||||
env = EnvConfig()
|
||||
|
||||
missing: List[str] = []
|
||||
if not env.macos_notarization_apple_id:
|
||||
missing.append("PROD_MACOS_NOTARIZATION_APPLE_ID")
|
||||
if not env.macos_notarization_team_id:
|
||||
missing.append("PROD_MACOS_NOTARIZATION_TEAM_ID")
|
||||
if not env.macos_notarization_password:
|
||||
missing.append("PROD_MACOS_NOTARIZATION_PWD")
|
||||
if missing:
|
||||
log_error("Missing notarization credentials:")
|
||||
for name in missing:
|
||||
log_error(f" {name} not set")
|
||||
return None
|
||||
return env
|
||||
|
||||
|
||||
def _submit_notarization(submission_path: Path, env: EnvConfig) -> bool:
|
||||
assert env.macos_notarization_apple_id is not None
|
||||
assert env.macos_notarization_team_id is not None
|
||||
assert env.macos_notarization_password is not None
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"xcrun", "notarytool", "store-credentials", "notarytool-profile",
|
||||
"--apple-id", env.macos_notarization_apple_id,
|
||||
"--team-id", env.macos_notarization_team_id,
|
||||
"--password", env.macos_notarization_password,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
log_info("Submitting for notarization (this may take a while)...")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"xcrun", "notarytool", "submit", str(submission_path),
|
||||
"--keychain-profile", "notarytool-profile",
|
||||
"--wait",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
log_error(f"Notarization failed: {result.stderr}")
|
||||
log_error(result.stdout)
|
||||
return False
|
||||
|
||||
if "status: Accepted" not in result.stdout:
|
||||
log_error("Notarization was not accepted")
|
||||
log_error(result.stdout)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def notarize_macos_binary(
|
||||
binary_path: Path,
|
||||
env: Optional[EnvConfig] = None,
|
||||
) -> bool:
|
||||
"""Notarize a macOS binary with Apple
|
||||
"""Notarize a single macOS binary with Apple.
|
||||
|
||||
The binary must be zipped for notarization submission.
|
||||
|
||||
Args:
|
||||
binary_path: Path to binary to notarize (will be zipped internally)
|
||||
env: Environment config with notarization credentials
|
||||
|
||||
Returns:
|
||||
True on success, False on failure
|
||||
The binary is first wrapped in a zip via ``ditto --keepParent`` because
|
||||
``notarytool`` does not accept bare executables. For an already-zipped
|
||||
Sparkle bundle, call :func:`notarize_macos_zip` instead — double-wrapping
|
||||
nests zips and notarytool does not descend into nested archives.
|
||||
"""
|
||||
if not IS_MACOS():
|
||||
log_error("macOS notarization requires macOS")
|
||||
return False
|
||||
|
||||
env = _resolve_notarization_credentials(env)
|
||||
if env is None:
|
||||
env = EnvConfig()
|
||||
|
||||
apple_id = env.macos_notarization_apple_id
|
||||
team_id = env.macos_notarization_team_id
|
||||
password = env.macos_notarization_password
|
||||
|
||||
if not all([apple_id, team_id, password]):
|
||||
log_error("Missing notarization credentials:")
|
||||
if not apple_id:
|
||||
log_error(" PROD_MACOS_NOTARIZATION_APPLE_ID not set")
|
||||
if not team_id:
|
||||
log_error(" PROD_MACOS_NOTARIZATION_TEAM_ID not set")
|
||||
if not password:
|
||||
log_error(" PROD_MACOS_NOTARIZATION_PWD not set")
|
||||
return False
|
||||
|
||||
log_info(f"Notarizing {binary_path.name}...")
|
||||
|
||||
notarize_zip = None
|
||||
notarize_zip: Optional[Path] = None
|
||||
try:
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".zip")
|
||||
import os
|
||||
os.close(fd)
|
||||
notarize_zip = Path(tmp_path)
|
||||
|
||||
@@ -146,41 +197,7 @@ def notarize_macos_binary(
|
||||
log_error(f"Failed to create zip: {result.stderr}")
|
||||
return False
|
||||
|
||||
assert apple_id is not None
|
||||
assert team_id is not None
|
||||
assert password is not None
|
||||
subprocess.run(
|
||||
[
|
||||
"xcrun", "notarytool", "store-credentials", "notarytool-profile",
|
||||
"--apple-id", apple_id,
|
||||
"--team-id", team_id,
|
||||
"--password", password,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
log_info("Submitting for notarization (this may take a while)...")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"xcrun", "notarytool", "submit", str(notarize_zip),
|
||||
"--keychain-profile", "notarytool-profile",
|
||||
"--wait",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
log_error(f"Notarization failed: {result.stderr}")
|
||||
log_error(result.stdout)
|
||||
return False
|
||||
|
||||
if "status: Accepted" not in result.stdout:
|
||||
log_error("Notarization was not accepted")
|
||||
log_error(result.stdout)
|
||||
if not _submit_notarization(notarize_zip, env):
|
||||
return False
|
||||
|
||||
log_success(f"Notarized {binary_path.name}")
|
||||
@@ -194,6 +211,33 @@ def notarize_macos_binary(
|
||||
notarize_zip.unlink()
|
||||
|
||||
|
||||
def notarize_macos_zip(zip_path: Path, env: Optional[EnvConfig] = None) -> bool:
|
||||
"""Notarize a pre-built Sparkle bundle zip by submitting it directly.
|
||||
|
||||
``notarytool`` accepts ``.zip`` submissions and recursively scans the
|
||||
Mach-O binaries inside. No extra wrapping — passing this zip through
|
||||
``ditto --keepParent`` would nest zips and Apple's service would not
|
||||
descend into the inner archive.
|
||||
"""
|
||||
if not IS_MACOS():
|
||||
log_error("macOS notarization requires macOS")
|
||||
return False
|
||||
|
||||
env = _resolve_notarization_credentials(env)
|
||||
if env is None:
|
||||
return False
|
||||
|
||||
log_info(f"Notarizing {zip_path.name}...")
|
||||
try:
|
||||
if not _submit_notarization(zip_path, env):
|
||||
return False
|
||||
log_success(f"Notarized {zip_path.name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
log_error(f"Notarization failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def sign_windows_binary(
|
||||
binary_path: Path,
|
||||
env: Optional[EnvConfig] = None,
|
||||
@@ -264,7 +308,6 @@ def sign_windows_binary(
|
||||
|
||||
signed_file = temp_output_dir / binary_path.name
|
||||
if signed_file.exists():
|
||||
import shutil
|
||||
shutil.move(str(signed_file), str(binary_path))
|
||||
|
||||
try:
|
||||
@@ -294,15 +337,81 @@ def sign_windows_binary(
|
||||
return False
|
||||
|
||||
|
||||
def get_entitlements_path(root_dir: Path) -> Optional[Path]:
|
||||
"""Get path to server binary entitlements file"""
|
||||
candidates = [
|
||||
root_dir / "resources" / "entitlements" / "browseros-executable-entitlements.plist",
|
||||
root_dir / "packages" / "browseros" / "resources" / "entitlements" / "browseros-executable-entitlements.plist",
|
||||
def sign_server_bundle_macos(
|
||||
resources_dir: Path,
|
||||
env: EnvConfig,
|
||||
entitlements_root: Path,
|
||||
) -> bool:
|
||||
"""Codesign every known binary under ``resources_dir/bin/**``.
|
||||
|
||||
Unknown executables are a hard error: every regular file under
|
||||
``resources/bin/`` must have an entry in ``MACOS_SERVER_BINARIES``.
|
||||
This prevents silently shipping an unsigned binary when a new
|
||||
third-party dep is added to the agent build without being registered
|
||||
in the shared sign table. The unknown-file check runs before any
|
||||
codesign call so a bad release fails in seconds rather than after
|
||||
several minutes of signing.
|
||||
"""
|
||||
bin_dir = resources_dir / "bin"
|
||||
if not bin_dir.is_dir():
|
||||
log_error(f"bin dir not found: {bin_dir}")
|
||||
return False
|
||||
|
||||
# Only Mach-O-style executables need signing; any future data/config file
|
||||
# shipped under resources/bin/ (plists, shell completion, etc.) is not a
|
||||
# codesign target and must not trigger the unknown-binary guard.
|
||||
executables = [
|
||||
p
|
||||
for p in sorted(bin_dir.rglob("*"))
|
||||
if p.is_file() and not p.is_symlink() and os.access(p, os.X_OK)
|
||||
]
|
||||
unknowns = [p for p in executables if macos_sign_spec_for(p) is None]
|
||||
if unknowns:
|
||||
log_error(
|
||||
"Unknown executables found under resources/bin/ not registered in "
|
||||
"MACOS_SERVER_BINARIES (see build/common/server_binaries.py):"
|
||||
)
|
||||
for path in unknowns:
|
||||
log_error(f" - {path.relative_to(resources_dir)}")
|
||||
return False
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
for path in executables:
|
||||
spec = macos_sign_spec_for(path)
|
||||
assert spec is not None # unknowns filtered above
|
||||
|
||||
return None
|
||||
entitlements_path: Optional[Path] = None
|
||||
if spec.entitlements:
|
||||
entitlements_path = entitlements_root / spec.entitlements
|
||||
if not entitlements_path.exists():
|
||||
log_error(
|
||||
f"Missing entitlements for {path.name}: {entitlements_path}"
|
||||
)
|
||||
return False
|
||||
|
||||
if not sign_macos_binary(
|
||||
path,
|
||||
env,
|
||||
entitlements_path,
|
||||
identifier=f"com.browseros.{spec.identifier_suffix}",
|
||||
options=spec.options,
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def sign_server_bundle_windows(resources_dir: Path, env: EnvConfig) -> bool:
|
||||
"""Sign each Windows binary enumerated in ``WINDOWS_SERVER_BINARIES``.
|
||||
|
||||
A missing expected binary is a hard error: publishing an incomplete
|
||||
Windows bundle would ship a broken OTA update without a pipeline signal.
|
||||
Symmetric with the macOS bundle's unknown-file guard.
|
||||
"""
|
||||
bin_dir = resources_dir / "bin"
|
||||
for path in expected_windows_binary_paths(bin_dir):
|
||||
if not path.exists():
|
||||
log_error(f"Windows binary missing (cannot sign): {path}")
|
||||
return False
|
||||
if not sign_windows_binary(path, env):
|
||||
return False
|
||||
return True
|
||||
|
||||
51
packages/browseros/build/modules/sign/macos.py
generated
51
packages/browseros/build/modules/sign/macos.py
generated
@@ -10,6 +10,7 @@ from typing import Optional, List, Dict, Tuple
|
||||
from ...common.module import CommandModule, ValidationError
|
||||
from ...common.context import Context
|
||||
from ...common.env import EnvConfig
|
||||
from ...common.server_binaries import macos_sign_spec_for
|
||||
from ...common.utils import (
|
||||
run_command as utils_run_command,
|
||||
log_info,
|
||||
@@ -20,49 +21,19 @@ from ...common.utils import (
|
||||
join_paths,
|
||||
)
|
||||
|
||||
# Central list of BrowserOS Server binaries we need to sign explicitly.
|
||||
# Each entry controls identifiers, signing options, and entitlement files so
|
||||
# adding a new binary is a one-line update here rather than scattered changes.
|
||||
BROWSEROS_SERVER_BINARIES: Dict[str, Dict[str, str]] = {
|
||||
"browseros_server": {
|
||||
"identifier_suffix": "browseros_server",
|
||||
"options": "runtime",
|
||||
"entitlements": "browseros-executable-entitlements.plist",
|
||||
},
|
||||
"bun": {
|
||||
"identifier_suffix": "bun",
|
||||
"options": "runtime",
|
||||
"entitlements": "browseros-executable-entitlements.plist",
|
||||
},
|
||||
"podman": {
|
||||
"identifier_suffix": "podman",
|
||||
"options": "runtime",
|
||||
},
|
||||
"gvproxy": {
|
||||
"identifier_suffix": "gvproxy",
|
||||
"options": "runtime",
|
||||
},
|
||||
"vfkit": {
|
||||
"identifier_suffix": "vfkit",
|
||||
"options": "runtime",
|
||||
"entitlements": "podman-vfkit-entitlements.plist",
|
||||
},
|
||||
"krunkit": {
|
||||
"identifier_suffix": "krunkit",
|
||||
"options": "runtime",
|
||||
"entitlements": "podman-krunkit-entitlements.plist",
|
||||
},
|
||||
"podman-mac-helper": {
|
||||
"identifier_suffix": "podman_mac_helper",
|
||||
"options": "runtime",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_browseros_server_binary_info(component_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Return metadata for known BrowserOS Server binaries, if applicable."""
|
||||
name = component_path.stem.lower()
|
||||
return BROWSEROS_SERVER_BINARIES.get(name)
|
||||
spec = macos_sign_spec_for(component_path)
|
||||
if spec is None:
|
||||
return None
|
||||
info: Dict[str, str] = {
|
||||
"identifier_suffix": spec.identifier_suffix,
|
||||
"options": spec.options,
|
||||
}
|
||||
if spec.entitlements:
|
||||
info["entitlements"] = spec.entitlements
|
||||
return info
|
||||
|
||||
|
||||
def run_command(
|
||||
|
||||
12
packages/browseros/build/modules/sign/windows.py
generated
12
packages/browseros/build/modules/sign/windows.py
generated
@@ -7,6 +7,7 @@ from typing import List, Optional
|
||||
from ...common.module import CommandModule, ValidationError
|
||||
from ...common.context import Context
|
||||
from ...common.env import EnvConfig
|
||||
from ...common.server_binaries import expected_windows_binary_paths
|
||||
from ...common.utils import (
|
||||
log_info,
|
||||
log_error,
|
||||
@@ -16,15 +17,6 @@ from ...common.utils import (
|
||||
IS_WINDOWS,
|
||||
)
|
||||
|
||||
BROWSEROS_SERVER_BINARIES: List[str] = [
|
||||
"browseros_server.exe",
|
||||
"third_party/bun.exe",
|
||||
"third_party/rg.exe",
|
||||
"third_party/podman/podman.exe",
|
||||
"third_party/podman/gvproxy.exe",
|
||||
"third_party/podman/win-sshproxy.exe",
|
||||
]
|
||||
|
||||
|
||||
class WindowsSignModule(CommandModule):
|
||||
produces = ["signed_installer"]
|
||||
@@ -105,7 +97,7 @@ class WindowsSignModule(CommandModule):
|
||||
def get_browseros_server_binary_paths(build_output_dir: Path) -> List[Path]:
|
||||
"""Return absolute paths to BrowserOS Server binaries for signing."""
|
||||
server_dir = build_output_dir / "BrowserOSServer" / "default" / "resources" / "bin"
|
||||
return [server_dir / Path(binary) for binary in BROWSEROS_SERVER_BINARIES]
|
||||
return expected_windows_binary_paths(server_dir)
|
||||
|
||||
|
||||
def build_mini_installer(ctx: Context) -> bool:
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
diff --git a/chrome/browser/devtools/protocol/browser_handler.cc b/chrome/browser/devtools/protocol/browser_handler.cc
|
||||
index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
index 30bd52d09c3fc..66746cef3fe0e 100644
|
||||
--- a/chrome/browser/devtools/protocol/browser_handler.cc
|
||||
+++ b/chrome/browser/devtools/protocol/browser_handler.cc
|
||||
@@ -4,23 +4,37 @@
|
||||
|
||||
#include "chrome/browser/devtools/protocol/browser_handler.h"
|
||||
|
||||
+#include <algorithm>
|
||||
#include <set>
|
||||
@@ -8,19 +8,32 @@
|
||||
#include <vector>
|
||||
|
||||
#include "base/functional/bind.h"
|
||||
@@ -40,29 +35,40 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
#include "content/public/browser/browser_task_traits.h"
|
||||
#include "content/public/browser/browser_thread.h"
|
||||
#include "content/public/browser/devtools_agent_host.h"
|
||||
@@ -30,10 +44,21 @@
|
||||
@@ -30,10 +43,32 @@
|
||||
#include "ui/gfx/image/image.h"
|
||||
#include "ui/gfx/image/image_png_rep.h"
|
||||
|
||||
+#if BUILDFLAG(IS_MAC)
|
||||
+#include "chrome/browser/devtools/protocol/browser_handler_mac.h"
|
||||
+#if BUILDFLAG(IS_LINUX)
|
||||
+#include <string_view>
|
||||
+#include "ui/ozone/platform_selection.h"
|
||||
+#endif
|
||||
+
|
||||
using protocol::Response;
|
||||
|
||||
namespace {
|
||||
|
||||
+#if !BUILDFLAG(IS_MAC)
|
||||
+// Off-screen position used to hide windows while keeping their compositors
|
||||
+// active. This enables CDP operations like Page.captureScreenshot on hidden
|
||||
+// windows. Uses cross-platform ui::BaseWindow::SetBounds/ShowInactive APIs.
|
||||
+constexpr int kOffScreenPosition = -32000;
|
||||
+// Hidden windows are realized without OS-visible presence via per-platform
|
||||
+// widget plumbing (see Widget::InitParams::headless). Wayland and ChromeOS
|
||||
+// don't yet have that plumbing; reject hidden-window CDP requests on those
|
||||
+// platforms until it lands.
|
||||
+bool HiddenWindowsSupportedOnThisPlatform() {
|
||||
+#if BUILDFLAG(IS_MAC) || BUILDFLAG(IS_WIN)
|
||||
+ return true;
|
||||
+#elif BUILDFLAG(IS_LINUX)
|
||||
+ // IS_LINUX covers both X11 and Wayland — the Ozone platform is chosen at
|
||||
+ // runtime. Only X11Window has the headless plumbing; Wayland would still
|
||||
+ // surface the window to the compositor.
|
||||
+ return std::string_view(ui::GetOzonePlatformName()) == "x11";
|
||||
+#else
|
||||
+ return false;
|
||||
+#endif
|
||||
+}
|
||||
+
|
||||
BrowserWindow* GetBrowserWindow(int window_id) {
|
||||
BrowserWindow* result = nullptr;
|
||||
ForEachCurrentBrowserWindowInterfaceOrderedByActivation(
|
||||
@@ -72,17 +97,419 @@ std::unique_ptr<protocol::Browser::Bounds> GetBrowserWindowBounds(
|
||||
@@ -72,12 +107,394 @@ std::unique_ptr<protocol::Browser::Bounds> GetBrowserWindowBounds(
|
||||
.Build();
|
||||
}
|
||||
|
||||
@@ -215,7 +221,6 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+
|
||||
+Response ResolveTabIdentifier(std::optional<std::string> target_id,
|
||||
+ std::optional<int> tab_id,
|
||||
+ const base::flat_set<int>& hidden_window_ids,
|
||||
+ TabLookupResult* result) {
|
||||
+ if (target_id.has_value() && tab_id.has_value()) {
|
||||
+ return Response::InvalidParams(
|
||||
@@ -254,8 +259,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ result->web_contents = wc;
|
||||
+ result->bwi = found_bwi;
|
||||
+ result->tab_index = found_index;
|
||||
+ result->is_hidden =
|
||||
+ hidden_window_ids.contains(found_bwi->GetSessionID().id());
|
||||
+ result->is_hidden = found_bwi->GetBrowserForMigrationOnly()->is_hidden();
|
||||
+ return Response::Success();
|
||||
+ }
|
||||
+
|
||||
@@ -288,8 +292,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ result->web_contents = found_wc;
|
||||
+ result->bwi = found_bwi;
|
||||
+ result->tab_index = found_index;
|
||||
+ result->is_hidden =
|
||||
+ hidden_window_ids.contains(found_bwi->GetSessionID().id());
|
||||
+ result->is_hidden = found_bwi->GetBrowserForMigrationOnly()->is_hidden();
|
||||
+ return Response::Success();
|
||||
+}
|
||||
+
|
||||
@@ -442,10 +445,9 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ out_indices->push_back(found_index);
|
||||
+ }
|
||||
+
|
||||
+ if (!(*out_bwi)->GetTabStripModel()->SupportsTabGroups()) {
|
||||
+ return Response::ServerError("Tab grouping not supported for this window");
|
||||
+ }
|
||||
+
|
||||
+ // TabStripModel::AddTo{New,Existing}Group require sorted, duplicate-free
|
||||
+ // indices (CHECK'd in release). Normalize here so any caller layout is
|
||||
+ // safe — caller order isn't preserved by the tab-group insertion anyway.
|
||||
+ std::ranges::sort(*out_indices);
|
||||
+ out_indices->erase(std::ranges::unique(*out_indices).begin(),
|
||||
+ out_indices->end());
|
||||
@@ -462,29 +464,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
if (dispatcher)
|
||||
protocol::Browser::Dispatcher::wire(dispatcher, this);
|
||||
}
|
||||
|
||||
-BrowserHandler::~BrowserHandler() = default;
|
||||
+BrowserHandler::~BrowserHandler() {
|
||||
+ // Close per-profile hidden windows so they don't become orphaned invisible
|
||||
+ // windows. Verify each still exists via GetBrowserWindowInterface before
|
||||
+ // touching it — during browser shutdown they may already be gone.
|
||||
+ for (auto& [profile, browser] : hidden_window_per_profile_) {
|
||||
+ if (!browser)
|
||||
+ continue;
|
||||
+ BrowserWindowInterface* bwi =
|
||||
+ GetBrowserWindowInterface(browser->session_id().id());
|
||||
+ if (bwi) {
|
||||
+ bwi->GetTabStripModel()->CloseAllTabs();
|
||||
+ bwi->GetWindow()->Close();
|
||||
+ }
|
||||
+ }
|
||||
+ hidden_window_per_profile_.clear();
|
||||
+ hidden_window_ids_.clear();
|
||||
+}
|
||||
|
||||
Response BrowserHandler::GetWindowForTarget(
|
||||
std::optional<std::string> target_id,
|
||||
@@ -120,6 +547,65 @@ Response BrowserHandler::GetWindowForTarget(
|
||||
@@ -120,6 +537,65 @@ Response BrowserHandler::GetWindowForTarget(
|
||||
return Response::Success();
|
||||
}
|
||||
|
||||
@@ -550,7 +530,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
Response BrowserHandler::GetWindowBounds(
|
||||
int window_id,
|
||||
std::unique_ptr<protocol::Browser::Bounds>* out_bounds) {
|
||||
@@ -297,3 +783,909 @@ protocol::Response BrowserHandler::AddPrivacySandboxEnrollmentOverride(
|
||||
@@ -297,3 +773,749 @@ protocol::Response BrowserHandler::AddPrivacySandboxEnrollmentOverride(
|
||||
net::SchemefulSite(url_to_add));
|
||||
return Response::Success();
|
||||
}
|
||||
@@ -565,7 +545,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ ForEachCurrentBrowserWindowInterfaceOrderedByActivation(
|
||||
+ [&](BrowserWindowInterface* bwi) {
|
||||
+ bool is_hidden =
|
||||
+ IsHiddenWindow(bwi->GetSessionID().id());
|
||||
+ bwi->GetBrowserForMigrationOnly()->is_hidden();
|
||||
+ windows->push_back(BuildWindowInfo(bwi, is_hidden));
|
||||
+ return true;
|
||||
+ });
|
||||
@@ -578,7 +558,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ BrowserWindowInterface* bwi =
|
||||
+ GetLastActiveBrowserWindowInterfaceWithAnyProfile();
|
||||
+ if (bwi) {
|
||||
+ bool is_hidden = IsHiddenWindow(bwi->GetSessionID().id());
|
||||
+ bool is_hidden = bwi->GetBrowserForMigrationOnly()->is_hidden();
|
||||
+ *out_window = BuildWindowInfo(bwi, is_hidden);
|
||||
+ }
|
||||
+ return Response::Success();
|
||||
@@ -606,7 +586,15 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ type = ParseWindowType(window_type.value());
|
||||
+ }
|
||||
+
|
||||
+ const bool want_hidden = hidden.value_or(false);
|
||||
+ if (want_hidden && !HiddenWindowsSupportedOnThisPlatform()) {
|
||||
+ return Response::ServerError(
|
||||
+ "Hidden windows are not yet supported on this platform. "
|
||||
+ "Use X11 (XDG_SESSION_TYPE=x11), macOS, or Windows.");
|
||||
+ }
|
||||
+
|
||||
+ Browser::CreateParams params(type, profile, true);
|
||||
+ params.hidden = want_hidden;
|
||||
+ if (bounds) {
|
||||
+ params.initial_bounds =
|
||||
+ gfx::Rect(bounds->GetLeft(0), bounds->GetTop(0),
|
||||
@@ -618,11 +606,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ GURL navigate_url = url.has_value() ? GURL(url.value()) : GURL();
|
||||
+ chrome::AddTabAt(browser, navigate_url, -1, true);
|
||||
+
|
||||
+ if (hidden.value_or(false)) {
|
||||
+ MakeWindowHidden(browser);
|
||||
+ } else {
|
||||
+ browser->window()->Show();
|
||||
+ }
|
||||
+ browser->window()->Show();
|
||||
+
|
||||
+ BrowserWindowInterface* bwi = GetBrowserWindowInterface(
|
||||
+ browser->session_id().id());
|
||||
@@ -639,16 +623,6 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ if (!bwi) {
|
||||
+ return Response::ServerError("Browser window not found");
|
||||
+ }
|
||||
+ hidden_window_ids_.erase(window_id);
|
||||
+ // Clean up hidden_window_per_profile_ if this was a hidden window.
|
||||
+ Browser* browser = bwi->GetBrowserForMigrationOnly();
|
||||
+ for (auto it = hidden_window_per_profile_.begin();
|
||||
+ it != hidden_window_per_profile_.end(); ++it) {
|
||||
+ if (it->second == browser) {
|
||||
+ hidden_window_per_profile_.erase(it);
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ bwi->GetTabStripModel()->CloseAllTabs();
|
||||
+ bwi->GetWindow()->Close();
|
||||
+ return Response::Success();
|
||||
@@ -663,28 +637,6 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ return Response::Success();
|
||||
+}
|
||||
+
|
||||
+Response BrowserHandler::ShowWindow(int window_id) {
|
||||
+ BrowserWindowInterface* bwi = GetBrowserWindowInterface(window_id);
|
||||
+ if (!bwi) {
|
||||
+ return Response::ServerError("Browser window not found");
|
||||
+ }
|
||||
+ if (IsHiddenWindow(window_id)) {
|
||||
+ MakeWindowVisible(bwi);
|
||||
+ }
|
||||
+ bwi->GetWindow()->Show();
|
||||
+ return Response::Success();
|
||||
+}
|
||||
+
|
||||
+Response BrowserHandler::HideWindow(int window_id) {
|
||||
+ BrowserWindowInterface* bwi = GetBrowserWindowInterface(window_id);
|
||||
+ if (!bwi) {
|
||||
+ return Response::ServerError("Browser window not found");
|
||||
+ }
|
||||
+ Browser* browser = bwi->GetBrowserForMigrationOnly();
|
||||
+ MakeWindowHidden(browser);
|
||||
+ return Response::Success();
|
||||
+}
|
||||
+
|
||||
+// --- Tab Management ---
|
||||
+
|
||||
+Response BrowserHandler::GetTabs(
|
||||
@@ -700,7 +652,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ if (!bwi) {
|
||||
+ return Response::ServerError("Browser window not found");
|
||||
+ }
|
||||
+ bool is_hidden = IsHiddenWindow(bwi->GetSessionID().id());
|
||||
+ bool is_hidden = bwi->GetBrowserForMigrationOnly()->is_hidden();
|
||||
+ TabStripModel* tab_strip = bwi->GetTabStripModel();
|
||||
+ for (int i = 0; i < tab_strip->count(); ++i) {
|
||||
+ tabs->push_back(
|
||||
@@ -708,9 +660,9 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ }
|
||||
+ } else {
|
||||
+ ForEachCurrentBrowserWindowInterfaceOrderedByActivation(
|
||||
+ [&tabs, this](BrowserWindowInterface* bwi) {
|
||||
+ [&tabs](BrowserWindowInterface* bwi) {
|
||||
+ bool is_hidden =
|
||||
+ IsHiddenWindow(bwi->GetSessionID().id());
|
||||
+ bwi->GetBrowserForMigrationOnly()->is_hidden();
|
||||
+ if (is_hidden) {
|
||||
+ return true;
|
||||
+ }
|
||||
@@ -725,8 +677,8 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+
|
||||
+ if (include_hidden.value_or(false) && !window_id.has_value()) {
|
||||
+ ForEachCurrentBrowserWindowInterfaceOrderedByActivation(
|
||||
+ [&tabs, this](BrowserWindowInterface* bwi) {
|
||||
+ if (!IsHiddenWindow(bwi->GetSessionID().id())) {
|
||||
+ [&tabs](BrowserWindowInterface* bwi) {
|
||||
+ if (!bwi->GetBrowserForMigrationOnly()->is_hidden()) {
|
||||
+ return true;
|
||||
+ }
|
||||
+ TabStripModel* tab_strip = bwi->GetTabStripModel();
|
||||
@@ -772,7 +724,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) {
|
||||
+ TabLookupResult lookup;
|
||||
+ Response response = ResolveTabIdentifier(target_id, tab_id,
|
||||
+ hidden_window_ids_, &lookup);
|
||||
+ &lookup);
|
||||
+ if (!response.IsSuccess())
|
||||
+ return response;
|
||||
+
|
||||
@@ -787,48 +739,12 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ std::optional<int> index,
|
||||
+ std::optional<bool> background,
|
||||
+ std::optional<bool> pinned,
|
||||
+ std::optional<bool> hidden,
|
||||
+ std::optional<std::string> browser_context_id,
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) {
|
||||
+ bool is_hidden = hidden.value_or(false);
|
||||
+
|
||||
+ if (is_hidden) {
|
||||
+ if (pinned.value_or(false)) {
|
||||
+ return Response::InvalidParams("Cannot pin a hidden tab");
|
||||
+ }
|
||||
+
|
||||
+ Profile* profile = nullptr;
|
||||
+ BrowserWindowInterface* last_active =
|
||||
+ GetLastActiveBrowserWindowInterfaceWithAnyProfile();
|
||||
+ if (last_active) {
|
||||
+ profile = last_active->GetProfile();
|
||||
+ }
|
||||
+ if (!profile) {
|
||||
+ return Response::ServerError("No profile available");
|
||||
+ }
|
||||
+
|
||||
+ Browser* hidden_browser = GetOrCreateHiddenWindow(profile);
|
||||
+ if (!hidden_browser) {
|
||||
+ return Response::ServerError("Failed to create hidden window for tab");
|
||||
+ }
|
||||
+
|
||||
+ GURL navigate_url = url.has_value() ? GURL(url.value()) : GURL();
|
||||
+ chrome::AddTabAt(hidden_browser, navigate_url, -1, false);
|
||||
+
|
||||
+ TabStripModel* tab_strip = hidden_browser->tab_strip_model();
|
||||
+ int new_index = tab_strip->count() - 1;
|
||||
+ content::WebContents* wc = tab_strip->GetWebContentsAt(new_index);
|
||||
+ if (!wc) {
|
||||
+ return Response::ServerError("Failed to create hidden tab");
|
||||
+ }
|
||||
+
|
||||
+ BrowserWindowInterface* bwi = GetBrowserWindowInterface(
|
||||
+ hidden_browser->session_id().id());
|
||||
+ *out_tab = BuildTabInfo(wc, bwi, new_index, true);
|
||||
+ return Response::Success();
|
||||
+ }
|
||||
+
|
||||
+ // Normal (visible) tab creation.
|
||||
+ // Tab visibility is derived from its window (a tab in a hidden window is
|
||||
+ // hidden; otherwise visible). Callers that need a hidden workspace must
|
||||
+ // createWindow(hidden=true) first, then createTab with the returned
|
||||
+ // windowId.
|
||||
+ BrowserWindowInterface* bwi = nullptr;
|
||||
+ if (window_id.has_value()) {
|
||||
+ bwi = GetBrowserWindowInterface(window_id.value());
|
||||
@@ -868,7 +784,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ std::optional<int> tab_id) {
|
||||
+ TabLookupResult lookup;
|
||||
+ Response response = ResolveTabIdentifier(target_id, tab_id,
|
||||
+ hidden_window_ids_, &lookup);
|
||||
+ &lookup);
|
||||
+ if (!response.IsSuccess())
|
||||
+ return response;
|
||||
+
|
||||
@@ -882,7 +798,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ std::optional<int> tab_id) {
|
||||
+ TabLookupResult lookup;
|
||||
+ Response response = ResolveTabIdentifier(target_id, tab_id,
|
||||
+ hidden_window_ids_, &lookup);
|
||||
+ &lookup);
|
||||
+ if (!response.IsSuccess())
|
||||
+ return response;
|
||||
+
|
||||
@@ -904,7 +820,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) {
|
||||
+ TabLookupResult lookup;
|
||||
+ Response response = ResolveTabIdentifier(target_id, tab_id,
|
||||
+ hidden_window_ids_, &lookup);
|
||||
+ &lookup);
|
||||
+ if (!response.IsSuccess())
|
||||
+ return response;
|
||||
+
|
||||
@@ -963,7 +879,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) {
|
||||
+ TabLookupResult lookup;
|
||||
+ Response response = ResolveTabIdentifier(target_id, tab_id,
|
||||
+ hidden_window_ids_, &lookup);
|
||||
+ &lookup);
|
||||
+ if (!response.IsSuccess())
|
||||
+ return response;
|
||||
+
|
||||
@@ -990,7 +906,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) {
|
||||
+ TabLookupResult lookup;
|
||||
+ Response response = ResolveTabIdentifier(target_id, tab_id,
|
||||
+ hidden_window_ids_, &lookup);
|
||||
+ &lookup);
|
||||
+ if (!response.IsSuccess())
|
||||
+ return response;
|
||||
+
|
||||
@@ -1011,7 +927,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) {
|
||||
+ TabLookupResult lookup;
|
||||
+ Response response = ResolveTabIdentifier(target_id, tab_id,
|
||||
+ hidden_window_ids_, &lookup);
|
||||
+ &lookup);
|
||||
+ if (!response.IsSuccess())
|
||||
+ return response;
|
||||
+
|
||||
@@ -1035,7 +951,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) {
|
||||
+ TabLookupResult lookup;
|
||||
+ Response response = ResolveTabIdentifier(target_id, tab_id,
|
||||
+ hidden_window_ids_, &lookup);
|
||||
+ &lookup);
|
||||
+ if (!response.IsSuccess())
|
||||
+ return response;
|
||||
+
|
||||
@@ -1064,8 +980,8 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ } else {
|
||||
+ // Find last active non-hidden window.
|
||||
+ ForEachCurrentBrowserWindowInterfaceOrderedByActivation(
|
||||
+ [this, &target_bwi](BrowserWindowInterface* bwi) {
|
||||
+ if (!IsHiddenWindow(bwi->GetSessionID().id())) {
|
||||
+ [&target_bwi](BrowserWindowInterface* bwi) {
|
||||
+ if (!bwi->GetBrowserForMigrationOnly()->is_hidden()) {
|
||||
+ target_bwi = bwi;
|
||||
+ return false;
|
||||
+ }
|
||||
@@ -1103,45 +1019,6 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ return Response::Success();
|
||||
+}
|
||||
+
|
||||
+Response BrowserHandler::HideTab(
|
||||
+ std::optional<std::string> target_id,
|
||||
+ std::optional<int> tab_id,
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) {
|
||||
+ TabLookupResult lookup;
|
||||
+ Response response = ResolveTabIdentifier(target_id, tab_id,
|
||||
+ hidden_window_ids_, &lookup);
|
||||
+ if (!response.IsSuccess())
|
||||
+ return response;
|
||||
+
|
||||
+ if (lookup.is_hidden) {
|
||||
+ return Response::InvalidParams("Tab is already hidden");
|
||||
+ }
|
||||
+
|
||||
+ // Detach from visible window.
|
||||
+ TabStripModel* source_strip = lookup.bwi->GetTabStripModel();
|
||||
+ std::unique_ptr<content::WebContents> detached =
|
||||
+ source_strip->DetachWebContentsAtForInsertion(lookup.tab_index);
|
||||
+ if (!detached) {
|
||||
+ return Response::ServerError("Failed to detach tab");
|
||||
+ }
|
||||
+
|
||||
+ // Insert into hidden window.
|
||||
+ Profile* profile =
|
||||
+ Profile::FromBrowserContext(detached->GetBrowserContext());
|
||||
+ Browser* hidden_browser = GetOrCreateHiddenWindow(profile);
|
||||
+
|
||||
+ content::WebContents* raw_wc = detached.get();
|
||||
+ hidden_browser->tab_strip_model()->InsertWebContentsAt(
|
||||
+ -1, std::move(detached), AddTabTypes::ADD_NONE);
|
||||
+
|
||||
+ BrowserWindowInterface* hidden_bwi = GetBrowserWindowInterface(
|
||||
+ hidden_browser->session_id().id());
|
||||
+ int new_index =
|
||||
+ hidden_browser->tab_strip_model()->GetIndexOfWebContents(raw_wc);
|
||||
+ *out_tab = BuildTabInfo(raw_wc, hidden_bwi, new_index, true);
|
||||
+ return Response::Success();
|
||||
+}
|
||||
+
|
||||
+// --- Tab Group Management ---
|
||||
+
|
||||
+Response BrowserHandler::GetTabGroups(
|
||||
@@ -1403,60 +1280,3 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
+ return Response::Success();
|
||||
+}
|
||||
+
|
||||
+// --- Hidden Window Helpers ---
|
||||
+
|
||||
+Browser* BrowserHandler::GetOrCreateHiddenWindow(Profile* profile) {
|
||||
+ auto it = hidden_window_per_profile_.find(profile);
|
||||
+ if (it != hidden_window_per_profile_.end()) {
|
||||
+ return it->second;
|
||||
+ }
|
||||
+
|
||||
+ Browser::CreateParams params(Browser::TYPE_NORMAL, profile, true);
|
||||
+ Browser* browser = Browser::Create(params);
|
||||
+
|
||||
+ // Add a blank tab so ShowInactive has content to composite.
|
||||
+ chrome::AddTabAt(browser, GURL(), -1, false);
|
||||
+ MakeWindowHidden(browser);
|
||||
+
|
||||
+ hidden_window_per_profile_[profile] = browser;
|
||||
+ return browser;
|
||||
+}
|
||||
+
|
||||
+void BrowserHandler::MakeWindowHidden(Browser* browser) {
|
||||
+#if BUILDFLAG(IS_MAC)
|
||||
+ SetWindowHeadless(browser->window(), true);
|
||||
+ browser->window()->ShowInactive();
|
||||
+#else
|
||||
+ gfx::Rect offscreen_bounds = browser->window()->GetBounds();
|
||||
+ offscreen_bounds.set_origin(
|
||||
+ gfx::Point(kOffScreenPosition, kOffScreenPosition));
|
||||
+ browser->window()->SetBounds(offscreen_bounds);
|
||||
+ browser->window()->ShowInactive();
|
||||
+#endif
|
||||
+ hidden_window_ids_.insert(browser->session_id().id());
|
||||
+}
|
||||
+
|
||||
+void BrowserHandler::MakeWindowVisible(BrowserWindowInterface* bwi) {
|
||||
+ Browser* browser = bwi->GetBrowserForMigrationOnly();
|
||||
+#if BUILDFLAG(IS_MAC)
|
||||
+ SetWindowHeadless(browser->window(), false);
|
||||
+#else
|
||||
+ gfx::Rect bounds = bwi->GetWindow()->GetBounds();
|
||||
+ bounds.set_origin(gfx::Point(100, 100));
|
||||
+ bwi->GetWindow()->SetBounds(bounds);
|
||||
+#endif
|
||||
+ hidden_window_ids_.erase(bwi->GetSessionID().id());
|
||||
+ // Remove from per-profile cache so GetOrCreateHiddenWindow will lazily
|
||||
+ // create a new hidden window for future hidden tab operations.
|
||||
+ for (auto it = hidden_window_per_profile_.begin();
|
||||
+ it != hidden_window_per_profile_.end(); ++it) {
|
||||
+ if (it->second == browser) {
|
||||
+ hidden_window_per_profile_.erase(it);
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+bool BrowserHandler::IsHiddenWindow(int window_id) const {
|
||||
+ return hidden_window_ids_.contains(window_id);
|
||||
+}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/chrome/browser/devtools/protocol/browser_handler.h b/chrome/browser/devtools/protocol/browser_handler.h
|
||||
index e1424aa52cbf6..ffd1c86c5aed9 100644
|
||||
index e1424aa52cbf6..412380c066d63 100644
|
||||
--- a/chrome/browser/devtools/protocol/browser_handler.h
|
||||
+++ b/chrome/browser/devtools/protocol/browser_handler.h
|
||||
@@ -5,9 +5,17 @@
|
||||
@@ -35,7 +35,7 @@ index e1424aa52cbf6..ffd1c86c5aed9 100644
|
||||
protocol::Response GetWindowBounds(
|
||||
int window_id,
|
||||
std::unique_ptr<protocol::Browser::Bounds>* out_bounds) override;
|
||||
@@ -41,9 +57,118 @@ class BrowserHandler : public protocol::Browser::Backend {
|
||||
@@ -41,6 +57,101 @@ class BrowserHandler : public protocol::Browser::Backend {
|
||||
protocol::Response AddPrivacySandboxEnrollmentOverride(
|
||||
const std::string& in_url) override;
|
||||
|
||||
@@ -54,8 +54,6 @@ index e1424aa52cbf6..ffd1c86c5aed9 100644
|
||||
+ std::unique_ptr<protocol::Browser::WindowInfo>* out_window) override;
|
||||
+ protocol::Response CloseWindow(int window_id) override;
|
||||
+ protocol::Response ActivateWindow(int window_id) override;
|
||||
+ protocol::Response ShowWindow(int window_id) override;
|
||||
+ protocol::Response HideWindow(int window_id) override;
|
||||
+
|
||||
+ // Tab management
|
||||
+ protocol::Response GetTabs(
|
||||
@@ -76,7 +74,6 @@ index e1424aa52cbf6..ffd1c86c5aed9 100644
|
||||
+ std::optional<int> index,
|
||||
+ std::optional<bool> background,
|
||||
+ std::optional<bool> pinned,
|
||||
+ std::optional<bool> hidden,
|
||||
+ std::optional<std::string> browser_context_id,
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) override;
|
||||
+ protocol::Response CloseTab(std::optional<std::string> target_id,
|
||||
@@ -108,10 +105,6 @@ index e1424aa52cbf6..ffd1c86c5aed9 100644
|
||||
+ std::optional<int> index,
|
||||
+ std::optional<bool> activate,
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) override;
|
||||
+ protocol::Response HideTab(
|
||||
+ std::optional<std::string> target_id,
|
||||
+ std::optional<int> tab_id,
|
||||
+ std::unique_ptr<protocol::Browser::TabInfo>* out_tab) override;
|
||||
+
|
||||
+ // Tab group management
|
||||
+ protocol::Response GetTabGroups(
|
||||
@@ -142,15 +135,5 @@ index e1424aa52cbf6..ffd1c86c5aed9 100644
|
||||
+ std::unique_ptr<protocol::Browser::TabGroupInfo>* out_group) override;
|
||||
+
|
||||
private:
|
||||
+ Browser* GetOrCreateHiddenWindow(Profile* profile);
|
||||
+ void MakeWindowHidden(Browser* browser);
|
||||
+ void MakeWindowVisible(BrowserWindowInterface* bwi);
|
||||
+ bool IsHiddenWindow(int window_id) const;
|
||||
+
|
||||
base::flat_set<std::string> contexts_with_overridden_permissions_;
|
||||
std::string target_id_;
|
||||
+ base::flat_set<int> hidden_window_ids_;
|
||||
+ base::flat_map<raw_ptr<Profile>, raw_ptr<Browser>> hidden_window_per_profile_;
|
||||
};
|
||||
|
||||
#endif // CHROME_BROWSER_DEVTOOLS_PROTOCOL_BROWSER_HANDLER_H_
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
diff --git a/chrome/browser/sessions/session_service_base.cc b/chrome/browser/sessions/session_service_base.cc
|
||||
index c4a1664d393c9..263ae4511c8f2 100644
|
||||
--- a/chrome/browser/sessions/session_service_base.cc
|
||||
+++ b/chrome/browser/sessions/session_service_base.cc
|
||||
@@ -819,6 +819,11 @@ bool SessionServiceBase::ShouldTrackBrowser(
|
||||
return false;
|
||||
}
|
||||
|
||||
+ // Hidden Browsers are ephemeral agent workspaces; never persist them.
|
||||
+ if (browser->GetBrowserForMigrationOnly()->is_hidden()) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
// Never track app popup windows that do not have a trusted source (i.e.
|
||||
// popup windows spawned by an app). If this logic changes, be sure to also
|
||||
// change SessionRestoreImpl::CreateRestoredBrowser().
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/chrome/browser/ui/browser.cc b/chrome/browser/ui/browser.cc
|
||||
index ca32d6faace3a..459c9597ea6f8 100644
|
||||
index ca32d6faace3a..b809394eb8b71 100644
|
||||
--- a/chrome/browser/ui/browser.cc
|
||||
+++ b/chrome/browser/ui/browser.cc
|
||||
@@ -42,6 +42,7 @@
|
||||
@@ -10,7 +10,77 @@ index ca32d6faace3a..459c9597ea6f8 100644
|
||||
#include "chrome/browser/buildflags.h"
|
||||
#include "chrome/browser/content_settings/host_content_settings_map_factory.h"
|
||||
#include "chrome/browser/content_settings/mixed_content_settings_tab_helper.h"
|
||||
@@ -2298,6 +2299,11 @@ bool Browser::ShouldFocusLocationBarByDefault(WebContents* source) {
|
||||
@@ -584,6 +585,7 @@ Browser::Browser(const CreateParams& params)
|
||||
is_trusted_source_(params.trusted_source),
|
||||
session_id_(SessionID::NewUnique()),
|
||||
omit_from_session_restore_(params.omit_from_session_restore),
|
||||
+ is_hidden_(params.hidden),
|
||||
should_trigger_session_restore_(params.should_trigger_session_restore),
|
||||
cancel_download_confirmation_state_(
|
||||
CancelDownloadConfirmationState::kNotPrompted),
|
||||
@@ -672,6 +674,10 @@ Browser::Browser(const CreateParams& params)
|
||||
}
|
||||
|
||||
Browser::~Browser() {
|
||||
+ // Release capturer-count pins early so renderers see Visibility updates
|
||||
+ // before WebContents is torn down.
|
||||
+ hidden_tab_pins_.clear();
|
||||
+
|
||||
if (!is_delete_scheduled_) {
|
||||
// Guarantee the Browser has performed the necessary cleanup in the
|
||||
// `OnWindowClosing()` lifecycle hook. This may not be invoked during
|
||||
@@ -1562,6 +1568,19 @@ WebContents* Browser::OpenURL(
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Browser, TabStripModelObserver implementation:
|
||||
|
||||
+void Browser::PinHiddenTabVisibility(content::WebContents* web_contents) {
|
||||
+ if (!web_contents || hidden_tab_pins_.contains(web_contents)) {
|
||||
+ return;
|
||||
+ }
|
||||
+ hidden_tab_pins_[web_contents] = web_contents->IncrementCapturerCount(
|
||||
+ gfx::Size(), /*stay_hidden=*/false, /*stay_awake=*/true,
|
||||
+ /*is_activity=*/true);
|
||||
+}
|
||||
+
|
||||
+void Browser::UnpinHiddenTabVisibility(content::WebContents* web_contents) {
|
||||
+ hidden_tab_pins_.erase(web_contents);
|
||||
+}
|
||||
+
|
||||
void Browser::OnTabStripModelChanged(TabStripModel* tab_strip_model,
|
||||
const TabStripModelChange& change,
|
||||
const TabStripSelectionChange& selection) {
|
||||
@@ -1579,6 +1598,9 @@ void Browser::OnTabStripModelChanged(TabStripModel* tab_strip_model,
|
||||
}
|
||||
for (const auto& contents : change.GetInsert()->contents) {
|
||||
OnTabInsertedAt(contents.contents, contents.index);
|
||||
+ if (is_hidden_) {
|
||||
+ PinHiddenTabVisibility(contents.contents);
|
||||
+ }
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1589,6 +1611,9 @@ void Browser::OnTabStripModelChanged(TabStripModel* tab_strip_model,
|
||||
}
|
||||
OnTabDetached(contents.contents,
|
||||
contents.contents == selection.old_contents);
|
||||
+ if (is_hidden_) {
|
||||
+ UnpinHiddenTabVisibility(contents.contents);
|
||||
+ }
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1601,6 +1626,10 @@ void Browser::OnTabStripModelChanged(TabStripModel* tab_strip_model,
|
||||
auto* replace = change.GetReplace();
|
||||
OnTabReplacedAt(replace->old_contents, replace->new_contents,
|
||||
replace->index);
|
||||
+ if (is_hidden_) {
|
||||
+ UnpinHiddenTabVisibility(replace->old_contents);
|
||||
+ PinHiddenTabVisibility(replace->new_contents);
|
||||
+ }
|
||||
break;
|
||||
}
|
||||
case TabStripModelChange::kSelectionOnly:
|
||||
@@ -2298,6 +2327,11 @@ bool Browser::ShouldFocusLocationBarByDefault(WebContents* source) {
|
||||
source->GetController().GetPendingEntry()
|
||||
? source->GetController().GetPendingEntry()
|
||||
: source->GetController().GetLastCommittedEntry();
|
||||
@@ -22,7 +92,7 @@ index ca32d6faace3a..459c9597ea6f8 100644
|
||||
if (entry) {
|
||||
const GURL& url = entry->GetURL();
|
||||
const GURL& virtual_url = entry->GetVirtualURL();
|
||||
@@ -2310,15 +2316,18 @@ bool Browser::ShouldFocusLocationBarByDefault(WebContents* source) {
|
||||
@@ -2310,15 +2344,18 @@ bool Browser::ShouldFocusLocationBarByDefault(WebContents* source) {
|
||||
url.host() == chrome::kChromeUINewTabHost) ||
|
||||
(virtual_url.SchemeIs(content::kChromeUIScheme) &&
|
||||
virtual_url.host() == chrome::kChromeUINewTabHost)) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
diff --git a/chrome/browser/ui/browser.h b/chrome/browser/ui/browser.h
|
||||
index 6dead6a78a90f..03e58a1c1d2a0 100644
|
||||
--- a/chrome/browser/ui/browser.h
|
||||
+++ b/chrome/browser/ui/browser.h
|
||||
@@ -13,6 +13,8 @@
|
||||
#include <vector>
|
||||
|
||||
#include "base/functional/callback.h"
|
||||
+#include "base/containers/flat_map.h"
|
||||
+#include "base/functional/callback_helpers.h"
|
||||
#include "base/gtest_prod_util.h"
|
||||
#include "base/memory/raw_ptr.h"
|
||||
#include "base/memory/scoped_refptr.h"
|
||||
@@ -286,6 +288,12 @@ class Browser : public TabStripModelObserver,
|
||||
// Whether this browser was created specifically for dragged tab(s).
|
||||
bool in_tab_dragging = false;
|
||||
|
||||
+ // Create the window as hidden (invisible to the OS compositor: no taskbar
|
||||
+ // entry, no Mission Control, no Alt-Tab). Used for offscreen agent
|
||||
+ // contexts. Decided at construction; does not change over a Browser's
|
||||
+ // lifetime.
|
||||
+ bool hidden = false;
|
||||
+
|
||||
// Supply a custom BrowserWindow implementation, to be used instead of the
|
||||
// default. Intended for testing. The resulting Browser takes ownership
|
||||
// of `window`.
|
||||
@@ -467,6 +475,7 @@ class Browser : public TabStripModelObserver,
|
||||
}
|
||||
|
||||
SessionID session_id() const { return session_id_; }
|
||||
+ bool is_hidden() const { return is_hidden_; }
|
||||
bool omit_from_session_restore() const { return omit_from_session_restore_; }
|
||||
bool should_trigger_session_restore() const {
|
||||
return should_trigger_session_restore_;
|
||||
@@ -1283,6 +1292,19 @@ class Browser : public TabStripModelObserver,
|
||||
// restore.
|
||||
bool omit_from_session_restore_ = false;
|
||||
|
||||
+ const bool is_hidden_;
|
||||
+
|
||||
+ // For hidden Browsers only: ScopedClosureRunners returned by
|
||||
+ // WebContents::IncrementCapturerCount, keyed by WebContents*. Holding the
|
||||
+ // runner pins Visibility::kVisible so pages in the hidden window behave as
|
||||
+ // foreground (unthrottled rAF, playing video, live DOM). Drop the runner
|
||||
+ // (on tab detach/remove or Browser destruction) to decrement the count.
|
||||
+ base::flat_map<content::WebContents*, base::ScopedClosureRunner>
|
||||
+ hidden_tab_pins_;
|
||||
+
|
||||
+ void PinHiddenTabVisibility(content::WebContents* web_contents);
|
||||
+ void UnpinHiddenTabVisibility(content::WebContents* web_contents);
|
||||
+
|
||||
// If true, a new window opening should be treated like the start of a session
|
||||
// (with potential session restore, startup URLs, etc.). Otherwise, don't
|
||||
// restore the session.
|
||||
@@ -0,0 +1,17 @@
|
||||
diff --git a/chrome/browser/ui/browser_finder.cc b/chrome/browser/ui/browser_finder.cc
|
||||
index a7e0eb934caaa..5002a1cb3100c 100644
|
||||
--- a/chrome/browser/ui/browser_finder.cc
|
||||
+++ b/chrome/browser/ui/browser_finder.cc
|
||||
@@ -154,6 +154,12 @@ bool BrowserMatches(BrowserWindowInterface* browser,
|
||||
return false;
|
||||
}
|
||||
|
||||
+ // Hidden Browsers are agent-owned scratch space; never pick them as a
|
||||
+ // default target for user-initiated actions (new tabs, find-any, etc.).
|
||||
+ if (browser->GetBrowserForMigrationOnly()->is_hidden()) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
diff --git a/chrome/browser/ui/browser_list.cc b/chrome/browser/ui/browser_list.cc
|
||||
index 0ff6437a325d1..3446dc27869fb 100644
|
||||
--- a/chrome/browser/ui/browser_list.cc
|
||||
+++ b/chrome/browser/ui/browser_list.cc
|
||||
@@ -128,6 +128,21 @@ void BrowserList::RemoveObserver(BrowserListObserver* observer) {
|
||||
observers_.Get().RemoveObserver(observer);
|
||||
}
|
||||
|
||||
+bool ShouldShowBrowserInUserInterface(const Browser* browser) {
|
||||
+ return browser && !browser->is_hidden();
|
||||
+}
|
||||
+
|
||||
+// static
|
||||
+BrowserList::BrowserVector BrowserList::GetUserVisibleBrowsers() {
|
||||
+ BrowserVector result;
|
||||
+ for (Browser* browser : GetInstance()->browsers_) {
|
||||
+ if (ShouldShowBrowserInUserInterface(browser)) {
|
||||
+ result.push_back(browser);
|
||||
+ }
|
||||
+ }
|
||||
+ return result;
|
||||
+}
|
||||
+
|
||||
// static
|
||||
void BrowserList::SetLastActive(Browser* browser) {
|
||||
BrowserList* instance = GetInstance();
|
||||
@@ -137,6 +152,12 @@ void BrowserList::SetLastActive(Browser* browser) {
|
||||
DCHECK(browser->window())
|
||||
<< "SetLastActive called for a browser with no window set.";
|
||||
|
||||
+ // Hidden Browsers never become last-active — FindLastActive and
|
||||
+ // default-new-tab resolution should always target user-visible windows.
|
||||
+ if (browser->is_hidden()) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
base::RecordAction(UserMetricsAction("ActiveBrowserChanged"));
|
||||
|
||||
RemoveBrowserFrom(browser, &instance->browsers_ordered_by_activation_);
|
||||
@@ -0,0 +1,29 @@
|
||||
diff --git a/chrome/browser/ui/browser_list.h b/chrome/browser/ui/browser_list.h
|
||||
index a8f0e5b82d586..ac636aa584728 100644
|
||||
--- a/chrome/browser/ui/browser_list.h
|
||||
+++ b/chrome/browser/ui/browser_list.h
|
||||
@@ -25,6 +25,12 @@ class Browser;
|
||||
class BrowserWindowInterface;
|
||||
class BrowserListObserver;
|
||||
|
||||
+// True if `browser` should appear in user-facing UI enumerations (tab search,
|
||||
+// window menus, drag-drop candidates, extensions API, etc.). Returns false for
|
||||
+// hidden Browsers — agent-owned workspaces that exist in BrowserList but are
|
||||
+// not part of the user's visible windowing experience.
|
||||
+bool ShouldShowBrowserInUserInterface(const Browser* browser);
|
||||
+
|
||||
// Maintains a list of Browser objects.
|
||||
class BrowserList {
|
||||
public:
|
||||
@@ -37,6 +43,11 @@ class BrowserList {
|
||||
|
||||
static BrowserList* GetInstance();
|
||||
|
||||
+ // Returns the BrowserList filtered to user-visible Browsers (see
|
||||
+ // ShouldShowBrowserInUserInterface). Use this — instead of GetInstance() —
|
||||
+ // at UI enumeration sites so hidden agent workspaces are excluded.
|
||||
+ static BrowserVector GetUserVisibleBrowsers();
|
||||
+
|
||||
// Adds or removes |browser| from the list it is associated with. The browser
|
||||
// object should be valid BEFORE these calls (for the benefit of observers),
|
||||
// so notify and THEN delete the object.
|
||||
@@ -0,0 +1,28 @@
|
||||
diff --git a/chrome/browser/ui/browser_unittest.cc b/chrome/browser/ui/browser_unittest.cc
|
||||
index 6a1925b7e1857..2797f54d2a6d2 100644
|
||||
--- a/chrome/browser/ui/browser_unittest.cc
|
||||
+++ b/chrome/browser/ui/browser_unittest.cc
|
||||
@@ -265,6 +265,23 @@ TEST_F(BrowserUnitTest, CreateBrowserWithIncognitoModeEnabled) {
|
||||
EXPECT_TRUE(otr_browser);
|
||||
}
|
||||
|
||||
+TEST_F(BrowserUnitTest, IsHiddenReflectsCreateParams) {
|
||||
+ Browser::CreateParams params(profile(), /*user_gesture=*/true);
|
||||
+ params.hidden = true;
|
||||
+ std::unique_ptr<BrowserWindow> hidden_window(CreateBrowserWindow());
|
||||
+ params.window = hidden_window.release();
|
||||
+ std::unique_ptr<Browser> browser =
|
||||
+ Browser::DeprecatedCreateOwnedForTesting(params);
|
||||
+ EXPECT_TRUE(browser->is_hidden());
|
||||
+
|
||||
+ Browser::CreateParams visible_params(profile(), /*user_gesture=*/true);
|
||||
+ std::unique_ptr<BrowserWindow> visible_window(CreateBrowserWindow());
|
||||
+ visible_params.window = visible_window.release();
|
||||
+ std::unique_ptr<Browser> visible =
|
||||
+ Browser::DeprecatedCreateOwnedForTesting(visible_params);
|
||||
+ EXPECT_FALSE(visible->is_hidden());
|
||||
+}
|
||||
+
|
||||
#if BUILDFLAG(IS_CHROMEOS)
|
||||
TEST_F(BrowserUnitTest, CreateBrowserDuringKioskSplashScreen) {
|
||||
// Setting up user manager state to be in kiosk mode:
|
||||
@@ -0,0 +1,12 @@
|
||||
diff --git a/chrome/browser/ui/views/frame/browser_native_widget_ash.cc b/chrome/browser/ui/views/frame/browser_native_widget_ash.cc
|
||||
index e40416204fd90..0297c184a5938 100644
|
||||
--- a/chrome/browser/ui/views/frame/browser_native_widget_ash.cc
|
||||
+++ b/chrome/browser/ui/views/frame/browser_native_widget_ash.cc
|
||||
@@ -189,6 +189,7 @@ views::Widget::InitParams BrowserNativeWidgetAsh::GetWidgetParams(
|
||||
params.context = ash::Shell::GetPrimaryRootWindow();
|
||||
|
||||
Browser* browser = browser_view_->browser();
|
||||
+ params.headless = browser->is_hidden();
|
||||
const int32_t restore_id = browser->create_params().restore_id;
|
||||
params.init_properties_container.SetProperty(app_restore::kWindowIdKey,
|
||||
browser->session_id().id());
|
||||
@@ -0,0 +1,14 @@
|
||||
diff --git a/chrome/browser/ui/views/frame/browser_native_widget_aura.cc b/chrome/browser/ui/views/frame/browser_native_widget_aura.cc
|
||||
index b4c409f8c0e43..5b77b13ca3024 100644
|
||||
--- a/chrome/browser/ui/views/frame/browser_native_widget_aura.cc
|
||||
+++ b/chrome/browser/ui/views/frame/browser_native_widget_aura.cc
|
||||
@@ -81,6 +81,9 @@ views::Widget::InitParams BrowserNativeWidgetAura::GetWidgetParams(
|
||||
views::Widget::InitParams::Ownership ownership) {
|
||||
views::Widget::InitParams params(ownership);
|
||||
params.native_widget = this;
|
||||
+ if (browser_view_) {
|
||||
+ params.headless = browser_view_->browser()->is_hidden();
|
||||
+ }
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
diff --git a/chrome/browser/ui/views/frame/browser_native_widget_mac.mm b/chrome/browser/ui/views/frame/browser_native_widget_mac.mm
|
||||
index 1fa6bb2578622..b871faddd3e1a 100644
|
||||
--- a/chrome/browser/ui/views/frame/browser_native_widget_mac.mm
|
||||
+++ b/chrome/browser/ui/views/frame/browser_native_widget_mac.mm
|
||||
@@ -531,6 +531,9 @@ views::Widget::InitParams BrowserNativeWidgetMac::GetWidgetParams(
|
||||
views::Widget::InitParams::Ownership ownership) {
|
||||
views::Widget::InitParams params(ownership);
|
||||
params.native_widget = this;
|
||||
+ if (browser_view_) {
|
||||
+ params.headless = browser_view_->browser()->is_hidden();
|
||||
+ }
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.mm b/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.mm
|
||||
index b89085a51a169..13314d0803d4a 100644
|
||||
--- a/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.mm
|
||||
+++ b/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.mm
|
||||
@@ -531,7 +531,7 @@ void NativeWidgetNSWindowBridge::InitWindow(
|
||||
is_translucent_window_ = params->is_translucent;
|
||||
pending_restoration_data_ = params->state_restoration_data;
|
||||
|
||||
- if (display::Screen::Get()->IsHeadless()) {
|
||||
+ if (params->is_headless || display::Screen::Get()->IsHeadless()) {
|
||||
[window_ setIsHeadless:YES];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
diff --git a/components/remote_cocoa/common/native_widget_ns_window.mojom b/components/remote_cocoa/common/native_widget_ns_window.mojom
|
||||
index 7387ef1852678..482f63d9f7f12 100644
|
||||
--- a/components/remote_cocoa/common/native_widget_ns_window.mojom
|
||||
+++ b/components/remote_cocoa/common/native_widget_ns_window.mojom
|
||||
@@ -90,6 +90,9 @@ struct NativeWidgetNSWindowInitParams {
|
||||
// If true, this window is functionally a tooltip, and shouldn't be presented
|
||||
// as a new window to the accessibility system.
|
||||
bool is_tooltip;
|
||||
+ // If true, InitWindow installs the per-window headless swizzler so the
|
||||
+ // NSWindow never appears in Dock, Mission Control, or Cmd-Tab.
|
||||
+ bool is_headless;
|
||||
};
|
||||
|
||||
// The visibility style of the toolbar in immersive fullscreen.
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/third_party/blink/public/devtools_protocol/domains/Browser.pdl b/third_party/blink/public/devtools_protocol/domains/Browser.pdl
|
||||
index 6015a94554c21..a25c14c912689 100644
|
||||
index 6015a94554c21..0d94b009929f8 100644
|
||||
--- a/third_party/blink/public/devtools_protocol/domains/Browser.pdl
|
||||
+++ b/third_party/blink/public/devtools_protocol/domains/Browser.pdl
|
||||
@@ -8,6 +8,16 @@
|
||||
@@ -19,7 +19,7 @@ index 6015a94554c21..a25c14c912689 100644
|
||||
|
||||
# The state of the browser window.
|
||||
experimental type WindowState extends string
|
||||
@@ -31,6 +41,228 @@ domain Browser
|
||||
@@ -31,6 +41,212 @@ domain Browser
|
||||
# The window state. Default to normal.
|
||||
optional WindowState windowState
|
||||
|
||||
@@ -90,14 +90,6 @@ index 6015a94554c21..a25c14c912689 100644
|
||||
+ parameters
|
||||
+ WindowID windowId
|
||||
+
|
||||
+ experimental command showWindow
|
||||
+ parameters
|
||||
+ WindowID windowId
|
||||
+
|
||||
+ experimental command hideWindow
|
||||
+ parameters
|
||||
+ WindowID windowId
|
||||
+
|
||||
+ # --- Tab Management ---
|
||||
+ # Commands that operate on a single tab accept both targetId and tabId.
|
||||
+ # Exactly one must be provided.
|
||||
@@ -129,7 +121,6 @@ index 6015a94554c21..a25c14c912689 100644
|
||||
+ optional integer index
|
||||
+ optional boolean background
|
||||
+ optional boolean pinned
|
||||
+ optional boolean hidden
|
||||
+ optional BrowserContextID browserContextId
|
||||
+ returns
|
||||
+ TabInfo tab
|
||||
@@ -184,13 +175,6 @@ index 6015a94554c21..a25c14c912689 100644
|
||||
+ returns
|
||||
+ TabInfo tab
|
||||
+
|
||||
+ experimental command hideTab
|
||||
+ parameters
|
||||
+ optional Target.TargetID targetId
|
||||
+ optional TabID tabId
|
||||
+ returns
|
||||
+ TabInfo tab
|
||||
+
|
||||
+ # --- Tab Group Management ---
|
||||
+
|
||||
+ # Get all tab groups across all windows (or a specific window).
|
||||
@@ -248,7 +232,7 @@ index 6015a94554c21..a25c14c912689 100644
|
||||
experimental type PermissionType extends string
|
||||
enum
|
||||
ar
|
||||
@@ -294,6 +526,28 @@ domain Browser
|
||||
@@ -294,6 +510,28 @@ domain Browser
|
||||
# position and size are returned.
|
||||
Bounds bounds
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
diff --git a/ui/ozone/platform/x11/x11_window.cc b/ui/ozone/platform/x11/x11_window.cc
|
||||
index 7af34829238f6..6cd3c5035825b 100644
|
||||
--- a/ui/ozone/platform/x11/x11_window.cc
|
||||
+++ b/ui/ozone/platform/x11/x11_window.cc
|
||||
@@ -366,6 +366,14 @@ void X11Window::Initialize(PlatformWindowInitProperties properties) {
|
||||
window_properties_.insert(x11::GetAtom("_NET_WM_STATE_SKIP_TASKBAR"));
|
||||
}
|
||||
|
||||
+ // Headless windows are agent-owned — never surface in taskbar, pager, or
|
||||
+ // window-switcher even if a WM ignores the unmap (see Show()).
|
||||
+ is_headless_ = properties.headless;
|
||||
+ if (is_headless_) {
|
||||
+ window_properties_.insert(x11::GetAtom("_NET_WM_STATE_SKIP_TASKBAR"));
|
||||
+ window_properties_.insert(x11::GetAtom("_NET_WM_STATE_SKIP_PAGER"));
|
||||
+ }
|
||||
+
|
||||
// If the window should stay on top of other windows, add the
|
||||
// _NET_WM_STATE_ABOVE property.
|
||||
is_always_on_top_ = properties.keep_on_top;
|
||||
@@ -491,6 +499,15 @@ void X11Window::Show(bool inactive) {
|
||||
return;
|
||||
}
|
||||
|
||||
+ if (is_headless_) {
|
||||
+ // Headless: the XWindow stays unmapped so no WM sees it. Notify the
|
||||
+ // delegate as if we mapped so the content compositor runs and paints.
|
||||
+ window_mapped_in_client_ = true;
|
||||
+ platform_window_delegate_->OnWindowStateChanged(
|
||||
+ PlatformWindowState::kUnknown, PlatformWindowState::kNormal);
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
Map(inactive);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
diff --git a/ui/ozone/platform/x11/x11_window.h b/ui/ozone/platform/x11/x11_window.h
|
||||
index f744189f0aa26..ad022d7303ccf 100644
|
||||
--- a/ui/ozone/platform/x11/x11_window.h
|
||||
+++ b/ui/ozone/platform/x11/x11_window.h
|
||||
@@ -428,6 +428,11 @@ class X11Window : public PlatformWindow,
|
||||
// True if the window is security-sensitive. Implies |is_always_on_top_|.
|
||||
bool is_security_surface_ = false;
|
||||
|
||||
+ // Mirrors PlatformWindowInitProperties::headless. When true the XWindow is
|
||||
+ // created but never mapped: _NET_WM_STATE_SKIP_TASKBAR + SKIP_PAGER hints
|
||||
+ // ensure no WM surface, Show() is a no-op at the X level.
|
||||
+ bool is_headless_ = false;
|
||||
+
|
||||
// True if the window is fully obscured by another window.
|
||||
bool is_occluded_ = false;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
diff --git a/ui/platform_window/platform_window_init_properties.h b/ui/platform_window/platform_window_init_properties.h
|
||||
index fe72bc821b0f7..f90b6cc15e2b6 100644
|
||||
--- a/ui/platform_window/platform_window_init_properties.h
|
||||
+++ b/ui/platform_window/platform_window_init_properties.h
|
||||
@@ -158,6 +158,13 @@ struct COMPONENT_EXPORT(PLATFORM_WINDOW) PlatformWindowInitProperties {
|
||||
bool enable_compositing_based_throttling = false;
|
||||
|
||||
size_t compositor_memory_limit_mb = 0;
|
||||
+
|
||||
+ // When true, the native window is created but never mapped/shown to the
|
||||
+ // OS compositor. Used by BrowserOS agent hidden windows: compositor still
|
||||
+ // runs (so pages render, screenshots work), but WM/taskbar/overview never
|
||||
+ // see it. On X11: SKIP_TASKBAR + SKIP_PAGER hints, XUnmapWindow. On
|
||||
+ // Wayland: not yet supported.
|
||||
+ bool headless = false;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
@@ -0,0 +1,12 @@
|
||||
diff --git a/ui/views/cocoa/native_widget_mac_ns_window_host.mm b/ui/views/cocoa/native_widget_mac_ns_window_host.mm
|
||||
index e40d0d5be1c48..0f4940de7214b 100644
|
||||
--- a/ui/views/cocoa/native_widget_mac_ns_window_host.mm
|
||||
+++ b/ui/views/cocoa/native_widget_mac_ns_window_host.mm
|
||||
@@ -475,6 +475,7 @@ void NativeWidgetMacNSWindowHost::InitWindow(
|
||||
window_params->is_translucent =
|
||||
params.opacity == Widget::InitParams::WindowOpacity::kTranslucent;
|
||||
window_params->is_tooltip = is_tooltip;
|
||||
+ window_params->is_headless = params.headless;
|
||||
|
||||
// macOS likes to put shadows on most things. However, frameless windows
|
||||
// (with styleMask = NSWindowStyleMaskBorderless) default to no shadow. So
|
||||
@@ -0,0 +1,12 @@
|
||||
diff --git a/ui/views/widget/desktop_aura/desktop_window_tree_host_platform.cc b/ui/views/widget/desktop_aura/desktop_window_tree_host_platform.cc
|
||||
index 17476127a1b05..7c56e3c4a290e 100644
|
||||
--- a/ui/views/widget/desktop_aura/desktop_window_tree_host_platform.cc
|
||||
+++ b/ui/views/widget/desktop_aura/desktop_window_tree_host_platform.cc
|
||||
@@ -141,6 +141,7 @@ ui::PlatformWindowInitProperties ConvertWidgetInitParamsToInitProperties(
|
||||
properties.workspace = params.workspace;
|
||||
properties.opacity = GetPlatformWindowOpacity(params.opacity);
|
||||
properties.shadow_type = GetPlatformWindowShadowType(params.shadow_type);
|
||||
+ properties.headless = params.headless;
|
||||
|
||||
if (params.parent && params.parent->GetHost()) {
|
||||
properties.parent_widget = params.parent->GetHost()->GetAcceleratedWidget();
|
||||
@@ -0,0 +1,26 @@
|
||||
diff --git a/ui/views/widget/desktop_aura/desktop_window_tree_host_win.cc b/ui/views/widget/desktop_aura/desktop_window_tree_host_win.cc
|
||||
index 3a02cb67eb114..41503dadbfbab 100644
|
||||
--- a/ui/views/widget/desktop_aura/desktop_window_tree_host_win.cc
|
||||
+++ b/ui/views/widget/desktop_aura/desktop_window_tree_host_win.cc
|
||||
@@ -169,6 +169,7 @@ void DesktopWindowTreeHostWin::FinishTouchDrag(gfx::Point screen_point) {
|
||||
// DesktopWindowTreeHostWin, DesktopWindowTreeHost implementation:
|
||||
|
||||
void DesktopWindowTreeHostWin::Init(const Widget::InitParams& params) {
|
||||
+ is_headless_ = params.headless;
|
||||
wm::SetAnimationHost(content_window(), this);
|
||||
if (params.type == Widget::InitParams::TYPE_WINDOW &&
|
||||
!params.remove_standard_frame) {
|
||||
@@ -313,6 +314,13 @@ void DesktopWindowTreeHostWin::Show(ui::mojom::WindowShowState show_state,
|
||||
const gfx::Rect& restore_bounds) {
|
||||
OnAcceleratedWidgetMadeVisible(true);
|
||||
|
||||
+ if (is_headless_) {
|
||||
+ // Headless: keep the aura side "visible" (so compositor runs + WebContents
|
||||
+ // sees a shown widget), but never call ShowWindow(SW_SHOW*) on the HWND.
|
||||
+ content_window()->Show();
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
gfx::Rect pixel_restore_bounds;
|
||||
if (show_state == ui::mojom::WindowShowState::kMaximized) {
|
||||
// The window parameter is intentionally passed as nullptr because a
|
||||
@@ -0,0 +1,17 @@
|
||||
diff --git a/ui/views/widget/desktop_aura/desktop_window_tree_host_win.h b/ui/views/widget/desktop_aura/desktop_window_tree_host_win.h
|
||||
index b65ced55f997d..8f9aecfc0fee7 100644
|
||||
--- a/ui/views/widget/desktop_aura/desktop_window_tree_host_win.h
|
||||
+++ b/ui/views/widget/desktop_aura/desktop_window_tree_host_win.h
|
||||
@@ -349,6 +349,12 @@ class VIEWS_EXPORT DesktopWindowTreeHostWin
|
||||
// True if the window is allow to take screenshots, by default is true.
|
||||
bool allow_screenshots_ = true;
|
||||
|
||||
+ // Honors Widget::InitParams::headless: the HWND is created but never
|
||||
+ // transitioned to visible via ShowWindow(SW_SHOW*), so the OS compositor
|
||||
+ // (taskbar, Alt-Tab, Task View, peek preview) doesn't see it. The aura
|
||||
+ // side still transitions to visible so the content compositor runs.
|
||||
+ bool is_headless_ = false;
|
||||
+
|
||||
// Visibility of the cursor. On Windows we can have multiple root windows and
|
||||
// the implementation of ::ShowCursor() is based on a counter, so making this
|
||||
// member static ensures that ::ShowCursor() is always called exactly once
|
||||
19
packages/browseros/chromium_patches/ui/views/widget/widget.h
Normal file
19
packages/browseros/chromium_patches/ui/views/widget/widget.h
Normal file
@@ -0,0 +1,19 @@
|
||||
diff --git a/ui/views/widget/widget.h b/ui/views/widget/widget.h
|
||||
index fe512d76f83fa..14493c081492f 100644
|
||||
--- a/ui/views/widget/widget.h
|
||||
+++ b/ui/views/widget/widget.h
|
||||
@@ -504,6 +504,14 @@ class VIEWS_EXPORT Widget : public internal::NativeWidgetDelegate,
|
||||
// If true then the widget uses software compositing.
|
||||
bool force_software_compositing = false;
|
||||
|
||||
+ // When true, the native widget is created without OS-visible presence
|
||||
+ // (no taskbar entry, no Mission Control, no Alt-Tab) while keeping the
|
||||
+ // compositor path alive. Honored per-platform by native widget
|
||||
+ // implementations (Mac swizzler, Windows WS_EX_TOOLWINDOW + never-show,
|
||||
+ // X11 unmapped-with-skip-taskbar). Decided at construction; never
|
||||
+ // transitions.
|
||||
+ bool headless = false;
|
||||
+
|
||||
// If set, the window size will follow the content preferred size.
|
||||
bool autosize = false;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
diff --git a/ui/views/widget/widget_hwnd_utils.cc b/ui/views/widget/widget_hwnd_utils.cc
|
||||
index 55e5b8a832c9c..ad41487c91e58 100644
|
||||
--- a/ui/views/widget/widget_hwnd_utils.cc
|
||||
+++ b/ui/views/widget/widget_hwnd_utils.cc
|
||||
@@ -56,6 +56,14 @@ WindowStyles CalculateWindowStylesFromInitParams(
|
||||
if (params.activatable == Widget::InitParams::Activatable::kNo) {
|
||||
styles.ex_style |= WS_EX_NOACTIVATE;
|
||||
}
|
||||
+ // Headless: belt-and-suspenders — even though the HWND is never shown
|
||||
+ // (see DesktopWindowTreeHostWin::Show), mark it as a tool window and
|
||||
+ // non-activating so that any accidental surface from the OS compositor
|
||||
+ // has no taskbar/Alt-Tab presence.
|
||||
+ if (params.headless) {
|
||||
+ styles.ex_style |= WS_EX_TOOLWINDOW;
|
||||
+ styles.ex_style |= WS_EX_NOACTIVATE;
|
||||
+ }
|
||||
if (params.EffectiveZOrderLevel() != ui::ZOrderLevel::kNormal) {
|
||||
styles.ex_style |= WS_EX_TOPMOST;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
diff --git a/ui/views/widget/widget_unittest.cc b/ui/views/widget/widget_unittest.cc
|
||||
index 9467ff86dfabb..b6d136d5f27bc 100644
|
||||
--- a/ui/views/widget/widget_unittest.cc
|
||||
+++ b/ui/views/widget/widget_unittest.cc
|
||||
@@ -264,6 +264,14 @@ TEST_F(WidgetTest, WidgetInitParams) {
|
||||
EXPECT_EQ(Widget::InitParams::WindowOpacity::kInferred, init1.opacity);
|
||||
}
|
||||
|
||||
+TEST_F(WidgetTest, HeadlessInitParamDefaultsFalse) {
|
||||
+ Widget::InitParams params(Widget::InitParams::CLIENT_OWNS_WIDGET,
|
||||
+ Widget::InitParams::TYPE_WINDOW);
|
||||
+ EXPECT_FALSE(params.headless);
|
||||
+ params.headless = true;
|
||||
+ EXPECT_TRUE(params.headless);
|
||||
+}
|
||||
+
|
||||
// Tests that the internal name is propagated through widget initialization to
|
||||
// the native widget and back.
|
||||
class WidgetWithCustomParamsTest : public WidgetTest {
|
||||
@@ -1,4 +1,4 @@
|
||||
BROWSEROS_MAJOR=0
|
||||
BROWSEROS_MINOR=45
|
||||
BROWSEROS_BUILD=0
|
||||
BROWSEROS_PATCH=1
|
||||
BROWSEROS_PATCH=2
|
||||
|
||||
Reference in New Issue
Block a user