mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-21 12:55:09 +00:00
* refactor(openclaw): TKT-788 cleanup — bump image, lock no-auth, delete observer + image bypass Re-lands the openclaw-only changes from #934 (reverted in #953 because the original PR's working tree had stale rollback content for `packages/browseros/tools/patch/`). This commit is the same openclaw diff with zero changes outside `packages/browseros-agent/`. What changes (TKT-788 work-streams A + B + C): WS-A — bundled gateway no-auth: - Bump image from `ghcr.io/openclaw/openclaw:2026.4.12` to `ghcr.io/browseros-ai/openclaw:2026.5.2-browseros.1` (BrowserOS- pinned variant with the no-auth contract baked in). - Configure gateway with `auth.mode: 'none'`; remove the device-auth bootstrap dance that the older binary required. - Delete the per-call token plumbing the http-client / observer / chat- client carried (340 LOC). The harness still passes a stable token in headers for backwards-compat with code that hasn't been re-pointed yet, but it is no longer required by the gateway. WS-C — delete the image-attachment bypass: - The HTTP `/v1/chat/completions` carve-out for OpenClaw image turns is gone. Image attachments now ride through ACP as image content blocks (which acpx 0.6.x supports natively for openclaw, claude, codex). - Delete `openclaw-gateway-chat-client.ts` (211 LOC) and `image-turn.ts` (219 LOC). - Drop `maybeHandleTurn` from the `AcpxAgentAdapter` interface and the openclaw entry. `AcpxAdapterTurnInput` removed. - Drop the corresponding 'diverts OpenClaw image turns to the gateway chat client' test from `acpx-runtime.test.ts`. WS-B — replace the WS observer with harness events: - Delete `openclaw-observer.ts` (276 LOC) — no more parallel WS subscription, no more `new OpenClawObserver`, no more `ensureObserverConnected` / `observer.disconnect()` plumbing. - Wire `AgentHarnessService` to receive turn-lifecycle events from the runtime stream itself (`turnLifecycleListeners`) and feed ClawSession from those, preserving the dashboard SSE shape. Net: 314 insertions / 1144 deletions, all under `packages/browseros-agent/`. Typecheck clean across all 6 packages. 946 server tests pass (1 unrelated CDP-dependent test skipped — same state as origin/dev). Reference: TKT-788. The patch-CLI rollback that was in the squash of #934 is intentionally NOT in this commit. * fix(openclaw): handle 2026.5.4 acp-cli envelope shapes (media + injected timestamp) + bump image OpenClaw 2026.5.4 (the BrowserOS-pinned image variant with the no-auth handshake bypass needed for cron tool calls from inside ACP) introduced two new envelope prefix shapes that the post-bypass-deletion path now surfaces in user-message text: [media attached: <internal-path> (<mime>)] [<weekday> <YYYY-MM-DD HH:MM> <TZ>] [Working directory: <path>] <BrowserOS role envelope> The previous cleaner only matched a leading [Working directory: ...] \n\n line. With media + timestamp prefixes ahead of it the anchor no longer matched, so image-attachment user turns rendered with 8+ lines of envelope leak in the chat panel. Replaces the single OPENCLAW_WORKDIR_PREFIX with three content-shape- anchored patterns chained through stripOpenClawAcpCliEnvelope(): 1. [media attached: <path> (<mime>)] ← repeats per attachment 2. [<weekday> <YYYY-MM-DD HH:MM> <TZ>] ← injectTimestamp 3. [Working directory: <path>] ← acp-cli prefixCwd Each is anchored on its content shape (media attached:, weekday abbrev + ISO date, Working directory:) rather than just '[…]', so user-typed lines that happen to start with brackets are not eaten. Also bumps OPENCLAW_IMAGE from 2026.5.2-browseros.1 to 2026.5.4-browseros.1. The 5.2 image refused tool-side WS connections with 'device identity required' even though gateway auth.mode=none — PR #6 in browseros-ai/openclaw added the OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH bypass that ships in 5.4. Without 5.4, the cron tool (and any other tool that opens a fresh gateway WS from inside the embedded runner) fails with 1008. Verified end-to-end with the BrowserOS chat endpoint: - Plain text turn: clean - Image attachment turn: clean (was leaking 8 envelope lines pre-fix) - One-shot kind:at cron fires, PING fire renders clean - Second openclaw agent creates, runs, history isolated 15/15 history-mapper unit tests pass; typecheck clean across all packages.
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 BrowserOS
|
|
*/
|
|
|
|
import { describe, expect, it, mock } from 'bun:test'
|
|
import {
|
|
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
|
OPENCLAW_IMAGE,
|
|
} from '@browseros/shared/constants/openclaw'
|
|
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
|
|
import { ContainerNameInUseError } from '../../../../src/lib/vm/errors'
|
|
|
|
const PROJECT_DIR = '/tmp/openclaw'
|
|
const OPENCLAW_NAME_RELEASE_WAIT = { timeoutMs: 10_000, intervalMs: 100 }
|
|
const defaultSpec = {
|
|
hostPort: 18789,
|
|
hostHome: '/Users/me/.browseros/vm/openclaw',
|
|
envFilePath: '/Users/me/.browseros/vm/openclaw/.openclaw/.env',
|
|
gatewayToken: 'token-123',
|
|
timezone: 'America/Los_Angeles',
|
|
}
|
|
|
|
describe('ContainerRuntime', () => {
|
|
it('starts the gateway by loading the image, creating, and starting a container', async () => {
|
|
const deps = createDeps()
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await runtime.startGateway(defaultSpec)
|
|
|
|
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
|
|
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
|
{ force: true },
|
|
undefined,
|
|
)
|
|
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
|
|
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
|
OPENCLAW_NAME_RELEASE_WAIT,
|
|
)
|
|
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
|
|
'openclaw',
|
|
undefined,
|
|
)
|
|
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
|
image: OPENCLAW_IMAGE,
|
|
restart: 'unless-stopped',
|
|
ports: [
|
|
{
|
|
hostIp: '127.0.0.1',
|
|
hostPort: 18789,
|
|
containerPort: 18789,
|
|
},
|
|
],
|
|
envFile: '/mnt/browseros/vm/openclaw/.openclaw/.env',
|
|
mounts: [
|
|
{
|
|
source: '/mnt/browseros/vm/openclaw',
|
|
target: '/home/node',
|
|
},
|
|
],
|
|
addHosts: ['host.containers.internal:192.168.5.2'],
|
|
}),
|
|
undefined,
|
|
)
|
|
expect(deps.shell.startContainer).toHaveBeenCalledWith(
|
|
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
|
)
|
|
})
|
|
|
|
it('reconciles and retries when gateway create reports name-in-use', async () => {
|
|
const deps = createDeps()
|
|
deps.shell.createContainer = mock(async () => {
|
|
if (deps.shell.createContainer.mock.calls.length === 1) {
|
|
throw new ContainerNameInUseError(
|
|
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
|
'nerdctl create',
|
|
1,
|
|
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
|
|
)
|
|
}
|
|
})
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await runtime.startGateway(defaultSpec)
|
|
|
|
expect(deps.shell.createContainer).toHaveBeenCalledTimes(2)
|
|
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(2)
|
|
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
|
|
expect(deps.shell.startContainer).toHaveBeenCalledWith(
|
|
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
|
)
|
|
})
|
|
|
|
it('bounds gateway create retries when the name stays in use', async () => {
|
|
const deps = createDeps()
|
|
deps.shell.createContainer = mock(async () => {
|
|
throw new ContainerNameInUseError(
|
|
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
|
'nerdctl create',
|
|
1,
|
|
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
|
|
)
|
|
})
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await expect(runtime.startGateway(defaultSpec)).rejects.toBeInstanceOf(
|
|
ContainerNameInUseError,
|
|
)
|
|
|
|
expect(deps.shell.createContainer).toHaveBeenCalledTimes(3)
|
|
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
|
|
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(3)
|
|
expect(deps.shell.startContainer).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses OPENCLAW_IMAGE as a direct image override', async () => {
|
|
const previous = process.env.OPENCLAW_IMAGE
|
|
process.env.OPENCLAW_IMAGE = 'localhost/openclaw:test'
|
|
const deps = createDeps()
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
try {
|
|
await runtime.startGateway(defaultSpec)
|
|
} finally {
|
|
if (previous === undefined) delete process.env.OPENCLAW_IMAGE
|
|
else process.env.OPENCLAW_IMAGE = previous
|
|
}
|
|
|
|
expect(deps.loader.ensureImageLoaded).toHaveBeenCalledWith(
|
|
'localhost/openclaw:test',
|
|
undefined,
|
|
)
|
|
expect(deps.loader.ensureAgentImageLoaded).not.toHaveBeenCalled()
|
|
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
|
expect.objectContaining({ image: 'localhost/openclaw:test' }),
|
|
undefined,
|
|
)
|
|
})
|
|
|
|
it('passes private-ingress no-auth only when requested', async () => {
|
|
const deps = createDeps()
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await runtime.startGateway({
|
|
...defaultSpec,
|
|
gatewayToken: undefined,
|
|
privateIngressNoAuth: true,
|
|
})
|
|
|
|
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
env: expect.objectContaining({
|
|
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
|
|
}),
|
|
}),
|
|
undefined,
|
|
)
|
|
})
|
|
|
|
it('delegates ensureReady and stopVm to VmRuntime', async () => {
|
|
const deps = createDeps()
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await runtime.ensureReady()
|
|
await runtime.stopVm()
|
|
|
|
expect(deps.vm.ensureReady).toHaveBeenCalled()
|
|
expect(deps.vm.getDefaultGateway).toHaveBeenCalled()
|
|
expect(deps.vm.stopVm).toHaveBeenCalled()
|
|
})
|
|
|
|
it('runs setup commands with guest paths', async () => {
|
|
const deps = createDeps()
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await runtime.runGatewaySetupCommand(
|
|
['node', 'dist/index.js', 'agents', 'list', '--json'],
|
|
defaultSpec,
|
|
)
|
|
|
|
expect(deps.shell.runCommand).toHaveBeenCalledWith(
|
|
expect.arrayContaining([
|
|
'create',
|
|
'--name',
|
|
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
|
'--env-file',
|
|
'/mnt/browseros/vm/openclaw/.openclaw/.env',
|
|
'-v',
|
|
'/mnt/browseros/vm/openclaw:/home/node',
|
|
'--add-host',
|
|
'host.containers.internal:192.168.5.2',
|
|
OPENCLAW_IMAGE,
|
|
]),
|
|
undefined,
|
|
)
|
|
expect(deps.shell.runCommand).toHaveBeenCalledWith(
|
|
['start', '-a', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
|
|
undefined,
|
|
)
|
|
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
|
|
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
|
{ force: true },
|
|
undefined,
|
|
)
|
|
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
|
|
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
|
OPENCLAW_NAME_RELEASE_WAIT,
|
|
)
|
|
})
|
|
|
|
it('reconciles and retries when setup create reports name-in-use', async () => {
|
|
const deps = createDeps()
|
|
let setupCreateCount = 0
|
|
deps.shell.runCommand = mock(async (args: string[]) => {
|
|
if (args[0] === 'create') {
|
|
setupCreateCount += 1
|
|
if (setupCreateCount === 1) {
|
|
return {
|
|
exitCode: 1,
|
|
stdout: '',
|
|
stderr: `name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup" is already used`,
|
|
}
|
|
}
|
|
}
|
|
return { exitCode: 0, stdout: '', stderr: '' }
|
|
})
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await expect(
|
|
runtime.runGatewaySetupCommand(
|
|
['node', 'dist/index.js', 'agents', 'list', '--json'],
|
|
defaultSpec,
|
|
),
|
|
).resolves.toBe(0)
|
|
|
|
expect(setupCreateCount).toBe(2)
|
|
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
|
|
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('tails and fetches gateway logs through the new transport', async () => {
|
|
const deps = createDeps()
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
const stop = runtime.tailGatewayLogs(() => {})
|
|
const logs = await runtime.getGatewayLogs(10)
|
|
stop()
|
|
|
|
expect(deps.shell.tailLogs).toHaveBeenCalledWith(
|
|
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
|
expect.any(Function),
|
|
)
|
|
expect(deps.shell.runCommand).toHaveBeenCalledWith(
|
|
['logs', '-n', '10', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
|
expect.any(Function),
|
|
)
|
|
expect(logs).toEqual(['log line'])
|
|
})
|
|
|
|
it('prewarms the gateway image without creating a container', async () => {
|
|
const deps = createDeps()
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await runtime.prewarmGatewayImage()
|
|
|
|
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
|
|
'openclaw',
|
|
undefined,
|
|
)
|
|
expect(deps.shell.createContainer).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('detects when the gateway container uses the current image', async () => {
|
|
const deps = createDeps()
|
|
deps.shell.containerImageRef.mockImplementation(async () => OPENCLAW_IMAGE)
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
|
|
expect(deps.shell.containerImageRef).toHaveBeenCalledWith(
|
|
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
|
)
|
|
})
|
|
|
|
it('treats a digest-qualified current image ref as current', async () => {
|
|
const deps = createDeps()
|
|
deps.shell.containerImageRef.mockImplementation(
|
|
async () => `${OPENCLAW_IMAGE}@sha256:${'a'.repeat(64)}`,
|
|
)
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
|
|
})
|
|
|
|
it('detects when the gateway container uses an old image', async () => {
|
|
const deps = createDeps()
|
|
deps.shell.containerImageRef.mockImplementation(
|
|
async () => 'ghcr.io/openclaw/openclaw:old',
|
|
)
|
|
const runtime = new ContainerRuntime({
|
|
vm: deps.vm,
|
|
shell: deps.shell,
|
|
loader: deps.loader,
|
|
projectDir: PROJECT_DIR,
|
|
})
|
|
|
|
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
|
|
})
|
|
})
|
|
|
|
function createDeps() {
|
|
return {
|
|
vm: {
|
|
ensureReady: mock(async () => {}),
|
|
getDefaultGateway: mock(async () => '192.168.5.2'),
|
|
stopVm: mock(async () => {}),
|
|
isReady: mock(async () => true),
|
|
},
|
|
shell: {
|
|
createContainer: mock(async () => {}),
|
|
startContainer: mock(async () => {}),
|
|
stopContainer: mock(async () => {}),
|
|
removeContainer: mock(async () => {}),
|
|
containerImageRef: mock(async () => OPENCLAW_IMAGE),
|
|
waitForContainerNameRelease: mock(async () => {}),
|
|
exec: mock(async () => 0),
|
|
runCommand: mock(
|
|
async (_args: string[], onLog?: (line: string) => void) => {
|
|
onLog?.('log line')
|
|
return { exitCode: 0, stdout: 'log line\n', stderr: '' }
|
|
},
|
|
),
|
|
tailLogs: mock(() => () => {}),
|
|
},
|
|
loader: {
|
|
ensureImageLoaded: mock(async () => {}),
|
|
ensureAgentImageLoaded: mock(async () => OPENCLAW_IMAGE),
|
|
},
|
|
}
|
|
}
|