mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
25 Commits
fix/test-g
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9264ea972b | ||
|
|
3f6b2e5694 | ||
|
|
4f6493f0d1 | ||
|
|
5ccdbaf87f | ||
|
|
0650f21c80 | ||
|
|
e80ec467f4 | ||
|
|
41374439c4 | ||
|
|
ad99cd6cc1 | ||
|
|
47fc9e1292 | ||
|
|
2a61dcbc58 | ||
|
|
f5a2b7315c | ||
|
|
6de3b3422c | ||
|
|
224b6cd3a8 | ||
|
|
7baee8d57e | ||
|
|
e8e8c36fdb | ||
|
|
3810005457 | ||
|
|
688f7962cb | ||
|
|
526d784d82 | ||
|
|
331fec07e6 | ||
|
|
0652ee8ca8 | ||
|
|
156f5dbc5d | ||
|
|
ebd3200cfe | ||
|
|
4172daa130 | ||
|
|
c1b1e53a86 | ||
|
|
d653883e99 |
198
.github/workflows/test.yml
vendored
198
.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"
|
||||
|
||||
@@ -136,3 +189,124 @@ jobs:
|
||||
echo "See the uploaded \`junit-${{ matrix.suite }}\` artifact for details." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
comment:
|
||||
name: PR test summary
|
||||
needs: test
|
||||
if: >-
|
||||
always()
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Download JUnit artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: junit
|
||||
pattern: junit-*
|
||||
|
||||
- name: Build comment body
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import glob, os, xml.etree.ElementTree as ET
|
||||
|
||||
run_url = f"{os.environ['GITHUB_SERVER_URL']}/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}"
|
||||
marker = "<!-- browseros-agent-tests-summary -->"
|
||||
|
||||
suites = []
|
||||
failed_cases = []
|
||||
total_tests = total_failed = total_skipped = 0
|
||||
|
||||
for xml_path in sorted(glob.glob("junit/junit-*/*.xml")):
|
||||
suite_name = os.path.basename(os.path.dirname(xml_path)).removeprefix("junit-")
|
||||
try:
|
||||
root = ET.parse(xml_path).getroot()
|
||||
except ET.ParseError:
|
||||
suites.append({"name": suite_name, "passed": 0, "failed": 1, "skipped": 0, "total": 1})
|
||||
total_tests += 1
|
||||
total_failed += 1
|
||||
failed_cases.append((suite_name, "(could not parse junit XML)"))
|
||||
continue
|
||||
|
||||
testsuites = root.findall("testsuite") if root.tag == "testsuites" else [root]
|
||||
s_tests = s_fail = s_err = s_skip = 0
|
||||
for ts in testsuites:
|
||||
s_tests += int(ts.get("tests") or 0)
|
||||
s_fail += int(ts.get("failures") or 0)
|
||||
s_err += int(ts.get("errors") or 0)
|
||||
s_skip += int(ts.get("skipped") or 0)
|
||||
for tc in ts.iter("testcase"):
|
||||
if tc.find("failure") is not None or tc.find("error") is not None:
|
||||
cls = tc.get("classname") or ""
|
||||
name = tc.get("name") or "(unnamed)"
|
||||
label = f"{cls} > {name}" if cls else name
|
||||
failed_cases.append((suite_name, label))
|
||||
|
||||
s_failed = s_fail + s_err
|
||||
s_passed = max(s_tests - s_failed - s_skip, 0)
|
||||
suites.append({"name": suite_name, "passed": s_passed, "failed": s_failed, "skipped": s_skip, "total": s_tests})
|
||||
total_tests += s_tests
|
||||
total_failed += s_failed
|
||||
total_skipped += s_skip
|
||||
|
||||
total_passed = max(total_tests - total_failed - total_skipped, 0)
|
||||
|
||||
if total_tests == 0:
|
||||
header = "## :warning: No test results were produced"
|
||||
elif total_failed == 0:
|
||||
header = f"## :white_check_mark: Tests passed — {total_passed}/{total_tests}"
|
||||
else:
|
||||
header = f"## :x: Tests failed — {total_failed}/{total_tests} failed"
|
||||
|
||||
lines = [marker, header, ""]
|
||||
if suites:
|
||||
lines.append("| Suite | Passed | Failed | Skipped |")
|
||||
lines.append("|-------|--------|--------|---------|")
|
||||
for s in suites:
|
||||
icon = ":white_check_mark:" if s["failed"] == 0 and s["total"] > 0 else ":warning:" if s["total"] == 0 else ":x:"
|
||||
lines.append(f"| {icon} `{s['name']}` | {s['passed']}/{s['total']} | {s['failed']} | {s['skipped']} |")
|
||||
|
||||
if failed_cases:
|
||||
lines += ["", "<details open>", "<summary><b>Failed tests</b></summary>", ""]
|
||||
for suite_name, label in failed_cases[:50]:
|
||||
lines.append(f"- **{suite_name}** — `{label}`")
|
||||
if len(failed_cases) > 50:
|
||||
lines.append(f"- …and {len(failed_cases) - 50} more")
|
||||
lines += ["", "</details>"]
|
||||
|
||||
lines += ["", f"[View workflow run]({run_url})"]
|
||||
|
||||
with open("comment.md", "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
PY
|
||||
|
||||
- name: Upsert sticky PR comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const body = fs.readFileSync('comment.md', 'utf8');
|
||||
const marker = '<!-- browseros-agent-tests-summary -->';
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.pull_request.number;
|
||||
|
||||
const triggerSha = context.payload.pull_request.head.sha;
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number });
|
||||
if (pr.head.sha !== triggerSha) {
|
||||
core.info(`PR head has moved (${pr.head.sha} vs ${triggerSha}) — skipping stale comment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number, per_page: 100,
|
||||
});
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number, body });
|
||||
}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
**/.DS_Store
|
||||
**.auctor/**
|
||||
.auctor.json
|
||||
.gcs_entries
|
||||
**/dmg
|
||||
**/env
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
buildChatHistoryFromTurns,
|
||||
chatWithAgent,
|
||||
type OpenClawStreamEvent,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
@@ -187,6 +188,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
|
||||
const send = async (text: string) => {
|
||||
if (!text.trim() || streaming) return
|
||||
const history = buildChatHistoryFromTurns(turns)
|
||||
|
||||
const turn: AgentConversationTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -207,6 +209,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
agentId,
|
||||
text.trim(),
|
||||
sessionKeyRef.current,
|
||||
history,
|
||||
abortController.signal,
|
||||
)
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
|
||||
import {
|
||||
buildChatHistoryFromTurns,
|
||||
chatWithAgent,
|
||||
type OpenClawStreamEvent,
|
||||
} from './useOpenClaw'
|
||||
|
||||
interface ToolEntry {
|
||||
id: string
|
||||
@@ -204,6 +208,7 @@ export const AgentChat: FC<AgentChatProps> = ({
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
const history = buildChatHistoryFromTurns(turns)
|
||||
|
||||
const turn: ChatTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -225,6 +230,7 @@ export const AgentChat: FC<AgentChatProps> = ({
|
||||
agentId,
|
||||
text,
|
||||
sessionKeyRef.current,
|
||||
history,
|
||||
abortController.signal,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type {
|
||||
BrowserOSCustomRoleInput,
|
||||
BrowserOSRoleBoundary,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
@@ -35,53 +33,26 @@ 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 GatewayLifecycleAction,
|
||||
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 LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
|
||||
setup: 'Setting up OpenClaw...',
|
||||
start: 'Starting gateway...',
|
||||
stop: 'Stopping gateway...',
|
||||
restart: 'Restarting gateway...',
|
||||
reconnect: 'Restoring gateway connection...',
|
||||
}
|
||||
|
||||
const CONTROL_PLANE_COPY: Record<
|
||||
@@ -267,6 +238,122 @@ const ProviderSelector: FC<ProviderSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const PodmanOverridesCard: FC = () => {
|
||||
const { overrides, loading, saving, error, saveOverrides, clearOverrides } =
|
||||
usePodmanOverrides()
|
||||
|
||||
const [value, setValue] = useState('')
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
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" className="font-medium text-sm">
|
||||
Podman binary path
|
||||
</label>
|
||||
<Input
|
||||
id="podman-path"
|
||||
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>
|
||||
)
|
||||
|
||||
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 +368,6 @@ export const AgentsPage: FC = () => {
|
||||
loading: agentsLoading,
|
||||
error: agentsError,
|
||||
} = useOpenClawAgents(agentsQueryEnabled)
|
||||
const { roles, loading: rolesLoading, error: rolesError } = useOpenClawRoles()
|
||||
const {
|
||||
setupOpenClaw,
|
||||
createAgent,
|
||||
@@ -295,48 +381,20 @@ export const AgentsPage: FC = () => {
|
||||
creating,
|
||||
deleting,
|
||||
reconnecting,
|
||||
pendingGatewayAction,
|
||||
} = useOpenClawMutations()
|
||||
|
||||
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 +413,18 @@ 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
|
||||
setNewName((current) => current || 'agent')
|
||||
}, [createOpen])
|
||||
|
||||
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])
|
||||
|
||||
const inlineError =
|
||||
error ??
|
||||
statusError?.message ??
|
||||
agentsError?.message ??
|
||||
rolesError?.message ??
|
||||
null
|
||||
const lifecyclePending = pendingGatewayAction !== null
|
||||
const inlineError = lifecyclePending
|
||||
? null
|
||||
: (error ?? statusError?.message ?? agentsError?.message ?? null)
|
||||
const lifecycleBanner = pendingGatewayAction
|
||||
? LIFECYCLE_BANNER_COPY[pendingGatewayAction]
|
||||
: null
|
||||
|
||||
const gatewayUiState = useMemo(() => {
|
||||
if (!status) {
|
||||
@@ -425,6 +453,10 @@ export const AgentsPage: FC = () => {
|
||||
}
|
||||
}, [status])
|
||||
|
||||
const canManageAgents = gatewayUiState.canManageAgents && !lifecyclePending
|
||||
const showControlPlaneDegraded =
|
||||
!lifecyclePending && gatewayUiState.controlPlaneDegraded
|
||||
|
||||
const recoveryDetail = status ? getRecoveryDetail(status) : null
|
||||
const controlPlaneCopy = status
|
||||
? getControlPlaneCopy(status.controlPlaneStatus)
|
||||
@@ -462,34 +494,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 +506,6 @@ export const AgentsPage: FC = () => {
|
||||
})
|
||||
setCreateOpen(false)
|
||||
setNewName('')
|
||||
setCustomRole({
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
recommendedApps: [],
|
||||
boundaries: createDefaultCustomRoleBoundaries(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -619,7 +620,7 @@ export const AgentsPage: FC = () => {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!gatewayUiState.canManageAgents}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
New Agent
|
||||
@@ -630,6 +631,13 @@ export const AgentsPage: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lifecycleBanner && (
|
||||
<Alert>
|
||||
<Loader2 className="animate-spin" />
|
||||
<AlertTitle>{lifecycleBanner}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{inlineError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
@@ -649,7 +657,7 @@ export const AgentsPage: FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status && gatewayUiState.controlPlaneDegraded && (
|
||||
{status && showControlPlaneDegraded && (
|
||||
<Alert
|
||||
variant={
|
||||
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
|
||||
@@ -770,7 +778,7 @@ export const AgentsPage: FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!gatewayUiState.canManageAgents}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Create Agent
|
||||
@@ -788,20 +796,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">
|
||||
@@ -809,7 +807,7 @@ export const AgentsPage: FC = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setChatAgent(agent)}
|
||||
disabled={!gatewayUiState.canManageAgents}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-4" />
|
||||
Chat
|
||||
@@ -819,7 +817,7 @@ export const AgentsPage: FC = () => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(agent.agentId)}
|
||||
disabled={!gatewayUiState.canManageAgents || deleting}
|
||||
disabled={!canManageAgents || deleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
@@ -832,6 +830,8 @@ export const AgentsPage: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PodmanOverridesCard />
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -868,246 +868,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 +901,8 @@ export const AgentsPage: FC = () => {
|
||||
disabled={
|
||||
!newName.trim() ||
|
||||
creating ||
|
||||
rolesLoading ||
|
||||
!gatewayUiState.canManageAgents ||
|
||||
compatibleProviders.length === 0 ||
|
||||
(creationMode === 'builtin' && !selectedRole)
|
||||
!canManageAgents ||
|
||||
compatibleProviders.length === 0
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
const OPENCLAW_SUPPORTED_PROVIDER_TYPES: ProviderType[] = [
|
||||
'openrouter',
|
||||
'openai',
|
||||
'openai-compatible',
|
||||
'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,21 @@ 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
|
||||
}
|
||||
|
||||
export type GatewayLifecycleAction =
|
||||
| 'setup'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'restart'
|
||||
| 'reconnect'
|
||||
|
||||
async function clawFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -117,16 +102,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 +154,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()
|
||||
@@ -278,6 +231,13 @@ export function useOpenClawMutations() {
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
let pendingGatewayAction: GatewayLifecycleAction | null = null
|
||||
if (setupMutation.isPending) pendingGatewayAction = 'setup'
|
||||
else if (restartMutation.isPending) pendingGatewayAction = 'restart'
|
||||
else if (stopMutation.isPending) pendingGatewayAction = 'stop'
|
||||
else if (startMutation.isPending) pendingGatewayAction = 'start'
|
||||
else if (reconnectMutation.isPending) pendingGatewayAction = 'reconnect'
|
||||
|
||||
return {
|
||||
setupOpenClaw: setupMutation.mutateAsync,
|
||||
createAgent: createMutation.mutateAsync,
|
||||
@@ -298,6 +258,51 @@ export function useOpenClawMutations() {
|
||||
creating: createMutation.isPending,
|
||||
deleting: deleteMutation.isPending,
|
||||
reconnecting: reconnectMutation.isPending,
|
||||
pendingGatewayAction,
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,17 +319,60 @@ export interface OpenClawStreamEvent {
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface OpenClawChatHistoryMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ChatHistoryTurnLike {
|
||||
userText: string
|
||||
parts: Array<{ kind: string; text?: string }>
|
||||
}
|
||||
|
||||
export function buildChatHistoryFromTurns(
|
||||
turns: ChatHistoryTurnLike[],
|
||||
): OpenClawChatHistoryMessage[] {
|
||||
const messages: OpenClawChatHistoryMessage[] = []
|
||||
|
||||
for (const turn of turns) {
|
||||
const userText = turn.userText.trim()
|
||||
if (userText) {
|
||||
messages.push({ role: 'user', content: userText })
|
||||
}
|
||||
|
||||
const assistantText = turn.parts
|
||||
.filter(
|
||||
(
|
||||
part,
|
||||
): part is {
|
||||
kind: 'text'
|
||||
text: string
|
||||
} => part.kind === 'text' && typeof part.text === 'string',
|
||||
)
|
||||
.map((part) => part.text.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
|
||||
if (assistantText) {
|
||||
messages.push({ role: 'assistant', content: assistantText })
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
export async function chatWithAgent(
|
||||
agentId: string,
|
||||
message: string,
|
||||
sessionKey?: string,
|
||||
history: OpenClawChatHistoryMessage[] = [],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, sessionKey }),
|
||||
body: JSON.stringify({ message, sessionKey, history }),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ import type { Browser } from '../../browser/browser'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { metrics } from '../../lib/metrics'
|
||||
import { Sentry } from '../../lib/sentry'
|
||||
import { getMonitoringService } from '../../monitoring/service'
|
||||
import type { ToolRegistry } from '../../tools/tool-registry'
|
||||
import type { GlobalAclPolicyService } from '../services/acl/global-acl-policy'
|
||||
import { resolveAclPolicyForMcpRequest } from '../services/acl/resolve-acl-policy'
|
||||
@@ -39,16 +40,35 @@ export function createMcpRoutes(deps: McpRouteDeps) {
|
||||
|
||||
app.post('/', async (c) => {
|
||||
const scopeId = c.req.header('X-BrowserOS-Scope-Id') || 'ephemeral'
|
||||
const monitoringService = getMonitoringService()
|
||||
const explicitAgentId =
|
||||
c.req.query('agentId') ??
|
||||
c.req.header('X-BrowserOS-Agent-Id') ??
|
||||
undefined
|
||||
const activeSession = explicitAgentId
|
||||
? {
|
||||
agentId: explicitAgentId,
|
||||
monitoringSessionId:
|
||||
monitoringService.getActiveSessionId(explicitAgentId),
|
||||
}
|
||||
: monitoringService.getSingleActiveSession()
|
||||
const agentId = activeSession?.agentId
|
||||
metrics.log('mcp.request', { scopeId })
|
||||
const aclRules = await resolveAclPolicyForMcpRequest({
|
||||
policyService: deps.policyService,
|
||||
})
|
||||
const monitoringSessionId = activeSession?.monitoringSessionId
|
||||
const observer =
|
||||
monitoringSessionId && agentId
|
||||
? monitoringService.createObserver(monitoringSessionId, agentId)
|
||||
: undefined
|
||||
|
||||
// Per-request server + transport: no shared state, no race conditions,
|
||||
// no ID collisions. Required by MCP SDK 1.26.0+ security fix (GHSA-345p-7cg4-v4c7).
|
||||
const mcpServer = createMcpServer({
|
||||
...deps,
|
||||
aclRules,
|
||||
observer,
|
||||
})
|
||||
const transport = new StreamableHTTPTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
@@ -62,6 +82,9 @@ export function createMcpRoutes(deps: McpRouteDeps) {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('route', 'mcp')
|
||||
scope.setTag('scopeId', scopeId)
|
||||
if (agentId) {
|
||||
scope.setTag('agentId', agentId)
|
||||
}
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
logger.error('Error handling MCP request', {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Hono } from 'hono'
|
||||
import { getMonitoringService } from '../../monitoring/service'
|
||||
import { isValidMonitoringRunId } from '../../monitoring/storage'
|
||||
|
||||
export function createMonitoringRoutes() {
|
||||
return new Hono()
|
||||
.get('/runs', async (c) => {
|
||||
const limitParam = c.req.query('limit')
|
||||
const parsedLimit = limitParam ? Number.parseInt(limitParam, 10) : 50
|
||||
const limit =
|
||||
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 50
|
||||
|
||||
const runs = await getMonitoringService().listRuns(limit)
|
||||
return c.json({ runs })
|
||||
})
|
||||
.get('/runs/:id', async (c) => {
|
||||
const runId = c.req.param('id')
|
||||
if (!isValidMonitoringRunId(runId)) {
|
||||
return c.json({ error: 'Invalid monitoring run id' }, 400)
|
||||
}
|
||||
const envelope = await getMonitoringService().getRunEnvelope(runId)
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Monitoring run not found' }, 404)
|
||||
}
|
||||
|
||||
return c.json({ run: envelope })
|
||||
})
|
||||
.post('/debug/runs', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
agentId?: string
|
||||
sessionKey?: string
|
||||
originalPrompt?: string
|
||||
chatHistory?: Array<{ role?: 'user' | 'assistant'; content?: string }>
|
||||
}>()
|
||||
|
||||
if (!body.agentId?.trim()) {
|
||||
return c.json({ error: 'agentId is required' }, 400)
|
||||
}
|
||||
if (!body.sessionKey?.trim()) {
|
||||
return c.json({ error: 'sessionKey is required' }, 400)
|
||||
}
|
||||
if (!body.originalPrompt?.trim()) {
|
||||
return c.json({ error: 'originalPrompt is required' }, 400)
|
||||
}
|
||||
|
||||
const chatHistory = Array.isArray(body.chatHistory)
|
||||
? body.chatHistory
|
||||
.filter(
|
||||
(turn): turn is { role: 'user' | 'assistant'; content: string } =>
|
||||
(turn.role === 'user' || turn.role === 'assistant') &&
|
||||
typeof turn.content === 'string',
|
||||
)
|
||||
.map((turn) => ({
|
||||
role: turn.role,
|
||||
content: turn.content,
|
||||
}))
|
||||
: []
|
||||
|
||||
const session = await getMonitoringService().startSession({
|
||||
agentId: body.agentId.trim(),
|
||||
sessionKey: body.sessionKey.trim(),
|
||||
originalPrompt: body.originalPrompt.trim(),
|
||||
chatHistory,
|
||||
source: 'debug',
|
||||
})
|
||||
|
||||
return c.json({ session }, 201)
|
||||
})
|
||||
.post('/debug/runs/:id/finalize', async (c) => {
|
||||
const runId = c.req.param('id')
|
||||
if (!isValidMonitoringRunId(runId)) {
|
||||
return c.json({ error: 'Invalid monitoring run id' }, 400)
|
||||
}
|
||||
const body = await c.req.json<{
|
||||
agentId?: string
|
||||
sessionKey?: string
|
||||
status?: 'completed' | 'failed' | 'aborted' | 'incomplete'
|
||||
finalAssistantMessage?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
if (!body.agentId?.trim()) {
|
||||
return c.json({ error: 'agentId is required' }, 400)
|
||||
}
|
||||
if (!body.sessionKey?.trim()) {
|
||||
return c.json({ error: 'sessionKey is required' }, 400)
|
||||
}
|
||||
if (
|
||||
body.status !== 'completed' &&
|
||||
body.status !== 'failed' &&
|
||||
body.status !== 'aborted' &&
|
||||
body.status !== 'incomplete'
|
||||
) {
|
||||
return c.json({ error: 'status is invalid' }, 400)
|
||||
}
|
||||
|
||||
const envelope = await getMonitoringService().finalizeSession({
|
||||
monitoringSessionId: runId,
|
||||
agentId: body.agentId.trim(),
|
||||
sessionKey: body.sessionKey.trim(),
|
||||
status: body.status,
|
||||
finalAssistantMessage: body.finalAssistantMessage,
|
||||
error: body.error,
|
||||
})
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Monitoring run not found' }, 404)
|
||||
}
|
||||
|
||||
return c.json({ run: envelope })
|
||||
})
|
||||
}
|
||||
@@ -7,38 +7,48 @@
|
||||
* Thin layer delegating to OpenClawService.
|
||||
*/
|
||||
|
||||
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 { accessSync, existsSync, constants as fsConstants } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { getMonitoringService } from '../../monitoring/service'
|
||||
import type { MonitoringChatTurn } from '../../monitoring/types'
|
||||
import {
|
||||
OpenClawAgentAlreadyExistsError,
|
||||
OpenClawAgentNotFoundError,
|
||||
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() {
|
||||
@@ -72,7 +82,7 @@ export function createOpenClawRoutes() {
|
||||
return c.json(
|
||||
{
|
||||
status: 'running',
|
||||
port: OPENCLAW_GATEWAY_PORT,
|
||||
port: getOpenClawService().getPort(),
|
||||
agents: agents.map((a) => ({
|
||||
agentId: a.agentId,
|
||||
name: a.name,
|
||||
@@ -89,6 +99,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 +167,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 +198,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)
|
||||
}
|
||||
@@ -287,6 +229,7 @@ export function createOpenClawRoutes() {
|
||||
const body = await c.req.json<{
|
||||
message: string
|
||||
sessionKey?: string
|
||||
history?: MonitoringChatTurn[]
|
||||
}>()
|
||||
|
||||
if (!body.message?.trim()) {
|
||||
@@ -294,12 +237,37 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
|
||||
const sessionKey = body.sessionKey ?? crypto.randomUUID()
|
||||
const history = Array.isArray(body.history)
|
||||
? body.history.filter((entry): entry is MonitoringChatTurn =>
|
||||
Boolean(
|
||||
entry &&
|
||||
(entry.role === 'user' || entry.role === 'assistant') &&
|
||||
typeof entry.content === 'string',
|
||||
),
|
||||
)
|
||||
: []
|
||||
if (getMonitoringService().getActiveSessionId(id)) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'A monitored chat session is already active for this agent. Wait for it to finish before starting another.',
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
const monitoringContext = await getMonitoringService().startSession({
|
||||
agentId: id,
|
||||
sessionKey,
|
||||
originalPrompt: body.message.trim(),
|
||||
chatHistory: history,
|
||||
})
|
||||
|
||||
try {
|
||||
const eventStream = await getOpenClawService().chatStream(
|
||||
id,
|
||||
sessionKey,
|
||||
body.message,
|
||||
history,
|
||||
)
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
@@ -309,20 +277,68 @@ export function createOpenClawRoutes() {
|
||||
return stream(c, async (s) => {
|
||||
const reader = eventStream.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
let finalAssistantMessage: string | undefined
|
||||
let status: 'completed' | 'failed' | 'aborted' | 'incomplete' =
|
||||
'incomplete'
|
||||
let finalError: string | undefined
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (
|
||||
value.type === 'done' &&
|
||||
typeof value.data.text === 'string' &&
|
||||
value.data.text.trim()
|
||||
) {
|
||||
finalAssistantMessage = value.data.text
|
||||
status = 'completed'
|
||||
}
|
||||
if (value.type === 'error') {
|
||||
finalError =
|
||||
(typeof value.data.message === 'string'
|
||||
? value.data.message
|
||||
: typeof value.data.error === 'string'
|
||||
? value.data.error
|
||||
: undefined) ?? 'Unknown chat stream error'
|
||||
status = 'failed'
|
||||
}
|
||||
await s.write(
|
||||
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
|
||||
)
|
||||
}
|
||||
await s.write(encoder.encode('data: [DONE]\n\n'))
|
||||
} catch (error) {
|
||||
if (c.req.raw.signal.aborted) {
|
||||
status = 'aborted'
|
||||
} else {
|
||||
status = 'failed'
|
||||
finalError =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
await getMonitoringService().finalizeSession({
|
||||
monitoringSessionId: monitoringContext.monitoringSessionId,
|
||||
agentId: id,
|
||||
sessionKey,
|
||||
status,
|
||||
finalAssistantMessage,
|
||||
error: finalError,
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
await getMonitoringService().finalizeSession({
|
||||
monitoringSessionId: monitoringContext.monitoringSessionId,
|
||||
agentId: id,
|
||||
sessionKey,
|
||||
status: c.req.raw.signal.aborted ? 'aborted' : 'failed',
|
||||
error: err instanceof Error ? err.message : String(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 +368,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { createHealthRoute } from './routes/health'
|
||||
import { createKlavisRoutes } from './routes/klavis'
|
||||
import { createMcpRoutes } from './routes/mcp'
|
||||
import { createMemoryRoutes } from './routes/memory'
|
||||
import { createMonitoringRoutes } from './routes/monitoring'
|
||||
import { createOAuthRoutes } from './routes/oauth'
|
||||
import { createOpenClawRoutes } from './routes/openclaw'
|
||||
import { createProviderRoutes } from './routes/provider'
|
||||
@@ -121,6 +122,10 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createAclRoutes({ policyService: aclPolicyService }))
|
||||
|
||||
const monitoringRoutes = new Hono<Env>()
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createMonitoringRoutes())
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use('/*', cors(defaultCorsConfig))
|
||||
.route('/health', createHealthRoute({ browser }))
|
||||
@@ -143,6 +148,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.route('/soul', createSoulRoutes())
|
||||
.route('/memory', createMemoryRoutes())
|
||||
.route('/skills', createSkillsRoutes())
|
||||
.route('/monitoring', monitoringRoutes)
|
||||
.route('/acl-rules', aclRoutes)
|
||||
.route('/test-provider', createProviderRoutes({ browserosId }))
|
||||
.route('/refine-prompt', createRefinePromptRoutes({ browserosId }))
|
||||
|
||||
@@ -20,6 +20,7 @@ import { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
|
||||
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
import type { ToolExecutionObserver } from '../../../monitoring/observer'
|
||||
import { klavisStrataCache } from './strata-cache'
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
||||
@@ -237,6 +238,7 @@ export function buildKlavisToolSet(handle: KlavisProxyHandle): ToolSet {
|
||||
export function registerKlavisTools(
|
||||
mcpServer: McpServer,
|
||||
handle: KlavisProxyHandle,
|
||||
observer?: ToolExecutionObserver,
|
||||
): void {
|
||||
mcpServer.registerTool(
|
||||
'connector_mcp_servers',
|
||||
@@ -247,9 +249,16 @@ export function registerKlavisTools(
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const startTime = performance.now()
|
||||
const toolCallId = crypto.randomUUID()
|
||||
const server_name = args.server_name as string
|
||||
|
||||
try {
|
||||
await observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: 'connector_mcp_servers',
|
||||
source: 'klavis-tool',
|
||||
args,
|
||||
})
|
||||
const klavisClient = new KlavisClient()
|
||||
const integrations = await klavisClient.getUserIntegrations(
|
||||
handle.browserosId,
|
||||
@@ -266,6 +275,14 @@ export function registerKlavisTools(
|
||||
success: true,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: {
|
||||
connected: true,
|
||||
server_name,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -294,6 +311,15 @@ export function registerKlavisTools(
|
||||
success: true,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: {
|
||||
connected: false,
|
||||
server_name,
|
||||
authUrl,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -320,6 +346,11 @@ export function registerKlavisTools(
|
||||
error_message: errorText,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
@@ -339,7 +370,14 @@ export function registerKlavisTools(
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const startTime = performance.now()
|
||||
const toolCallId = crypto.randomUUID()
|
||||
try {
|
||||
await observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: tool.name,
|
||||
source: 'klavis-tool',
|
||||
args,
|
||||
})
|
||||
const result = await handle.callTool(tool.name, args)
|
||||
|
||||
metrics.log('tool_executed', {
|
||||
@@ -349,6 +387,12 @@ export function registerKlavisTools(
|
||||
success: !result.isError,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: result,
|
||||
error: result.isError ? 'Tool returned isError=true' : undefined,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorText =
|
||||
@@ -362,6 +406,11 @@ export function registerKlavisTools(
|
||||
error_message: errorText,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AclRule } from '@browseros/shared/types/acl'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { Browser } from '../../../browser/browser'
|
||||
import type { ToolExecutionObserver } from '../../../monitoring/observer'
|
||||
import type { ToolRegistry } from '../../../tools/tool-registry'
|
||||
import {
|
||||
type KlavisProxyRef,
|
||||
@@ -24,6 +25,7 @@ export interface McpServiceDeps {
|
||||
resourcesDir: string
|
||||
aclRules?: AclRule[]
|
||||
klavisRef?: KlavisProxyRef
|
||||
observer?: ToolExecutionObserver
|
||||
}
|
||||
|
||||
export function createMcpServer(deps: McpServiceDeps): McpServer {
|
||||
@@ -48,11 +50,12 @@ export function createMcpServer(deps: McpServiceDeps): McpServer {
|
||||
resourcesDir: deps.resourcesDir,
|
||||
},
|
||||
aclRules: deps.aclRules,
|
||||
observer: deps.observer,
|
||||
})
|
||||
|
||||
// Register Klavis proxy tools (if connected via background init)
|
||||
if (deps.klavisRef?.handle) {
|
||||
registerKlavisTools(server, deps.klavisRef.handle)
|
||||
registerKlavisTools(server, deps.klavisRef.handle, deps.observer)
|
||||
}
|
||||
|
||||
return server
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
import type { ToolExecutionObserver } from '../../../monitoring/observer'
|
||||
import { executeTool, type ToolContext } from '../../../tools/framework'
|
||||
import type { ToolRegistry } from '../../../tools/tool-registry'
|
||||
|
||||
export function registerTools(
|
||||
mcpServer: McpServer,
|
||||
registry: ToolRegistry,
|
||||
ctx: ToolContext,
|
||||
ctx: ToolContext & { observer?: ToolExecutionObserver },
|
||||
): void {
|
||||
for (const tool of registry.all()) {
|
||||
const handler = async (
|
||||
@@ -15,9 +16,16 @@ export function registerTools(
|
||||
extra: { signal: AbortSignal },
|
||||
) => {
|
||||
const startTime = performance.now()
|
||||
const toolCallId = crypto.randomUUID()
|
||||
|
||||
try {
|
||||
logger.info(`${tool.name} request: ${JSON.stringify(args, null, ' ')}`)
|
||||
await ctx.observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: tool.name,
|
||||
source: 'browser-tool',
|
||||
args,
|
||||
})
|
||||
|
||||
const result = await executeTool(tool, args, ctx, extra.signal)
|
||||
|
||||
@@ -28,6 +36,12 @@ export function registerTools(
|
||||
source: 'mcp',
|
||||
})
|
||||
|
||||
await ctx.observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: result.structuredContent ?? result.content,
|
||||
error: result.isError ? 'Tool returned isError=true' : undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
@@ -44,6 +58,11 @@ export function registerTools(
|
||||
source: 'mcp',
|
||||
})
|
||||
|
||||
await ctx.observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
|
||||
@@ -3,21 +3,27 @@
|
||||
* 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,
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
} 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
|
||||
hostPort: number
|
||||
hostHome: string
|
||||
envFilePath: string
|
||||
gatewayToken?: string
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export class ContainerRuntime {
|
||||
constructor(
|
||||
@@ -41,64 +47,102 @@ 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 containerPort = String(OPENCLAW_GATEWAY_CONTAINER_PORT)
|
||||
const code = await this.runPodmanCommand(
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${input.hostPort}:${containerPort}`,
|
||||
...this.buildGatewayContainerRuntimeArgs(input),
|
||||
'--health-cmd',
|
||||
`curl -sf http://127.0.0.1:${containerPort}/healthz`,
|
||||
'--health-interval',
|
||||
'30s',
|
||||
'--health-timeout',
|
||||
'10s',
|
||||
'--health-retries',
|
||||
'3',
|
||||
input.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
containerPort,
|
||||
'--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
|
||||
}
|
||||
|
||||
async isHealthy(port: number): Promise<boolean> {
|
||||
async isHealthy(hostPort: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async isReady(port: number): Promise<boolean> {
|
||||
async isReady(hostPort: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
logger.info('Waiting for OpenClaw gateway readiness', { port, timeoutMs })
|
||||
async waitForReady(hostPort: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
logger.info('Waiting for OpenClaw gateway readiness', {
|
||||
hostPort,
|
||||
timeoutMs,
|
||||
})
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.isReady(port)) {
|
||||
if (await this.isReady(hostPort)) {
|
||||
logger.info('OpenClaw gateway became ready', {
|
||||
port,
|
||||
hostPort,
|
||||
waitMs: Date.now() - start,
|
||||
})
|
||||
return true
|
||||
@@ -106,22 +150,12 @@ export class ContainerRuntime {
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
logger.error('Timed out waiting for OpenClaw gateway readiness', {
|
||||
port,
|
||||
hostPort,
|
||||
timeoutMs,
|
||||
})
|
||||
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 +166,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 +187,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 +220,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 +238,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,264 @@
|
||||
/**
|
||||
* @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 OpenClawChatHistoryMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface OpenClawChatRequest {
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
message: string
|
||||
history?: OpenClawChatHistoryMessage[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export class OpenClawHttpChatClient {
|
||||
constructor(
|
||||
private readonly hostPort: 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.hostPort}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: resolveAgentModel(input.agentId),
|
||||
stream: true,
|
||||
messages: [
|
||||
...(input.history ?? []),
|
||||
{ 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,157 @@
|
||||
/**
|
||||
* @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]
|
||||
|
||||
export interface CustomOpenClawProviderConfig {
|
||||
providerId: string
|
||||
apiKeyEnvVar: string
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ResolvedOpenClawProviderConfig {
|
||||
envValues: Record<string, string>
|
||||
model?: string
|
||||
providerType?: SupportedOpenClawProvider
|
||||
customProvider?: CustomOpenClawProviderConfig
|
||||
}
|
||||
|
||||
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 deriveOpenClawProviderId(input: {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
}): string {
|
||||
const source =
|
||||
input.providerName?.trim() ||
|
||||
input.baseUrl?.trim() ||
|
||||
input.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 getOpenClawProviderEnvVar(
|
||||
providerType: SupportedOpenClawProvider,
|
||||
): string {
|
||||
return PROVIDER_ENV_VARS[providerType]
|
||||
}
|
||||
|
||||
export function resolveSupportedOpenClawProvider(input: {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}): ResolvedOpenClawProviderConfig {
|
||||
if (!input.providerType) {
|
||||
return { envValues: {} }
|
||||
}
|
||||
|
||||
if (isSupportedOpenClawProvider(input.providerType)) {
|
||||
const providerType = input.providerType
|
||||
const envVar = getOpenClawProviderEnvVar(providerType)
|
||||
return {
|
||||
envValues: input.apiKey ? { [envVar]: input.apiKey } : {},
|
||||
model: buildOpenClawModelRef(providerType, input.modelId),
|
||||
providerType,
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.baseUrl) {
|
||||
throw new UnsupportedOpenClawProviderError(input.providerType)
|
||||
}
|
||||
|
||||
const providerId = deriveOpenClawProviderId(input)
|
||||
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
|
||||
|
||||
return {
|
||||
envValues: input.apiKey ? { [apiKeyEnvVar]: input.apiKey } : {},
|
||||
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
|
||||
customProvider: {
|
||||
providerId,
|
||||
apiKeyEnvVar,
|
||||
config: {
|
||||
api: 'openai-completions',
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: `\${${apiKeyEnvVar}}`,
|
||||
...(input.modelId
|
||||
? {
|
||||
models: [{ id: input.modelId, name: input.modelId }],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
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`,
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,6 @@ export function resolveBundledPodmanPath(
|
||||
|
||||
export class PodmanRuntime {
|
||||
private podmanPath: string
|
||||
private machineReady = false
|
||||
|
||||
constructor(config?: { podmanPath?: string }) {
|
||||
this.podmanPath = config?.podmanPath ?? 'podman'
|
||||
@@ -99,9 +98,9 @@ export class PodmanRuntime {
|
||||
'machine',
|
||||
'init',
|
||||
'--cpus',
|
||||
'2',
|
||||
'8',
|
||||
'--memory',
|
||||
'2048',
|
||||
'8096',
|
||||
'--disk-size',
|
||||
'10',
|
||||
],
|
||||
@@ -138,12 +137,9 @@ export class PodmanRuntime {
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine stop failed with code ${code}`)
|
||||
this.machineReady = false
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
if (this.machineReady) return
|
||||
|
||||
const status = await this.getMachineStatus()
|
||||
|
||||
if (!status.initialized) {
|
||||
@@ -155,8 +151,6 @@ export class PodmanRuntime {
|
||||
onLog?.('Starting Podman machine...')
|
||||
await this.startMachine(onLog)
|
||||
}
|
||||
|
||||
this.machineReady = true
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
|
||||
@@ -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}
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Runtime state for the OpenClaw gateway. Today this is just the host port
|
||||
* we mapped the gateway container to, persisted so that a once-chosen port
|
||||
* is reused across restarts when it's still free.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import { getOpenClawStateDir } from './openclaw-env'
|
||||
|
||||
const RUNTIME_STATE_FILE = 'runtime-state.json'
|
||||
|
||||
interface RuntimeState {
|
||||
gatewayPort: number
|
||||
}
|
||||
|
||||
function getRuntimeStatePath(openclawDir: string): string {
|
||||
return join(getOpenClawStateDir(openclawDir), RUNTIME_STATE_FILE)
|
||||
}
|
||||
|
||||
export async function readPersistedGatewayPort(
|
||||
openclawDir: string,
|
||||
): Promise<number | null> {
|
||||
const path = getRuntimeStatePath(openclawDir)
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(path, 'utf-8'),
|
||||
) as Partial<RuntimeState>
|
||||
if (
|
||||
typeof parsed.gatewayPort === 'number' &&
|
||||
Number.isInteger(parsed.gatewayPort) &&
|
||||
parsed.gatewayPort > 0 &&
|
||||
parsed.gatewayPort <= 65535
|
||||
) {
|
||||
return parsed.gatewayPort
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writePersistedGatewayPort(
|
||||
openclawDir: string,
|
||||
port: number,
|
||||
): Promise<void> {
|
||||
await mkdir(getOpenClawStateDir(openclawDir), { recursive: true })
|
||||
const state: RuntimeState = { gatewayPort: port }
|
||||
await writeFile(
|
||||
getRuntimeStatePath(openclawDir),
|
||||
`${JSON.stringify(state, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer()
|
||||
server.once('error', () => resolve(false))
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true))
|
||||
})
|
||||
server.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
let port = startPort
|
||||
while (!(await isPortAvailable(port))) {
|
||||
port++
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a host port for the gateway container and persist it. Prefers the
|
||||
* previously persisted port when it's still bindable; otherwise scans
|
||||
* upward from OPENCLAW_GATEWAY_CONTAINER_PORT until a free port is found.
|
||||
*/
|
||||
export async function allocateGatewayPort(
|
||||
openclawDir: string,
|
||||
): Promise<number> {
|
||||
const persisted = await readPersistedGatewayPort(openclawDir)
|
||||
if (persisted !== null && (await isPortAvailable(persisted))) {
|
||||
return persisted
|
||||
}
|
||||
const port = await findAvailablePort(OPENCLAW_GATEWAY_CONTAINER_PORT)
|
||||
await writePersistedGatewayPort(openclawDir, port)
|
||||
return port
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -38,6 +49,18 @@ export function getOpenClawDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getLazyMonitoringDir(): string {
|
||||
return join(getBrowserosDir(), 'lazy-monitoring')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringRunsDir(): string {
|
||||
return join(getLazyMonitoringDir(), 'runs')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringRunDir(runId: string): string {
|
||||
return join(getLazyMonitoringRunsDir(), runId)
|
||||
}
|
||||
|
||||
export function getServerConfigPath(): string {
|
||||
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
|
||||
}
|
||||
@@ -57,10 +80,12 @@ 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 })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
}
|
||||
|
||||
export async function cleanOldSessions(): Promise<void> {
|
||||
|
||||
@@ -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,23 @@
|
||||
import type {
|
||||
JudgeAuditEnvelope,
|
||||
MonitoringFinalization,
|
||||
MonitoringSessionContext,
|
||||
MonitoringToolCallRecord,
|
||||
} from './types'
|
||||
|
||||
export function buildJudgeAuditEnvelope(input: {
|
||||
context: MonitoringSessionContext
|
||||
toolCalls: MonitoringToolCallRecord[]
|
||||
finalization: MonitoringFinalization | null
|
||||
}): JudgeAuditEnvelope {
|
||||
const envelope: JudgeAuditEnvelope = {
|
||||
run: input.context,
|
||||
toolCalls: input.toolCalls,
|
||||
}
|
||||
|
||||
if (input.finalization) {
|
||||
envelope.finalization = input.finalization
|
||||
}
|
||||
|
||||
return envelope
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { logger } from '../lib/logger'
|
||||
import type { MonitoringToolEndInput, MonitoringToolStartInput } from './types'
|
||||
|
||||
export interface ToolExecutionObserver {
|
||||
onToolStart(input: MonitoringToolStartInput): Promise<void>
|
||||
onToolEnd(input: MonitoringToolEndInput): Promise<void>
|
||||
}
|
||||
|
||||
export function swallowMonitoringError(
|
||||
operation: string,
|
||||
error: unknown,
|
||||
metadata: Record<string, unknown>,
|
||||
): void {
|
||||
logger.warn(`Lazy monitoring ${operation} failed`, {
|
||||
...metadata,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
222
packages/browseros-agent/apps/server/src/monitoring/service.ts
Normal file
222
packages/browseros-agent/apps/server/src/monitoring/service.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { buildJudgeAuditEnvelope } from './envelope'
|
||||
import { swallowMonitoringError, type ToolExecutionObserver } from './observer'
|
||||
import { MonitoringSessionRegistry } from './session-registry'
|
||||
import { MonitoringStorage } from './storage'
|
||||
import type {
|
||||
JudgeAuditEnvelope,
|
||||
MonitoringFinalization,
|
||||
MonitoringFinalizeInput,
|
||||
MonitoringRunSummary,
|
||||
MonitoringSessionContext,
|
||||
MonitoringSessionStartInput,
|
||||
MonitoringToolCallRecord,
|
||||
MonitoringToolEndInput,
|
||||
MonitoringToolStartInput,
|
||||
} from './types'
|
||||
|
||||
type ActiveToolCallState = Omit<
|
||||
MonitoringToolCallRecord,
|
||||
'finishedAt' | 'durationMs' | 'error' | 'output'
|
||||
>
|
||||
|
||||
export class MonitoringService {
|
||||
private readonly storage = new MonitoringStorage()
|
||||
private readonly registry = new MonitoringSessionRegistry()
|
||||
|
||||
async startSession(
|
||||
input: MonitoringSessionStartInput,
|
||||
): Promise<MonitoringSessionContext> {
|
||||
const context: MonitoringSessionContext = {
|
||||
monitoringSessionId: crypto.randomUUID(),
|
||||
agentId: input.agentId,
|
||||
sessionKey: input.sessionKey,
|
||||
originalPrompt: input.originalPrompt,
|
||||
chatHistory: input.chatHistory,
|
||||
startedAt: new Date().toISOString(),
|
||||
source: input.source ?? 'openclaw-agent-chat',
|
||||
}
|
||||
|
||||
await this.storage.writeContext(context)
|
||||
this.registry.setActive(context.agentId, context.monitoringSessionId)
|
||||
return context
|
||||
}
|
||||
|
||||
getActiveSessionId(agentId: string): string | undefined {
|
||||
return this.registry.getActive(agentId)
|
||||
}
|
||||
|
||||
getSingleActiveSession():
|
||||
| { agentId: string; monitoringSessionId: string }
|
||||
| undefined {
|
||||
return this.registry.getSingleActive()
|
||||
}
|
||||
clearActiveSession(agentId: string, monitoringSessionId: string): void {
|
||||
this.registry.clearIfMatches(agentId, monitoringSessionId)
|
||||
}
|
||||
|
||||
createObserver(
|
||||
monitoringSessionId: string,
|
||||
agentId: string,
|
||||
): ToolExecutionObserver {
|
||||
const activeToolCalls = new Map<string, ActiveToolCallState>()
|
||||
|
||||
return {
|
||||
onToolStart: async (input: MonitoringToolStartInput) => {
|
||||
try {
|
||||
activeToolCalls.set(input.toolCallId, {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
source: input.source,
|
||||
args: input.args,
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
swallowMonitoringError('tool start recording', error, {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onToolEnd: async (input: MonitoringToolEndInput) => {
|
||||
try {
|
||||
const active = activeToolCalls.get(input.toolCallId)
|
||||
if (!active) return
|
||||
|
||||
const finishedAt = new Date().toISOString()
|
||||
const durationMs = Math.max(
|
||||
0,
|
||||
new Date(finishedAt).getTime() -
|
||||
new Date(active.startedAt).getTime(),
|
||||
)
|
||||
|
||||
const record: MonitoringToolCallRecord = {
|
||||
...active,
|
||||
finishedAt,
|
||||
durationMs,
|
||||
}
|
||||
|
||||
if (input.error) {
|
||||
record.error = input.error
|
||||
}
|
||||
if (input.output !== undefined) {
|
||||
record.output = input.output
|
||||
}
|
||||
|
||||
await this.storage.appendToolCall(record)
|
||||
activeToolCalls.delete(input.toolCallId)
|
||||
} catch (error) {
|
||||
swallowMonitoringError('tool end recording', error, {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: input.toolCallId,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeSession(
|
||||
input: MonitoringFinalizeInput,
|
||||
): Promise<JudgeAuditEnvelope | null> {
|
||||
const context = await this.storage.readContext(input.monitoringSessionId)
|
||||
if (!context) {
|
||||
return null
|
||||
}
|
||||
|
||||
const finalization: MonitoringFinalization = {
|
||||
monitoringSessionId: input.monitoringSessionId,
|
||||
agentId: input.agentId,
|
||||
sessionKey: input.sessionKey,
|
||||
status: input.status,
|
||||
finalizedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (input.finalAssistantMessage) {
|
||||
finalization.finalAssistantMessage = input.finalAssistantMessage
|
||||
}
|
||||
if (input.error) {
|
||||
finalization.error = input.error
|
||||
}
|
||||
|
||||
await this.storage.writeFinalization(finalization)
|
||||
this.registry.clearIfMatches(input.agentId, input.monitoringSessionId)
|
||||
return this.buildAndPersistEnvelope(input.monitoringSessionId)
|
||||
}
|
||||
|
||||
async getRunEnvelope(runId: string): Promise<JudgeAuditEnvelope | null> {
|
||||
const context = await this.storage.readContext(runId)
|
||||
if (!context) return null
|
||||
|
||||
const toolCalls = await this.storage.readToolCalls(runId)
|
||||
const finalization = await this.storage.readFinalization(runId)
|
||||
|
||||
return buildJudgeAuditEnvelope({
|
||||
context,
|
||||
toolCalls,
|
||||
finalization,
|
||||
})
|
||||
}
|
||||
|
||||
async listRuns(limit = 50): Promise<MonitoringRunSummary[]> {
|
||||
const runIds = (await this.storage.listRunIds()).slice(0, limit)
|
||||
const summaries = await Promise.all(
|
||||
runIds.map(async (runId) => {
|
||||
const context = await this.storage.readContext(runId)
|
||||
if (!context) return null
|
||||
|
||||
const [toolCalls, finalization] = await Promise.all([
|
||||
this.storage.readToolCalls(runId),
|
||||
this.storage.readFinalization(runId),
|
||||
])
|
||||
|
||||
const summary: MonitoringRunSummary = {
|
||||
monitoringSessionId: context.monitoringSessionId,
|
||||
agentId: context.agentId,
|
||||
sessionKey: context.sessionKey,
|
||||
originalPrompt: context.originalPrompt,
|
||||
startedAt: context.startedAt,
|
||||
source: context.source,
|
||||
toolCallCount: toolCalls.length,
|
||||
}
|
||||
|
||||
if (finalization) {
|
||||
summary.finalization = {
|
||||
status: finalization.status,
|
||||
finalizedAt: finalization.finalizedAt,
|
||||
error: finalization.error,
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}),
|
||||
)
|
||||
|
||||
return summaries.filter((summary): summary is MonitoringRunSummary =>
|
||||
Boolean(summary),
|
||||
)
|
||||
}
|
||||
|
||||
private async buildAndPersistEnvelope(
|
||||
runId: string,
|
||||
): Promise<JudgeAuditEnvelope | null> {
|
||||
const envelope = await this.getRunEnvelope(runId)
|
||||
if (!envelope) return null
|
||||
|
||||
await this.storage.writeAuditEnvelope(runId, envelope)
|
||||
return envelope
|
||||
}
|
||||
}
|
||||
|
||||
let monitoringService: MonitoringService | null = null
|
||||
|
||||
export function getMonitoringService(): MonitoringService {
|
||||
if (!monitoringService) {
|
||||
monitoringService = new MonitoringService()
|
||||
}
|
||||
return monitoringService
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export class MonitoringSessionRegistry {
|
||||
private readonly activeSessionsByAgent = new Map<string, string>()
|
||||
|
||||
setActive(agentId: string, monitoringSessionId: string): void {
|
||||
this.activeSessionsByAgent.set(agentId, monitoringSessionId)
|
||||
}
|
||||
|
||||
getActive(agentId: string): string | undefined {
|
||||
return this.activeSessionsByAgent.get(agentId)
|
||||
}
|
||||
|
||||
getSingleActive():
|
||||
| { agentId: string; monitoringSessionId: string }
|
||||
| undefined {
|
||||
if (this.activeSessionsByAgent.size !== 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [agentId, monitoringSessionId] =
|
||||
this.activeSessionsByAgent.entries().next().value ?? []
|
||||
|
||||
if (!agentId || !monitoringSessionId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { agentId, monitoringSessionId }
|
||||
}
|
||||
clearIfMatches(agentId: string, monitoringSessionId: string): void {
|
||||
if (this.activeSessionsByAgent.get(agentId) !== monitoringSessionId) {
|
||||
return
|
||||
}
|
||||
this.activeSessionsByAgent.delete(agentId)
|
||||
}
|
||||
}
|
||||
175
packages/browseros-agent/apps/server/src/monitoring/storage.ts
Normal file
175
packages/browseros-agent/apps/server/src/monitoring/storage.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
appendFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
getLazyMonitoringRunDir,
|
||||
getLazyMonitoringRunsDir,
|
||||
} from '../lib/browseros-dir'
|
||||
import type {
|
||||
MonitoringFinalization,
|
||||
MonitoringSessionContext,
|
||||
MonitoringToolCallRecord,
|
||||
} from './types'
|
||||
|
||||
const CONTEXT_FILE_NAME = 'context.json'
|
||||
const TOOL_CALLS_FILE_NAME = 'tool-calls.jsonl'
|
||||
const FINALIZATION_FILE_NAME = 'finalization.json'
|
||||
const AUDIT_ENVELOPE_FILE_NAME = 'audit-envelope.json'
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
export class InvalidMonitoringRunIdError extends Error {
|
||||
constructor(runId: string) {
|
||||
super(`Invalid monitoring run id: ${runId}`)
|
||||
this.name = 'InvalidMonitoringRunIdError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidMonitoringRunId(runId: string): boolean {
|
||||
return UUID_PATTERN.test(runId)
|
||||
}
|
||||
|
||||
function assertValidMonitoringRunId(runId: string): void {
|
||||
if (!isValidMonitoringRunId(runId)) {
|
||||
throw new InvalidMonitoringRunIdError(runId)
|
||||
}
|
||||
}
|
||||
|
||||
export class MonitoringStorage {
|
||||
async writeContext(context: MonitoringSessionContext): Promise<void> {
|
||||
await this.ensureRunDir(context.monitoringSessionId)
|
||||
await writeFile(
|
||||
this.getContextPath(context.monitoringSessionId),
|
||||
`${JSON.stringify(context, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async appendToolCall(record: MonitoringToolCallRecord): Promise<void> {
|
||||
await this.ensureRunDir(record.monitoringSessionId)
|
||||
await appendFile(
|
||||
this.getToolCallsPath(record.monitoringSessionId),
|
||||
`${JSON.stringify(record)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async writeFinalization(finalization: MonitoringFinalization): Promise<void> {
|
||||
await this.ensureRunDir(finalization.monitoringSessionId)
|
||||
await writeFile(
|
||||
this.getFinalizationPath(finalization.monitoringSessionId),
|
||||
`${JSON.stringify(finalization, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async writeAuditEnvelope(runId: string, envelope: unknown): Promise<void> {
|
||||
await this.ensureRunDir(runId)
|
||||
await writeFile(
|
||||
this.getAuditEnvelopePath(runId),
|
||||
`${JSON.stringify(envelope, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async readContext(runId: string): Promise<MonitoringSessionContext | null> {
|
||||
return this.readJsonFile<MonitoringSessionContext>(
|
||||
this.getContextPath(runId),
|
||||
)
|
||||
}
|
||||
|
||||
async readFinalization(
|
||||
runId: string,
|
||||
): Promise<MonitoringFinalization | null> {
|
||||
return this.readJsonFile<MonitoringFinalization>(
|
||||
this.getFinalizationPath(runId),
|
||||
)
|
||||
}
|
||||
|
||||
async readToolCalls(runId: string): Promise<MonitoringToolCallRecord[]> {
|
||||
try {
|
||||
const content = await readFile(this.getToolCallsPath(runId), 'utf8')
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as MonitoringToolCallRecord]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async listRunIds(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(getLazyMonitoringRunsDir(), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
const directories = entries.filter(
|
||||
(entry) => entry.isDirectory() && isValidMonitoringRunId(entry.name),
|
||||
)
|
||||
const runStats = await Promise.all(
|
||||
directories.map(async (entry) => ({
|
||||
runId: entry.name,
|
||||
mtimeMs: await this.getDirectoryMtimeMs(entry.name),
|
||||
})),
|
||||
)
|
||||
return runStats
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
.map((entry) => entry.runId)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRunDir(runId: string): Promise<void> {
|
||||
assertValidMonitoringRunId(runId)
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunDir(runId), { recursive: true })
|
||||
}
|
||||
|
||||
private async getDirectoryMtimeMs(runId: string): Promise<number> {
|
||||
try {
|
||||
const info = await stat(getLazyMonitoringRunDir(runId))
|
||||
return info.mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private async readJsonFile<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const content = await readFile(path, 'utf8')
|
||||
return JSON.parse(content) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private getContextPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), CONTEXT_FILE_NAME)
|
||||
}
|
||||
|
||||
private getToolCallsPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), TOOL_CALLS_FILE_NAME)
|
||||
}
|
||||
|
||||
private getFinalizationPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), FINALIZATION_FILE_NAME)
|
||||
}
|
||||
|
||||
private getAuditEnvelopePath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), AUDIT_ENVELOPE_FILE_NAME)
|
||||
}
|
||||
}
|
||||
92
packages/browseros-agent/apps/server/src/monitoring/types.ts
Normal file
92
packages/browseros-agent/apps/server/src/monitoring/types.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export type MonitoringChatTurnRole = 'user' | 'assistant'
|
||||
|
||||
export interface MonitoringChatTurn {
|
||||
role: MonitoringChatTurnRole
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface MonitoringSessionContext {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
originalPrompt: string
|
||||
chatHistory: MonitoringChatTurn[]
|
||||
startedAt: string
|
||||
source: 'openclaw-agent-chat' | 'debug'
|
||||
}
|
||||
|
||||
export type MonitoringToolCallSource = 'browser-tool' | 'klavis-tool'
|
||||
|
||||
export interface MonitoringToolCallRecord {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
source: MonitoringToolCallSource
|
||||
args: unknown
|
||||
output?: unknown
|
||||
error?: string
|
||||
startedAt: string
|
||||
finishedAt?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export interface MonitoringFinalization {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
status: 'completed' | 'failed' | 'aborted' | 'incomplete'
|
||||
finalAssistantMessage?: string
|
||||
error?: string
|
||||
finalizedAt: string
|
||||
}
|
||||
|
||||
export interface JudgeAuditEnvelope {
|
||||
run: MonitoringSessionContext
|
||||
toolCalls: MonitoringToolCallRecord[]
|
||||
finalization?: MonitoringFinalization
|
||||
}
|
||||
|
||||
export interface MonitoringRunSummary {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
originalPrompt: string
|
||||
startedAt: string
|
||||
source: MonitoringSessionContext['source']
|
||||
toolCallCount: number
|
||||
finalization?: Pick<
|
||||
MonitoringFinalization,
|
||||
'status' | 'finalizedAt' | 'error'
|
||||
>
|
||||
}
|
||||
|
||||
export interface MonitoringSessionStartInput {
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
originalPrompt: string
|
||||
chatHistory: MonitoringChatTurn[]
|
||||
source?: MonitoringSessionContext['source']
|
||||
}
|
||||
|
||||
export interface MonitoringToolStartInput {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
source: MonitoringToolCallSource
|
||||
args: unknown
|
||||
}
|
||||
|
||||
export interface MonitoringToolEndInput {
|
||||
toolCallId: string
|
||||
output?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MonitoringFinalizeInput {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
status: MonitoringFinalization['status']
|
||||
finalAssistantMessage?: string
|
||||
error?: string
|
||||
}
|
||||
@@ -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,437 @@
|
||||
/**
|
||||
* @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, session headers, and defaults chat history 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('passes prior chat history through to the OpenClaw chat stream', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const chatStream = mock(
|
||||
async () =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'done',
|
||||
data: { text: 'Done' },
|
||||
})
|
||||
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 history = [
|
||||
{ role: 'user' as const, content: 'Find my open tasks' },
|
||||
{ role: 'assistant' as const, content: 'I am checking Linear now.' },
|
||||
]
|
||||
|
||||
const response = await route.request('/agents/research/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'Summarize what is blocked',
|
||||
sessionKey: 'session-456',
|
||||
history,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(chatStream).toHaveBeenCalledWith(
|
||||
'research',
|
||||
'session-456',
|
||||
'Summarize what is blocked',
|
||||
history,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects concurrent monitored chat requests for the same agent', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const actualMonitoringService = await import(
|
||||
'../../../src/monitoring/service'
|
||||
)
|
||||
const chatStream = mock(async () => new ReadableStream())
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
chatStream,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
mock.module('../../../src/monitoring/service', () => ({
|
||||
...actualMonitoringService,
|
||||
getMonitoringService: () =>
|
||||
({
|
||||
getActiveSessionId: (agentId: string) =>
|
||||
agentId === 'research' ? 'existing-run' : undefined,
|
||||
}) 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-789',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(chatStream).not.toHaveBeenCalled()
|
||||
expect(await response.json()).toEqual({
|
||||
error:
|
||||
'A monitored chat session is already active for this agent. Wait for it to finish before starting another.',
|
||||
})
|
||||
})
|
||||
|
||||
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',
|
||||
hostPort: 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.hostPort}: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,244 @@
|
||||
/**
|
||||
* @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',
|
||||
history: [{ role: 'assistant', content: 'Earlier reply' }],
|
||||
})
|
||||
|
||||
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: 'assistant', content: 'Earlier reply' },
|
||||
{ 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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -11,9 +11,43 @@ import path from 'node:path'
|
||||
import {
|
||||
configurePodmanRuntime,
|
||||
getPodmanRuntime,
|
||||
PodmanRuntime,
|
||||
resolveBundledPodmanPath,
|
||||
} from '../../../../src/api/services/openclaw/podman-runtime'
|
||||
|
||||
class FakePodmanRuntime extends PodmanRuntime {
|
||||
machineStatuses: Array<{ initialized: boolean; running: boolean }>
|
||||
initCalls = 0
|
||||
startCalls = 0
|
||||
statusCalls = 0
|
||||
|
||||
constructor(statuses: Array<{ initialized: boolean; running: boolean }>) {
|
||||
super({ podmanPath: 'podman' })
|
||||
this.machineStatuses = [...statuses]
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
this.statusCalls += 1
|
||||
return (
|
||||
this.machineStatuses.shift() ?? {
|
||||
initialized: true,
|
||||
running: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async initMachine(): Promise<void> {
|
||||
this.initCalls += 1
|
||||
}
|
||||
|
||||
async startMachine(): Promise<void> {
|
||||
this.startCalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('podman runtime', () => {
|
||||
let tempDir: string
|
||||
|
||||
@@ -80,4 +114,56 @@ describe('podman runtime', () => {
|
||||
|
||||
expect(runtime.getPodmanPath()).toBe('podman')
|
||||
})
|
||||
|
||||
it('ensureReady re-checks machine status on every call', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: true },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(3)
|
||||
expect(runtime.initCalls).toBe(0)
|
||||
expect(runtime.startCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('ensureReady initializes when machine is not present', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: false, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(1)
|
||||
expect(runtime.initCalls).toBe(1)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('ensureReady starts when machine is initialized but stopped', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.initCalls).toBe(0)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('ensureReady detects an externally stopped machine on the next call', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(2)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { appendFile, mkdir, rm } from 'node:fs/promises'
|
||||
import {
|
||||
getLazyMonitoringRunDir,
|
||||
getLazyMonitoringRunsDir,
|
||||
} from '../src/lib/browseros-dir'
|
||||
import {
|
||||
InvalidMonitoringRunIdError,
|
||||
isValidMonitoringRunId,
|
||||
MonitoringStorage,
|
||||
} from '../src/monitoring/storage'
|
||||
|
||||
const createdRunDirs = new Set<string>()
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...createdRunDirs].map(async (runId) => {
|
||||
await rm(getLazyMonitoringRunDir(runId), { recursive: true, force: true })
|
||||
}),
|
||||
)
|
||||
createdRunDirs.clear()
|
||||
})
|
||||
|
||||
describe('MonitoringStorage run id validation', () => {
|
||||
it('accepts UUID monitoring run ids', () => {
|
||||
expect(isValidMonitoringRunId('123e4567-e89b-12d3-a456-426614174000')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects path traversal run ids', async () => {
|
||||
expect(isValidMonitoringRunId('../../secret')).toBe(false)
|
||||
|
||||
const storage = new MonitoringStorage()
|
||||
await expect(storage.readContext('../../secret')).rejects.toBeInstanceOf(
|
||||
InvalidMonitoringRunIdError,
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves valid JSONL records when one line is malformed', async () => {
|
||||
const runId = '123e4567-e89b-12d3-a456-426614174001'
|
||||
createdRunDirs.add(runId)
|
||||
|
||||
const storage = new MonitoringStorage()
|
||||
await storage.writeContext({
|
||||
monitoringSessionId: runId,
|
||||
agentId: 'test-agent',
|
||||
sessionKey: 'session-1',
|
||||
originalPrompt: 'Inspect browser state safely',
|
||||
chatHistory: [{ role: 'user', content: 'Inspect browser state safely' }],
|
||||
startedAt: new Date().toISOString(),
|
||||
source: 'debug',
|
||||
})
|
||||
|
||||
await appendFile(
|
||||
`${getLazyMonitoringRunDir(runId)}/tool-calls.jsonl`,
|
||||
[
|
||||
JSON.stringify({
|
||||
monitoringSessionId: runId,
|
||||
agentId: 'test-agent',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'list_windows',
|
||||
source: 'browser-tool',
|
||||
args: {},
|
||||
startedAt: '2026-04-20T15:22:49.817Z',
|
||||
finishedAt: '2026-04-20T15:22:49.818Z',
|
||||
durationMs: 1,
|
||||
}),
|
||||
'{"broken":',
|
||||
JSON.stringify({
|
||||
monitoringSessionId: runId,
|
||||
agentId: 'test-agent',
|
||||
toolCallId: 'tool-2',
|
||||
toolName: 'take_snapshot',
|
||||
source: 'browser-tool',
|
||||
args: {},
|
||||
startedAt: '2026-04-20T15:22:50.817Z',
|
||||
finishedAt: '2026-04-20T15:22:50.818Z',
|
||||
durationMs: 1,
|
||||
}),
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const toolCalls = await storage.readToolCalls(runId)
|
||||
expect(toolCalls).toHaveLength(2)
|
||||
expect(toolCalls.map((record) => record.toolCallId)).toEqual([
|
||||
'tool-1',
|
||||
'tool-2',
|
||||
])
|
||||
})
|
||||
|
||||
it('skips non-uuid directories when listing run ids', async () => {
|
||||
const validRunId = '123e4567-e89b-12d3-a456-426614174002'
|
||||
createdRunDirs.add(validRunId)
|
||||
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunDir(validRunId), { recursive: true })
|
||||
await mkdir(`${getLazyMonitoringRunsDir()}/not-a-uuid`, {
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
const storage = new MonitoringStorage()
|
||||
const runIds = await storage.listRunIds()
|
||||
|
||||
expect(runIds).toContain(validRunId)
|
||||
expect(runIds).not.toContain('not-a-uuid')
|
||||
|
||||
await rm(`${getLazyMonitoringRunsDir()}/not-a-uuid`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
},
|
||||
@@ -216,6 +216,16 @@
|
||||
"chrome-devtools-mcp": "latest",
|
||||
},
|
||||
},
|
||||
"packages/agent-containers": {
|
||||
"name": "@browseros/agent-containers",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.933.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.5",
|
||||
},
|
||||
},
|
||||
"packages/agent-sdk": {
|
||||
"name": "@browseros-ai/agent-sdk",
|
||||
"version": "0.0.7",
|
||||
@@ -463,6 +473,8 @@
|
||||
|
||||
"@browseros/agent": ["@browseros/agent@workspace:apps/agent"],
|
||||
|
||||
"@browseros/agent-containers": ["@browseros/agent-containers@workspace:packages/agent-containers"],
|
||||
|
||||
"@browseros/cdp-protocol": ["@browseros/cdp-protocol@workspace:packages/cdp-protocol"],
|
||||
|
||||
"@browseros/eval": ["@browseros/eval@workspace:apps/eval"],
|
||||
@@ -4489,6 +4501,10 @@
|
||||
|
||||
"@browseros/agent/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
|
||||
|
||||
"@browseros/agent-containers/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
|
||||
|
||||
"@browseros/eval/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
@@ -5211,6 +5227,102 @@
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.11", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
|
||||
|
||||
"@browseros/agent-containers/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"@browseros/agent/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
|
||||
@@ -5597,6 +5709,70 @@
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums/@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/middleware-endpoint/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/middleware-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/util-waiter/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
@@ -5705,6 +5881,22 @@
|
||||
|
||||
"wxt/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
|
||||
@@ -5733,6 +5925,8 @@
|
||||
|
||||
"publish-browser-extension/listr2/cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"@browseros/agent-containers/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
|
||||
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "bun run build:agent-sdk",
|
||||
"build:agent-tarballs": "bun run --filter @browseros/agent-containers release",
|
||||
"dev:watch": "./tools/dev/run.sh watch",
|
||||
"dev:watch:new": "./tools/dev/run.sh watch --new",
|
||||
"dev:manual": "./tools/dev/run.sh watch --manual",
|
||||
@@ -27,10 +28,18 @@
|
||||
"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:agent-containers && 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:agent-containers": "bun run --filter @browseros/agent-containers test",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"agentId": "openclaw",
|
||||
"image": "ghcr.io/openclaw/openclaw",
|
||||
"version": "2026.4.12",
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@browseros/agent-containers",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun src/cli.ts --no-upload",
|
||||
"release": "bun src/cli.ts --upload",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.933.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.5"
|
||||
},
|
||||
"exports": {
|
||||
"./build": {
|
||||
"types": "./src/build.ts",
|
||||
"default": "./src/build.ts"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./src/cli.ts",
|
||||
"default": "./src/cli.ts"
|
||||
},
|
||||
"./config": {
|
||||
"types": "./src/config.ts",
|
||||
"default": "./src/config.ts"
|
||||
},
|
||||
"./download": {
|
||||
"types": "./src/download.ts",
|
||||
"default": "./src/download.ts"
|
||||
},
|
||||
"./manifest": {
|
||||
"types": "./src/manifest.ts",
|
||||
"default": "./src/manifest.ts"
|
||||
},
|
||||
"./r2": {
|
||||
"types": "./src/r2.ts",
|
||||
"default": "./src/r2.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
},
|
||||
"./upload": {
|
||||
"types": "./src/upload.ts",
|
||||
"default": "./src/upload.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { buildAgentTarball } from './build'
|
||||
import type { AgentTarballConfigEntry } from './types'
|
||||
|
||||
const OPENCLAW_ENTRY: AgentTarballConfigEntry = {
|
||||
agentId: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
platforms: ['linux/amd64', 'linux/arm64'],
|
||||
}
|
||||
|
||||
describe('buildAgentTarball', () => {
|
||||
it('runs podman pull and save, then emits a gzipped archive', async () => {
|
||||
const calls: string[][] = []
|
||||
const outputDir = await mkdtemp(join(tmpdir(), 'agent-container-build-'))
|
||||
|
||||
const artifact = await buildAgentTarball(
|
||||
OPENCLAW_ENTRY,
|
||||
'linux/amd64',
|
||||
outputDir,
|
||||
{
|
||||
commandRunner: async (command) => {
|
||||
calls.push(command)
|
||||
const outputIndex = command.indexOf('-o')
|
||||
if (outputIndex >= 0) {
|
||||
await writeFile(command[outputIndex + 1], 'oci archive')
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
'podman',
|
||||
'pull',
|
||||
'--platform',
|
||||
'linux/amd64',
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
],
|
||||
[
|
||||
'podman',
|
||||
'save',
|
||||
'--format=oci-archive',
|
||||
'-o',
|
||||
expect.stringContaining('openclaw-2026.4.12-linux-amd64.tar'),
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
],
|
||||
])
|
||||
expect(artifact.filename).toBe('openclaw-2026.4.12-linux-amd64.tar.gz')
|
||||
expect(existsSync(artifact.tarGzPath)).toBe(true)
|
||||
expect(artifact.sha256).toHaveLength(64)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { createGzip } from 'node:zlib'
|
||||
import { sha256File } from './checksum'
|
||||
import { archiveFilename, imageRef, platformId } from './naming'
|
||||
import { type CommandRunner, runCommand } from './process'
|
||||
import type {
|
||||
AgentTarballConfigEntry,
|
||||
AgentTarballPlatform,
|
||||
BuiltAgentTarball,
|
||||
} from './types'
|
||||
|
||||
export interface BuildAgentTarballOptions {
|
||||
commandRunner?: CommandRunner
|
||||
}
|
||||
|
||||
function outputDirFor(
|
||||
baseOutputDir: string,
|
||||
entry: AgentTarballConfigEntry,
|
||||
platform: AgentTarballPlatform,
|
||||
): string {
|
||||
return join(baseOutputDir, entry.agentId, entry.version, platformId(platform))
|
||||
}
|
||||
|
||||
async function gzipFile(
|
||||
sourcePath: string,
|
||||
destinationPath: string,
|
||||
): Promise<void> {
|
||||
await pipeline(
|
||||
createReadStream(sourcePath),
|
||||
createGzip(),
|
||||
createWriteStream(destinationPath),
|
||||
)
|
||||
}
|
||||
|
||||
export async function buildAgentTarball(
|
||||
entry: AgentTarballConfigEntry,
|
||||
platform: AgentTarballPlatform,
|
||||
baseOutputDir: string,
|
||||
options: BuildAgentTarballOptions = {},
|
||||
): Promise<BuiltAgentTarball> {
|
||||
const runner = options.commandRunner ?? runCommand
|
||||
const artifactDir = outputDirFor(baseOutputDir, entry, platform)
|
||||
const filename = archiveFilename(entry.agentId, entry.version, platform)
|
||||
const tarPath = join(artifactDir, filename.replace(/\.gz$/, ''))
|
||||
const tarGzPath = join(artifactDir, filename)
|
||||
const ref = imageRef(entry.image, entry.version)
|
||||
|
||||
await mkdir(artifactDir, { recursive: true })
|
||||
await runner(['podman', 'pull', '--platform', platform, ref])
|
||||
await runner(['podman', 'save', '--format=oci-archive', '-o', tarPath, ref])
|
||||
await gzipFile(tarPath, tarGzPath)
|
||||
await rm(tarPath, { force: true })
|
||||
|
||||
return {
|
||||
agentId: entry.agentId,
|
||||
version: entry.version,
|
||||
platform,
|
||||
imageRef: ref,
|
||||
tarGzPath,
|
||||
filename,
|
||||
sha256: await sha256File(tarGzPath),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
|
||||
export async function sha256File(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256')
|
||||
const stream = createReadStream(filePath)
|
||||
stream.on('data', (chunk) => hash.update(chunk))
|
||||
stream.on('error', reject)
|
||||
stream.on('end', () => resolve(hash.digest('hex')))
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyFileSha256(
|
||||
filePath: string,
|
||||
expectedSha256: string,
|
||||
): Promise<boolean> {
|
||||
return (await sha256File(filePath)) === expectedSha256.toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { buildAgentTarball } from './build'
|
||||
import { filterAgents, loadAgentTarballConfig } from './config'
|
||||
import { getDefaultConfigPath, getDefaultOutputDir } from './paths'
|
||||
import { loadR2PublishConfig } from './r2'
|
||||
import type { AgentTarballConfigEntry, BuiltAgentTarball } from './types'
|
||||
import { publishAgentTarballs } from './upload'
|
||||
|
||||
interface CliOptions {
|
||||
agentId?: string
|
||||
configPath: string
|
||||
outputDir: string
|
||||
upload: boolean
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
let agentId: string | undefined
|
||||
let configPath = getDefaultConfigPath()
|
||||
let outputDir = getDefaultOutputDir()
|
||||
let upload = false
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
if (arg === '--upload') upload = true
|
||||
else if (arg === '--no-upload') upload = false
|
||||
else if (arg === '--agent') agentId = argv[++i]
|
||||
else if (arg === '--config') configPath = argv[++i]
|
||||
else if (arg === '--output-dir') outputDir = argv[++i]
|
||||
else throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
|
||||
return { agentId, configPath, outputDir, upload }
|
||||
}
|
||||
|
||||
async function buildArtifacts(
|
||||
agents: AgentTarballConfigEntry[],
|
||||
outputDir: string,
|
||||
): Promise<BuiltAgentTarball[]> {
|
||||
const artifacts: BuiltAgentTarball[] = []
|
||||
|
||||
for (const agent of agents) {
|
||||
for (const platform of agent.platforms) {
|
||||
console.info(`Building ${agent.agentId} ${agent.version} for ${platform}`)
|
||||
artifacts.push(await buildAgentTarball(agent, platform, outputDir))
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts
|
||||
}
|
||||
|
||||
export async function runAgentTarballRelease(argv: string[]): Promise<void> {
|
||||
const options = parseArgs(argv)
|
||||
const config = await loadAgentTarballConfig(options.configPath)
|
||||
const agents = filterAgents(config, options.agentId)
|
||||
|
||||
if (agents.length === 0) {
|
||||
throw new Error(`No agents matched ${options.agentId}`)
|
||||
}
|
||||
|
||||
await mkdir(options.outputDir, { recursive: true })
|
||||
const artifacts = await buildArtifacts(agents, options.outputDir)
|
||||
|
||||
if (!options.upload) {
|
||||
console.info(
|
||||
`Built ${artifacts.length} tarball artifact(s) in ${options.outputDir}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const manifestPath = join(options.outputDir, 'manifest.json')
|
||||
const publishConfig = loadR2PublishConfig()
|
||||
const result = await publishAgentTarballs(artifacts, publishConfig, {
|
||||
manifestPath,
|
||||
})
|
||||
|
||||
console.info(
|
||||
`Published ${result.publishedArtifacts.length} tarball artifact(s)`,
|
||||
)
|
||||
console.info(`Manifest written to ${result.manifestPath}`)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
runAgentTarballRelease(process.argv.slice(2)).catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { loadAgentTarballConfig } from './config'
|
||||
import { getDefaultConfigPath } from './paths'
|
||||
|
||||
describe('loadAgentTarballConfig', () => {
|
||||
it('loads the bundled OpenClaw config', async () => {
|
||||
const config = await loadAgentTarballConfig(getDefaultConfigPath())
|
||||
|
||||
expect(config.agents).toEqual([
|
||||
{
|
||||
agentId: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
platforms: ['linux/amd64', 'linux/arm64'],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects duplicate agent/version/platform tuples', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-container-config-'))
|
||||
const path = join(dir, 'config.json')
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
agents: [
|
||||
{
|
||||
agentId: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
platforms: ['linux/amd64', 'linux/amd64'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(loadAgentTarballConfig(path)).rejects.toThrow(
|
||||
'Duplicate agent tarball entry: openclaw:2026.4.12:linux/amd64',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type {
|
||||
AgentTarballConfig,
|
||||
AgentTarballConfigEntry,
|
||||
AgentTarballPlatform,
|
||||
} from './types'
|
||||
|
||||
const VALID_PLATFORMS: AgentTarballPlatform[] = ['linux/amd64', 'linux/arm64']
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function parsePlatforms(value: unknown): AgentTarballPlatform[] {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new Error('Agent entry must include at least one platform')
|
||||
}
|
||||
|
||||
return value.map((entry) => {
|
||||
if (
|
||||
typeof entry !== 'string' ||
|
||||
!VALID_PLATFORMS.includes(entry as AgentTarballPlatform)
|
||||
) {
|
||||
throw new Error(`Unsupported platform: ${String(entry)}`)
|
||||
}
|
||||
return entry as AgentTarballPlatform
|
||||
})
|
||||
}
|
||||
|
||||
function parseEntry(value: unknown): AgentTarballConfigEntry {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error('Agent entry must be an object')
|
||||
}
|
||||
|
||||
const { agentId, image, version, platforms } = value
|
||||
if (typeof agentId !== 'string' || agentId.trim().length === 0) {
|
||||
throw new Error('Agent entry is missing agentId')
|
||||
}
|
||||
if (typeof image !== 'string' || image.trim().length === 0) {
|
||||
throw new Error(`Agent ${agentId} is missing image`)
|
||||
}
|
||||
if (typeof version !== 'string' || version.trim().length === 0) {
|
||||
throw new Error(`Agent ${agentId} is missing version`)
|
||||
}
|
||||
|
||||
return {
|
||||
agentId,
|
||||
image,
|
||||
version,
|
||||
platforms: parsePlatforms(platforms),
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueEntries(config: AgentTarballConfig): void {
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const agent of config.agents) {
|
||||
for (const platform of agent.platforms) {
|
||||
const key = `${agent.agentId}:${agent.version}:${platform}`
|
||||
if (seen.has(key)) {
|
||||
throw new Error(`Duplicate agent tarball entry: ${key}`)
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAgentTarballConfig(
|
||||
configPath: string,
|
||||
): Promise<AgentTarballConfig> {
|
||||
const raw = JSON.parse(await readFile(configPath, 'utf-8')) as unknown
|
||||
if (!isRecord(raw) || !Array.isArray(raw.agents)) {
|
||||
throw new Error('Agent tarball config must contain an agents array')
|
||||
}
|
||||
|
||||
const config = {
|
||||
agents: raw.agents.map((entry) => parseEntry(entry)),
|
||||
}
|
||||
assertUniqueEntries(config)
|
||||
return config
|
||||
}
|
||||
|
||||
export function filterAgents(
|
||||
config: AgentTarballConfig,
|
||||
agentId?: string,
|
||||
): AgentTarballConfigEntry[] {
|
||||
if (!agentId) return config.agents
|
||||
return config.agents.filter((agent) => agent.agentId === agentId)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { downloadArtifact } from './download'
|
||||
import type { AgentTarballManifestAsset } from './types'
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH
|
||||
})
|
||||
|
||||
describe('downloadArtifact', () => {
|
||||
it('streams the artifact to disk and verifies the checksum', async () => {
|
||||
const payload = 'archive'
|
||||
const asset: AgentTarballManifestAsset = {
|
||||
agentId: 'openclaw',
|
||||
version: '2026.4.12',
|
||||
platform: 'linux/amd64',
|
||||
imageRef: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
filename: 'openclaw-2026.4.12-linux-amd64.tar.gz',
|
||||
sha256: sha256(payload),
|
||||
objectKey:
|
||||
'agent-containers/openclaw/2026.4.12/linux-amd64/openclaw-2026.4.12-linux-amd64.tar.gz',
|
||||
url: 'https://cdn.browseros.com/agent-containers/openclaw/2026.4.12/linux-amd64/openclaw-2026.4.12-linux-amd64.tar.gz',
|
||||
}
|
||||
const destinationPath = join(
|
||||
tmpdir(),
|
||||
`agent-container-download-${Date.now()}.tar.gz`,
|
||||
)
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(payload)) as unknown as typeof fetch
|
||||
|
||||
await downloadArtifact(asset, destinationPath)
|
||||
|
||||
expect(await readFile(destinationPath, 'utf-8')).toBe(payload)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
|
||||
import { verifyFileSha256 } from './checksum'
|
||||
import type { AgentTarballManifest, AgentTarballManifestAsset } from './types'
|
||||
|
||||
export async function downloadManifest(
|
||||
url: string,
|
||||
): Promise<AgentTarballManifest> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download manifest: ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as AgentTarballManifest
|
||||
}
|
||||
|
||||
export async function downloadArtifact(
|
||||
asset: AgentTarballManifestAsset,
|
||||
destinationPath: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(asset.url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download artifact: ${response.status}`)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(
|
||||
`Failed to download artifact: empty response for ${asset.filename}`,
|
||||
)
|
||||
}
|
||||
|
||||
await mkdir(dirname(destinationPath), { recursive: true })
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as unknown as NodeReadableStream),
|
||||
createWriteStream(destinationPath),
|
||||
)
|
||||
|
||||
if (!(await verifyFileSha256(destinationPath, asset.sha256))) {
|
||||
throw new Error(`Checksum verification failed for ${asset.filename}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { buildAgentTarballManifest, toPublishedAgentTarballs } from './manifest'
|
||||
import type { BuiltAgentTarball } from './types'
|
||||
|
||||
const ARTIFACT: BuiltAgentTarball = {
|
||||
agentId: 'openclaw',
|
||||
version: '2026.4.12',
|
||||
platform: 'linux/amd64',
|
||||
imageRef: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
tarGzPath: '/tmp/openclaw.tar.gz',
|
||||
filename: 'openclaw-2026.4.12-linux-amd64.tar.gz',
|
||||
sha256: 'a'.repeat(64),
|
||||
}
|
||||
|
||||
describe('buildAgentTarballManifest', () => {
|
||||
it('emits manifest assets with object keys and URLs', () => {
|
||||
const published = toPublishedAgentTarballs(
|
||||
[ARTIFACT],
|
||||
'agent-containers',
|
||||
'https://cdn.browseros.com',
|
||||
)
|
||||
const manifest = buildAgentTarballManifest(
|
||||
published,
|
||||
'2026-04-22T00:00:00.000Z',
|
||||
)
|
||||
|
||||
expect(manifest).toEqual({
|
||||
publishedAt: '2026-04-22T00:00:00.000Z',
|
||||
assets: [
|
||||
{
|
||||
agentId: 'openclaw',
|
||||
version: '2026.4.12',
|
||||
platform: 'linux/amd64',
|
||||
imageRef: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
filename: 'openclaw-2026.4.12-linux-amd64.tar.gz',
|
||||
sha256: 'a'.repeat(64),
|
||||
objectKey:
|
||||
'agent-containers/openclaw/2026.4.12/linux-amd64/openclaw-2026.4.12-linux-amd64.tar.gz',
|
||||
url: 'https://cdn.browseros.com/agent-containers/openclaw/2026.4.12/linux-amd64/openclaw-2026.4.12-linux-amd64.tar.gz',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { archiveObjectKey } from './naming'
|
||||
import type {
|
||||
AgentTarballManifest,
|
||||
BuiltAgentTarball,
|
||||
PublishedAgentTarball,
|
||||
} from './types'
|
||||
|
||||
function assetUrl(cdnBaseUrl: string, objectKey: string): string {
|
||||
return `${cdnBaseUrl.replace(/\/+$/g, '')}/${objectKey}`
|
||||
}
|
||||
|
||||
export function toPublishedAgentTarballs(
|
||||
artifacts: BuiltAgentTarball[],
|
||||
uploadPrefix: string,
|
||||
cdnBaseUrl: string,
|
||||
): PublishedAgentTarball[] {
|
||||
return artifacts.map((artifact) => {
|
||||
const objectKey = archiveObjectKey(uploadPrefix, artifact)
|
||||
return {
|
||||
...artifact,
|
||||
objectKey,
|
||||
url: assetUrl(cdnBaseUrl, objectKey),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAgentTarballManifest(
|
||||
artifacts: PublishedAgentTarball[],
|
||||
publishedAt = new Date().toISOString(),
|
||||
): AgentTarballManifest {
|
||||
return {
|
||||
publishedAt,
|
||||
assets: artifacts.map((artifact) => ({
|
||||
agentId: artifact.agentId,
|
||||
version: artifact.version,
|
||||
platform: artifact.platform,
|
||||
imageRef: artifact.imageRef,
|
||||
filename: artifact.filename,
|
||||
sha256: artifact.sha256,
|
||||
objectKey: artifact.objectKey,
|
||||
url: artifact.url,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AgentTarballPlatform, BuiltAgentTarball } from './types'
|
||||
|
||||
function trimSlashes(value: string): string {
|
||||
return value.replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
export function joinObjectKey(...parts: string[]): string {
|
||||
return parts
|
||||
.map((part) => trimSlashes(part))
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
}
|
||||
|
||||
export function platformId(platform: AgentTarballPlatform): string {
|
||||
return platform.replace('/', '-')
|
||||
}
|
||||
|
||||
export function imageRef(image: string, version: string): string {
|
||||
return `${image}:${version}`
|
||||
}
|
||||
|
||||
export function archiveFilename(
|
||||
agentId: string,
|
||||
version: string,
|
||||
platform: AgentTarballPlatform,
|
||||
): string {
|
||||
return `${agentId}-${version}-${platformId(platform)}.tar.gz`
|
||||
}
|
||||
|
||||
export function archiveObjectKey(
|
||||
uploadPrefix: string,
|
||||
artifact: BuiltAgentTarball,
|
||||
): string {
|
||||
return joinObjectKey(
|
||||
uploadPrefix,
|
||||
artifact.agentId,
|
||||
artifact.version,
|
||||
platformId(artifact.platform),
|
||||
artifact.filename,
|
||||
)
|
||||
}
|
||||
|
||||
export function releaseManifestObjectKey(
|
||||
uploadPrefix: string,
|
||||
agentId: string,
|
||||
version: string,
|
||||
): string {
|
||||
return joinObjectKey(uploadPrefix, agentId, version, 'manifest.json')
|
||||
}
|
||||
|
||||
export function latestManifestObjectKey(uploadPrefix: string): string {
|
||||
return joinObjectKey(uploadPrefix, 'latest', 'manifest.json')
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
export function getPackageRootDir(): string {
|
||||
return PACKAGE_ROOT
|
||||
}
|
||||
|
||||
export function getDefaultConfigPath(): string {
|
||||
return join(PACKAGE_ROOT, 'config', 'agent-container-tarballs.json')
|
||||
}
|
||||
|
||||
export function getDefaultOutputDir(): string {
|
||||
return resolve(PACKAGE_ROOT, '../../dist/agent-tarballs')
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
export interface CommandRunnerOptions {
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
export type CommandRunner = (
|
||||
command: string[],
|
||||
options?: CommandRunnerOptions,
|
||||
) => Promise<void>
|
||||
|
||||
export async function runCommand(
|
||||
command: string[],
|
||||
options: CommandRunnerOptions = {},
|
||||
): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(command[0], command.slice(1), {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
proc.once('error', reject)
|
||||
proc.once('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`Command failed (${code ?? 'unknown'}): ${command.join(' ')}`,
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
61
packages/browseros-agent/packages/agent-containers/src/r2.ts
Normal file
61
packages/browseros-agent/packages/agent-containers/src/r2.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import {
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
type S3ClientConfig,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { joinObjectKey } from './naming'
|
||||
import type { R2PublishConfig, UploadFileRequest } from './types'
|
||||
|
||||
function requireEnv(name: string, env: NodeJS.ProcessEnv): string {
|
||||
const value = env[name]
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error(`Missing required environment variable: ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function createClientConfig(config: R2PublishConfig): S3ClientConfig {
|
||||
return {
|
||||
region: 'auto',
|
||||
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function loadR2PublishConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): R2PublishConfig {
|
||||
return {
|
||||
accountId: requireEnv('R2_ACCOUNT_ID', env),
|
||||
accessKeyId: requireEnv('R2_ACCESS_KEY_ID', env),
|
||||
secretAccessKey: requireEnv('R2_SECRET_ACCESS_KEY', env),
|
||||
bucket: requireEnv('R2_BUCKET', env),
|
||||
uploadPrefix:
|
||||
env.AGENT_CONTAINERS_R2_UPLOAD_PREFIX?.trim() || 'agent-containers',
|
||||
cdnBaseUrl:
|
||||
env.AGENT_CONTAINERS_CDN_BASE_URL?.trim() || 'https://cdn.browseros.com',
|
||||
}
|
||||
}
|
||||
|
||||
export function createR2Client(config: R2PublishConfig): S3Client {
|
||||
return new S3Client(createClientConfig(config))
|
||||
}
|
||||
|
||||
export async function uploadFileToObject(
|
||||
client: S3Client,
|
||||
config: R2PublishConfig,
|
||||
request: UploadFileRequest,
|
||||
): Promise<void> {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: joinObjectKey(request.key),
|
||||
Body: createReadStream(request.filePath),
|
||||
ContentType: request.contentType ?? 'application/octet-stream',
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export type AgentTarballPlatform = 'linux/amd64' | 'linux/arm64'
|
||||
|
||||
export interface AgentTarballConfigEntry {
|
||||
agentId: string
|
||||
image: string
|
||||
version: string
|
||||
platforms: AgentTarballPlatform[]
|
||||
}
|
||||
|
||||
export interface AgentTarballConfig {
|
||||
agents: AgentTarballConfigEntry[]
|
||||
}
|
||||
|
||||
export interface BuiltAgentTarball {
|
||||
agentId: string
|
||||
version: string
|
||||
platform: AgentTarballPlatform
|
||||
imageRef: string
|
||||
tarGzPath: string
|
||||
filename: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export interface PublishedAgentTarball extends BuiltAgentTarball {
|
||||
objectKey: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface AgentTarballManifestAsset {
|
||||
agentId: string
|
||||
version: string
|
||||
platform: AgentTarballPlatform
|
||||
imageRef: string
|
||||
filename: string
|
||||
sha256: string
|
||||
objectKey: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface AgentTarballManifest {
|
||||
publishedAt: string
|
||||
assets: AgentTarballManifestAsset[]
|
||||
}
|
||||
|
||||
export interface R2PublishConfig {
|
||||
accountId: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
bucket: string
|
||||
uploadPrefix: string
|
||||
cdnBaseUrl: string
|
||||
}
|
||||
|
||||
export interface UploadFileRequest {
|
||||
contentType?: string
|
||||
filePath: string
|
||||
key: string
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { BuiltAgentTarball, UploadFileRequest } from './types'
|
||||
import { publishAgentTarballs } from './upload'
|
||||
|
||||
describe('publishAgentTarballs', () => {
|
||||
it('uploads tarballs and the generated manifest', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-container-upload-'))
|
||||
const tarballPath = join(
|
||||
dir,
|
||||
'openclaw',
|
||||
'2026.4.12',
|
||||
'linux-amd64',
|
||||
'openclaw.tar.gz',
|
||||
)
|
||||
await mkdir(dirname(tarballPath), { recursive: true })
|
||||
await writeFile(tarballPath, 'archive')
|
||||
|
||||
const uploads: UploadFileRequest[] = []
|
||||
const artifact: BuiltAgentTarball = {
|
||||
agentId: 'openclaw',
|
||||
version: '2026.4.12',
|
||||
platform: 'linux/amd64',
|
||||
imageRef: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
tarGzPath: tarballPath,
|
||||
filename: 'openclaw-2026.4.12-linux-amd64.tar.gz',
|
||||
sha256: 'a'.repeat(64),
|
||||
}
|
||||
|
||||
const result = await publishAgentTarballs(
|
||||
[artifact],
|
||||
{
|
||||
accountId: 'account',
|
||||
accessKeyId: 'key',
|
||||
secretAccessKey: 'secret',
|
||||
bucket: 'bucket',
|
||||
uploadPrefix: 'agent-containers',
|
||||
cdnBaseUrl: 'https://cdn.browseros.com',
|
||||
},
|
||||
{
|
||||
manifestPath: join(dir, 'manifest.json'),
|
||||
uploadFile: async (request) => {
|
||||
uploads.push(request)
|
||||
},
|
||||
publishedAt: '2026-04-22T00:00:00.000Z',
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.publishedArtifacts).toHaveLength(1)
|
||||
expect(uploads).toEqual([
|
||||
{
|
||||
key: 'agent-containers/openclaw/2026.4.12/linux-amd64/openclaw-2026.4.12-linux-amd64.tar.gz',
|
||||
filePath: tarballPath,
|
||||
contentType: 'application/gzip',
|
||||
},
|
||||
{
|
||||
key: 'agent-containers/openclaw/2026.4.12/manifest.json',
|
||||
filePath: join(dir, 'openclaw', '2026.4.12', 'manifest.json'),
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
},
|
||||
{
|
||||
key: 'agent-containers/latest/manifest.json',
|
||||
filePath: join(dir, 'manifest.json'),
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
141
packages/browseros-agent/packages/agent-containers/src/upload.ts
Normal file
141
packages/browseros-agent/packages/agent-containers/src/upload.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { S3Client } from '@aws-sdk/client-s3'
|
||||
import { buildAgentTarballManifest, toPublishedAgentTarballs } from './manifest'
|
||||
import { latestManifestObjectKey, releaseManifestObjectKey } from './naming'
|
||||
import { createR2Client, uploadFileToObject } from './r2'
|
||||
import type {
|
||||
AgentTarballManifest,
|
||||
BuiltAgentTarball,
|
||||
PublishedAgentTarball,
|
||||
R2PublishConfig,
|
||||
UploadFileRequest,
|
||||
} from './types'
|
||||
|
||||
const JSON_CONTENT_TYPE = 'application/json; charset=utf-8'
|
||||
|
||||
export interface PublishAgentTarballsOptions {
|
||||
manifestPath?: string
|
||||
publishedAt?: string
|
||||
uploadFile?: (request: UploadFileRequest) => Promise<void>
|
||||
}
|
||||
|
||||
export interface PublishAgentTarballsResult {
|
||||
manifest: AgentTarballManifest
|
||||
manifestPath: string
|
||||
publishedArtifacts: PublishedAgentTarball[]
|
||||
}
|
||||
|
||||
function createUploadFile(
|
||||
client: S3Client,
|
||||
config: R2PublishConfig,
|
||||
): (request: UploadFileRequest) => Promise<void> {
|
||||
return async (request) => {
|
||||
await uploadFileToObject(client, config, request)
|
||||
}
|
||||
}
|
||||
|
||||
function manifestJson(manifest: AgentTarballManifest): string {
|
||||
return `${JSON.stringify(manifest, null, 2)}\n`
|
||||
}
|
||||
|
||||
function groupArtifactsByRelease(
|
||||
artifacts: PublishedAgentTarball[],
|
||||
): Map<string, PublishedAgentTarball[]> {
|
||||
const groups = new Map<string, PublishedAgentTarball[]>()
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const key = `${artifact.agentId}:${artifact.version}`
|
||||
const group = groups.get(key)
|
||||
if (group) {
|
||||
group.push(artifact)
|
||||
continue
|
||||
}
|
||||
groups.set(key, [artifact])
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
async function uploadReleaseManifests(
|
||||
artifacts: PublishedAgentTarball[],
|
||||
config: R2PublishConfig,
|
||||
uploadFile: (request: UploadFileRequest) => Promise<void>,
|
||||
publishedAt?: string,
|
||||
): Promise<void> {
|
||||
for (const releaseArtifacts of groupArtifactsByRelease(artifacts).values()) {
|
||||
const [artifact] = releaseArtifacts
|
||||
const manifest = buildAgentTarballManifest(releaseArtifacts, publishedAt)
|
||||
const manifestPath = join(
|
||||
dirname(dirname(artifact.tarGzPath)),
|
||||
'manifest.json',
|
||||
)
|
||||
await writeFile(manifestPath, manifestJson(manifest))
|
||||
await uploadFile({
|
||||
key: releaseManifestObjectKey(
|
||||
config.uploadPrefix,
|
||||
artifact.agentId,
|
||||
artifact.version,
|
||||
),
|
||||
filePath: manifestPath,
|
||||
contentType: JSON_CONTENT_TYPE,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishAgentTarballs(
|
||||
artifacts: BuiltAgentTarball[],
|
||||
config: R2PublishConfig,
|
||||
options: PublishAgentTarballsOptions = {},
|
||||
): Promise<PublishAgentTarballsResult> {
|
||||
const client = options.uploadFile ? undefined : createR2Client(config)
|
||||
const uploadFile =
|
||||
options.uploadFile ??
|
||||
(client ? createUploadFile(client, config) : undefined)
|
||||
if (!uploadFile) {
|
||||
throw new Error('Upload handler is not configured')
|
||||
}
|
||||
const publishedArtifacts = toPublishedAgentTarballs(
|
||||
artifacts,
|
||||
config.uploadPrefix,
|
||||
config.cdnBaseUrl,
|
||||
)
|
||||
const manifest = buildAgentTarballManifest(
|
||||
publishedArtifacts,
|
||||
options.publishedAt,
|
||||
)
|
||||
const manifestPath =
|
||||
options.manifestPath ??
|
||||
join(process.cwd(), 'dist', 'agent-tarballs', 'manifest.json')
|
||||
|
||||
try {
|
||||
for (const artifact of publishedArtifacts) {
|
||||
await uploadFile({
|
||||
key: artifact.objectKey,
|
||||
filePath: artifact.tarGzPath,
|
||||
contentType: 'application/gzip',
|
||||
})
|
||||
}
|
||||
|
||||
await uploadReleaseManifests(
|
||||
publishedArtifacts,
|
||||
config,
|
||||
uploadFile,
|
||||
options.publishedAt,
|
||||
)
|
||||
await writeFile(manifestPath, manifestJson(manifest))
|
||||
await uploadFile({
|
||||
key: latestManifestObjectKey(config.uploadPrefix),
|
||||
filePath: manifestPath,
|
||||
contentType: JSON_CONTENT_TYPE,
|
||||
})
|
||||
} finally {
|
||||
client?.destroy()
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifestPath,
|
||||
publishedArtifacts,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export const OPENCLAW_GATEWAY_PORT = 18789
|
||||
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
|
||||
export const OPENCLAW_CONTAINER_HOME = '/home/node/.openclaw'
|
||||
export const OPENCLAW_COMPOSE_PROJECT_NAME = 'browseros-openclaw'
|
||||
export const OPENCLAW_GATEWAY_CONTAINER_NAME = `${OPENCLAW_COMPOSE_PROJECT_NAME}-openclaw-gateway-1`
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user