Compare commits

...

5 Commits

Author SHA1 Message Date
Nikhil Sonti
8cd7c985e3 fix: address review feedback for PR #891 2026-04-30 12:54:44 -07:00
Nikhil Sonti
548c9dd996 feat(dev): bootstrap setup from dev watch 2026-04-30 12:49:25 -07:00
Nikhil
d38b01a8c7 feat(dev): add guided cleanup and reset commands (#890)
* feat(dev): add guided cleanup and reset commands

* fix: address cleanup reset review feedback
2026-04-30 12:27:15 -07:00
Nikhil
ff36c8412b fix(dev): use run lock for watch cleanup (#889)
* fix(dev): use run lock for watch cleanup

* fix(dev): address watch lock review comments
2026-04-30 11:46:17 -07:00
Nikhil
fd5aba249b fix: stabilize OpenClaw gateway startup (#888)
* feat(server): add shared process lock helper

* feat(container): add container name reconciliation helpers

* feat(openclaw): serialize lifecycle across processes

* fix(openclaw): reconcile fixed gateway container startup

* test(openclaw): cover lifecycle race recovery

* fix(server): satisfy process lock error override

* fix(openclaw): address review feedback

* test(openclaw): align serialization mock with image check
2026-04-30 11:31:40 -07:00
25 changed files with 2285 additions and 154 deletions

View File

@@ -108,6 +108,7 @@
"klavis": "^2.15.0",
"pino": "^9.6.0",
"posthog-node": "^4.17.0",
"proper-lockfile": "^4.1.2",
"puppeteer-core": "24.23.0",
"ws": "^8.18.0",
"zod": "^3.24.2",
@@ -117,6 +118,7 @@
"@types/bun": "1.3.5",
"@types/debug": "^4.1.12",
"@types/node": "^24.3.3",
"@types/proper-lockfile": "^4.1.4",
"@types/sinon": "^21.0.0",
"@types/ws": "^8.5.13",
"async-mutex": "^0.5.0",

View File

@@ -15,18 +15,26 @@ import type {
ContainerCommandResult,
ContainerSpec,
LogFn,
WaitForContainerNameReleaseOptions,
} from '../../../lib/container'
import { isContainerNameInUse } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import {
GUEST_VM_STATE,
hostPathToGuest,
type VmRuntime,
} from '../../../lib/vm'
import { ContainerNameInUseError } from '../../../lib/vm/errors'
const GATEWAY_CONTAINER_HOME = '/home/node'
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
const GATEWAY_NPM_PREFIX = `${GATEWAY_CONTAINER_HOME}/.npm-global`
const CREATE_CONTAINER_MAX_ATTEMPTS = 3
const OPENCLAW_NAME_RELEASE_WAIT: WaitForContainerNameReleaseOptions = {
timeoutMs: 10_000,
intervalMs: 100,
}
// Prepend user-installed bin so tools like `claude` / `gemini` CLI that
// are installed via npm into the mounted home are discoverable by
// OpenClaw's child-process spawns (no login shell is involved).
@@ -121,10 +129,9 @@ export class ContainerRuntime {
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.removeGatewayContainer(onLog)
const image = await this.ensureGatewayImageLoaded(onLog)
const container = await this.buildGatewayContainerSpec(input, image)
await this.shell.createContainer(container, onLog)
await this.createContainerWithNameReconcile(container, onLog)
await this.shell.startContainer(container.name)
}
@@ -208,10 +215,11 @@ export class ContainerRuntime {
onLog?: LogFn,
): Promise<number> {
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
await this.shell.removeContainer(setupContainerName, { force: true }, onLog)
await this.removeContainerAndWait(setupContainerName, onLog)
const image = await this.ensureGatewayImageLoaded(onLog)
const setupArgs = command[0] === 'node' ? command.slice(1) : command
const createResult = await this.shell.runCommand(
const createResult = await this.runSetupCreateWithNameReconcile(
setupContainerName,
[
'create',
'--name',
@@ -252,10 +260,74 @@ export class ContainerRuntime {
}
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
await this.shell.removeContainer(
OPENCLAW_GATEWAY_CONTAINER_NAME,
{ force: true },
onLog,
await this.removeContainerAndWait(OPENCLAW_GATEWAY_CONTAINER_NAME, onLog)
}
/** Create the fixed-name gateway after reconciling stale nerdctl name ownership. */
private async createContainerWithNameReconcile(
container: ContainerSpec,
onLog?: LogFn,
): Promise<void> {
let attempt = 1
while (true) {
await this.removeContainerAndWait(container.name, onLog)
try {
await this.shell.createContainer(container, onLog)
return
} catch (err) {
if (
!(err instanceof ContainerNameInUseError) ||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
) {
throw err
}
logger.warn('OpenClaw container name still in use; retrying create', {
containerName: container.name,
attempt,
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
})
attempt++
}
}
}
private async runSetupCreateWithNameReconcile(
setupContainerName: string,
createArgs: string[],
onLog?: LogFn,
): Promise<ContainerCommandResult> {
let attempt = 1
while (true) {
const result = await this.shell.runCommand(createArgs, onLog)
if (
result.exitCode === 0 ||
!isContainerNameInUse(result.stderr) ||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
) {
return result
}
logger.warn(
'OpenClaw setup container name still in use; retrying create',
{
containerName: setupContainerName,
attempt,
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
},
)
await this.removeContainerAndWait(setupContainerName, onLog)
attempt++
}
}
private async removeContainerAndWait(
containerName: string,
onLog?: LogFn,
): Promise<void> {
await this.shell.removeContainer(containerName, { force: true }, onLog)
await this.shell.waitForContainerNameRelease(
containerName,
OPENCLAW_NAME_RELEASE_WAIT,
)
}

View File

@@ -10,6 +10,7 @@
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
@@ -18,6 +19,7 @@ import {
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import { withProcessLock } from '../../../lib/process-lock'
import {
type AgentLiveStatus,
type AgentSessionState,
@@ -1012,10 +1014,16 @@ export class OpenClawService {
if (persistedPort !== null) {
this.setPort(persistedPort)
}
if (await this.isGatewayAvailable(this.hostPort)) {
const currentPortReady = await this.isGatewayPortReady(this.hostPort)
if (
currentPortReady &&
(await this.isGatewayAuthenticated(this.hostPort))
) {
return
}
const hostPort = await allocateGatewayPort(this.openclawDir)
const hostPort = await allocateGatewayPort(this.openclawDir, {
excludePort: currentPortReady ? this.hostPort : undefined,
})
if (hostPort !== this.hostPort) {
logProgress?.(`Allocated OpenClaw gateway host port ${hostPort}`)
logger.info('Allocated OpenClaw gateway host port', { hostPort })
@@ -1025,7 +1033,10 @@ export class OpenClawService {
private async isGatewayAvailable(hostPort: number): Promise<boolean> {
if (!(await this.isGatewayPortReady(hostPort))) return false
return this.isGatewayAuthenticated(hostPort)
}
private async isGatewayAuthenticated(hostPort: number): Promise<boolean> {
if (!this.tokenLoaded) {
logger.debug(
'OpenClaw gateway port is ready before auth token is loaded',
@@ -1512,8 +1523,14 @@ export class OpenClawService {
})
await previous.catch(() => undefined)
try {
logger.debug('OpenClaw lifecycle operation started', { operation })
return await fn()
return await withProcessLock(
'openclaw-lifecycle',
{ lockDir: join(this.openclawDir, '.locks') },
async () => {
logger.debug('OpenClaw lifecycle operation started', { operation })
return await fn()
},
)
} finally {
release()
}

View File

@@ -16,6 +16,7 @@ import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/ope
import { getOpenClawStateDir } from './openclaw-env'
const RUNTIME_STATE_FILE = 'runtime-state.json'
const MAX_TCP_PORT = 65_535
interface RuntimeState {
gatewayPort: number
@@ -26,7 +27,7 @@ function readForcedGatewayPort(): number | null {
if (!raw) return null
const parsed = Number.parseInt(raw, 10)
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_TCP_PORT) {
return null
}
return parsed
@@ -49,7 +50,7 @@ export async function readPersistedGatewayPort(
typeof parsed.gatewayPort === 'number' &&
Number.isInteger(parsed.gatewayPort) &&
parsed.gatewayPort > 0 &&
parsed.gatewayPort <= 65535
parsed.gatewayPort <= MAX_TCP_PORT
) {
return parsed.gatewayPort
}
@@ -82,14 +83,26 @@ function isPortAvailable(port: number): Promise<boolean> {
})
}
async function findAvailablePort(startPort: number): Promise<number> {
async function findAvailablePort(
startPort: number,
excludePort?: number,
): Promise<number> {
let port = startPort
while (!(await isPortAvailable(port))) {
while (port === excludePort || !(await isPortAvailable(port))) {
port++
if (port > MAX_TCP_PORT) {
throw new Error(
`No available OpenClaw gateway port found from ${startPort}`,
)
}
}
return port
}
export interface AllocateGatewayPortOptions {
excludePort?: number
}
/**
* Pick a host port for the gateway container and persist it. Prefers the
* previously persisted port when it's still bindable; otherwise scans
@@ -97,6 +110,7 @@ async function findAvailablePort(startPort: number): Promise<number> {
*/
export async function allocateGatewayPort(
openclawDir: string,
opts: AllocateGatewayPortOptions = {},
): Promise<number> {
const forcedPort = readForcedGatewayPort()
if (forcedPort !== null) {
@@ -105,10 +119,17 @@ export async function allocateGatewayPort(
}
const persisted = await readPersistedGatewayPort(openclawDir)
if (persisted !== null && (await isPortAvailable(persisted))) {
if (
persisted !== null &&
persisted !== opts.excludePort &&
(await isPortAvailable(persisted))
) {
return persisted
}
const port = await findAvailablePort(OPENCLAW_GATEWAY_CONTAINER_PORT)
const port = await findAvailablePort(
OPENCLAW_GATEWAY_CONTAINER_PORT,
opts.excludePort,
)
await writePersistedGatewayPort(openclawDir, port)
return port
}

View File

@@ -4,9 +4,20 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { ContainerCliError } from '../vm/errors'
import {
ContainerCliError,
ContainerNameInUseError,
ContainerNameReleaseTimeoutError,
} from '../vm/errors'
import { LimaCli } from '../vm/lima-cli'
import type { ContainerSpec, LogFn, MountSpec, PortMapping } from './types'
import type {
ContainerInfo,
ContainerSpec,
LogFn,
MountSpec,
PortMapping,
WaitForContainerNameReleaseOptions,
} from './types'
export function buildNerdctlCommand(args: string[]): string[] {
return ['nerdctl', ...args]
@@ -58,7 +69,18 @@ export class ContainerCli {
}
async createContainer(spec: ContainerSpec, onLog?: LogFn): Promise<void> {
await this.runRequired(buildCreateArgs(spec), onLog)
const args = buildCreateArgs(spec)
const result = await this.runCommand(args, onLog)
if (result.exitCode === 0) return
if (isContainerNameInUse(result.stderr)) {
throw new ContainerNameInUseError(
spec.name,
`nerdctl ${args.join(' ')}`,
result.exitCode,
result.stderr.trim(),
)
}
throw this.commandError(args, result)
}
async startContainer(name: string, onLog?: LogFn): Promise<void> {
@@ -84,6 +106,36 @@ export class ContainerCli {
throw this.commandError(args, result)
}
/** Inspect a named container without treating absence as a command failure. */
async inspectContainer(name: string): Promise<ContainerInfo | null> {
const args = ['container', 'inspect', '--format', '{{json .}}', name]
const result = await this.runCommand(args)
if (result.exitCode === 0) {
return parseContainerInfo(result.stdout, name)
}
if (isNoSuchContainer(result.stderr)) return null
throw this.commandError(args, result)
}
/** Wait for containerd/nerdctl to stop resolving a container name after rm. */
async waitForContainerNameRelease(
name: string,
opts: WaitForContainerNameReleaseOptions = {},
): Promise<void> {
const timeoutMs = opts.timeoutMs ?? 5_000
const intervalMs = opts.intervalMs ?? 100
const startedAt = Date.now()
while (Date.now() - startedAt <= timeoutMs) {
if (!(await this.inspectContainer(name))) return
const remainingMs = timeoutMs - (Date.now() - startedAt)
if (remainingMs <= 0) break
await Bun.sleep(Math.min(intervalMs, remainingMs))
}
throw new ContainerNameReleaseTimeoutError(name, timeoutMs)
}
async exec(name: string, cmd: string[], onLog?: LogFn): Promise<number> {
const result = await this.runCommand(['exec', name, ...cmd], onLog)
return result.exitCode
@@ -198,12 +250,65 @@ function mountArg(mount: MountSpec): string {
return `${mount.source}:${mount.target}${mount.readonly ? ':ro' : ''}`
}
function parseContainerInfo(
stdout: string,
fallbackName: string,
): ContainerInfo {
const line = stdout
.trim()
.split('\n')
.map((entry) => entry.trim())
.find(Boolean)
if (!line) {
throw new Error(`nerdctl container inspect returned empty output`)
}
const parsed = JSON.parse(line) as unknown
const container = Array.isArray(parsed) ? parsed[0] : parsed
const object = isRecord(container) ? container : {}
const config = isRecord(object.Config) ? object.Config : {}
const state = isRecord(object.State) ? object.State : {}
const name = stringValue(object.Name)?.replace(/^\/+/, '') ?? fallbackName
const status = stringValue(state.Status) ?? stringValue(object.Status)
const running =
typeof state.Running === 'boolean'
? state.Running
: status
? status.toLowerCase() === 'running'
: null
return {
id: stringValue(object.ID) ?? stringValue(object.Id),
name,
image: stringValue(config.Image) ?? stringValue(object.Image),
status,
running,
}
}
function isNoSuchContainer(stderr: string): boolean {
const lower = stderr.toLowerCase()
return lower.includes('no such container') || lower.includes('not found')
return (
lower.includes('no such container') || lower.includes('container not found')
)
}
export function isContainerNameInUse(stderr: string): boolean {
const lower = stderr.toLowerCase()
return (
(lower.includes('name-store error') && lower.includes('already used')) ||
lower.includes('name is already in use')
)
}
function linesToOutput(lines: string[]): string {
if (lines.length === 0) return ''
return `${lines.join('\n')}\n`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function stringValue(value: unknown): string | null {
return typeof value === 'string' && value ? value : null
}

View File

@@ -38,6 +38,19 @@ export interface ContainerSpec {
command?: string[]
}
export interface ContainerInfo {
id: string | null
name: string
image: string | null
status: string | null
running: boolean | null
}
export interface WaitForContainerNameReleaseOptions {
timeoutMs?: number
intervalMs?: number
}
export interface LogLine {
stream: 'stdout' | 'stderr'
line: string

View File

@@ -0,0 +1,130 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import lockfile from 'proper-lockfile'
const DEFAULT_STALE_MS = 60_000
const DEFAULT_UPDATE_MS = 15_000
const DEFAULT_TIMEOUT_MS = 120_000
const DEFAULT_RETRY_MIN_TIMEOUT_MS = 100
const DEFAULT_RETRY_MAX_TIMEOUT_MS = 1_000
export interface ProcessLockOptions {
lockDir: string
staleMs?: number
updateMs?: number
timeoutMs?: number
retryMinTimeoutMs?: number
retryMaxTimeoutMs?: number
randomize?: boolean
}
export class ProcessLockTimeoutError extends Error {
constructor(
public readonly lockName: string,
public readonly lockPath: string,
public readonly timeoutMs: number,
public override readonly cause?: unknown,
) {
super(
`Timed out acquiring process lock "${lockName}" at ${lockPath} after ${timeoutMs}ms`,
)
this.name = 'ProcessLockTimeoutError'
}
}
/** Run a critical section while holding a named lock shared across processes. */
export async function withProcessLock<T>(
name: string,
options: ProcessLockOptions,
fn: () => Promise<T>,
): Promise<T> {
const release = await acquireProcessLock(name, options)
try {
return await fn()
} finally {
await release()
}
}
export function resolveProcessLockPath(lockDir: string, name: string): string {
return join(lockDir, `${sanitizeLockName(name)}.lock`)
}
async function acquireProcessLock(
name: string,
options: ProcessLockOptions,
): Promise<() => Promise<void>> {
await mkdir(options.lockDir, { recursive: true })
const lockPath = resolveProcessLockPath(options.lockDir, name)
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryMinTimeoutMs =
options.retryMinTimeoutMs ?? DEFAULT_RETRY_MIN_TIMEOUT_MS
const retryMaxTimeoutMs =
options.retryMaxTimeoutMs ?? DEFAULT_RETRY_MAX_TIMEOUT_MS
const startedAt = Date.now()
let lastError: unknown
while (Date.now() - startedAt <= timeoutMs) {
try {
return await lockfile.lock(lockPath, {
lockfilePath: lockPath,
realpath: false,
stale: options.staleMs ?? DEFAULT_STALE_MS,
update: options.updateMs ?? DEFAULT_UPDATE_MS,
// The wrapper owns retry/backoff so acquisition respects timeoutMs.
retries: 0,
})
} catch (err) {
if (!isLockedError(err)) throw err
lastError = err
}
const remainingMs = timeoutMs - (Date.now() - startedAt)
if (remainingMs <= 0) break
await Bun.sleep(
Math.min(
remainingMs,
nextRetryDelay(retryMinTimeoutMs, retryMaxTimeoutMs, options.randomize),
),
)
}
throw new ProcessLockTimeoutError(name, lockPath, timeoutMs, lastError)
}
function sanitizeLockName(name: string): string {
const safeName = name
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^[.-]+|[.-]+$/g, '')
if (!safeName) throw new Error('Process lock name must not be empty')
return safeName
}
function isLockedError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
err.code === 'ELOCKED'
)
}
function nextRetryDelay(
minTimeoutMs: number,
maxTimeoutMs: number,
randomize = true,
): number {
if (maxTimeoutMs <= minTimeoutMs) return minTimeoutMs
if (!randomize) return minTimeoutMs
return (
minTimeoutMs + Math.floor(Math.random() * (maxTimeoutMs - minTimeoutMs))
)
}

View File

@@ -30,8 +30,36 @@ export class ContainerCliError extends VmError {
command: string,
public readonly exitCode: number,
public readonly stderr: string,
message = `${command} failed with exit code ${exitCode}: ${stderr}`,
) {
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
super(message)
}
}
export class ContainerNameInUseError extends ContainerCliError {
constructor(
public readonly containerName: string,
command: string,
exitCode: number,
stderr: string,
) {
super(
command,
exitCode,
stderr,
`${command} failed because container name "${containerName}" is already in use: ${stderr}`,
)
}
}
export class ContainerNameReleaseTimeoutError extends VmError {
constructor(
public readonly containerName: string,
public readonly timeoutMs: number,
) {
super(
`Timed out waiting ${timeoutMs}ms for container name "${containerName}" to be released`,
)
}
}

View File

@@ -9,8 +9,10 @@ import {
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',
@@ -36,6 +38,10 @@ describe('ContainerRuntime', () => {
{ force: true },
undefined,
)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_NAME_RELEASE_WAIT,
)
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
'openclaw',
undefined,
@@ -68,6 +74,62 @@ describe('ContainerRuntime', () => {
)
})
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'
@@ -152,6 +214,45 @@ describe('ContainerRuntime', () => {
{ 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 () => {
@@ -257,6 +358,7 @@ function createDeps() {
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) => {

View File

@@ -737,6 +737,77 @@ describe('OpenClawService', () => {
expect(probe).toHaveBeenCalledTimes(2)
})
it('serializes start across service instances sharing an OpenClaw dir', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({
gateway: {
auth: {
token: 'cli-token',
},
},
}),
)
let gatewayReady = false
let releaseStartGateway!: () => void
let notifyStartGatewayEntered!: () => void
const startGatewayEntered = new Promise<void>((resolve) => {
notifyStartGatewayEntered = resolve
})
const unblockStartGateway = new Promise<void>((resolve) => {
releaseStartGateway = resolve
})
const firstEnsureReady = mock(async () => {})
const secondEnsureReady = mock(async () => {})
const startGateway = mock(async () => {
notifyStartGatewayEntered()
await unblockStartGateway
gatewayReady = true
})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
const firstService = new OpenClawService() as MutableOpenClawService
const secondService = new OpenClawService() as MutableOpenClawService
firstService.openclawDir = tempDir
secondService.openclawDir = tempDir
firstService.runtime = {
ensureReady: firstEnsureReady,
isReady: async () => gatewayReady,
isGatewayCurrent: async () => true,
startGateway,
waitForReady,
}
secondService.runtime = {
ensureReady: secondEnsureReady,
isReady: async () => gatewayReady,
isGatewayCurrent: async () => true,
startGateway,
waitForReady,
}
firstService.cliClient = { probe }
secondService.cliClient = { probe }
mockGatewayAuth()
const firstStart = firstService.start()
await startGatewayEntered
const secondStart = secondService.start()
await Bun.sleep(25)
const secondEnteredBeforeFirstFinished = secondEnsureReady.mock.calls.length
releaseStartGateway()
await Promise.all([firstStart, secondStart])
expect(secondEnteredBeforeFirstFinished).toBe(0)
expect(firstEnsureReady).toHaveBeenCalledTimes(1)
expect(secondEnsureReady).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledTimes(1)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(2)
})
it('does not restart a ready gateway when start is called again', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })

View File

@@ -4,10 +4,20 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises'
import { join } from 'node:path'
import { ContainerCli } from '../../../src/lib/container/container-cli'
import { ContainerCliError } from '../../../src/lib/vm/errors'
import {
ContainerCliError,
ContainerNameInUseError,
} from '../../../src/lib/vm/errors'
import { fakeSsh } from '../../__helpers__/fake-ssh'
describe('ContainerCli', () => {
@@ -163,6 +173,92 @@ describe('ContainerCli', () => {
)
})
it('inspects a container by name', async () => {
const sshPath = await fakeSsh(
{
stdout: JSON.stringify({
ID: 'abc123',
Name: 'gateway',
Config: { Image: 'openclaw:v1' },
State: { Status: 'running', Running: true },
}),
},
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.inspectContainer('gateway')).resolves.toEqual({
id: 'abc123',
name: 'gateway',
image: 'openclaw:v1',
status: 'running',
running: true,
})
await expect(readFile(logPath, 'utf8')).resolves.toContain(
"lima-browseros-vm 'nerdctl' 'container' 'inspect' '--format' '{{json .}}' 'gateway'",
)
})
it('returns null when inspected containers are absent', async () => {
const sshPath = await fakeSsh(
{ stderr: 'no such container', exit: 1 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.inspectContainer('gateway')).resolves.toBeNull()
})
it('does not treat unrelated not found errors as absent containers', async () => {
const sshPath = await fakeSsh(
{ stderr: 'network interface not found', exit: 1 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.inspectContainer('gateway')).rejects.toBeInstanceOf(
ContainerCliError,
)
})
it('waits until a container name is no longer resolvable', async () => {
const sshPath = await fakeSshContainerExistsThenMissing(tempDir, logPath)
const cli = await createCli(sshPath, tempDir)
await expect(
cli.waitForContainerNameRelease('gateway', {
timeoutMs: 500,
intervalMs: 5,
}),
).resolves.toBeUndefined()
const inspectCalls = (await readFile(logPath, 'utf8'))
.split('\n')
.filter((line) => line.includes("'container' 'inspect'"))
expect(inspectCalls).toHaveLength(2)
})
it('classifies create name-store collisions as name-in-use errors', async () => {
const sshPath = await fakeSsh(
{
stderr:
'name-store error\nname "gateway" is already used by ID "abc123"',
exit: 1,
},
logPath,
)
const cli = await createCli(sshPath, tempDir)
const error = await cli
.createContainer({ name: 'gateway', image: 'openclaw:v1' })
.catch((err) => err)
expect(error).toBeInstanceOf(ContainerNameInUseError)
expect(error.containerName).toBe('gateway')
expect(error.stderr).toContain('name "gateway" is already used')
})
it('tolerates removal when the container is already absent', async () => {
const sshPath = await fakeSsh(
{ stderr: 'no such container', exit: 1 },
@@ -215,3 +311,31 @@ function sshConfigPath(tempDir: string): string {
function sshPrefix(configPath: string): string {
return `ARGS:-F ${configPath} lima-browseros-vm`
}
async function fakeSshContainerExistsThenMissing(
tempDir: string,
logPath: string,
): Promise<string> {
const path = join(tempDir, 'ssh-container-exists-then-missing')
const counterPath = join(tempDir, 'ssh-container-exists-then-missing.count')
const body = `#!/usr/bin/env bash
set -u
echo "ARGS:$*" >> "${logPath}"
count="$(cat "${counterPath}" 2>/dev/null || echo 0)"
next=$((count + 1))
printf '%s' "$next" > "${counterPath}"
case "$count" in
0)
printf '{"ID":"abc123","Name":"gateway","Config":{"Image":"openclaw:v1"},"State":{"Status":"exited","Running":false}}'
exit 0
;;
*)
echo "no such container" >&2
exit 1
;;
esac
`
await writeFile(path, body)
await chmod(path, 0o755)
return path
}

View File

@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
ProcessLockTimeoutError,
resolveProcessLockPath,
withProcessLock,
} from '../../src/lib/process-lock'
describe('process-lock', () => {
let tempDir: string
let lockDir: string
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'process-lock-'))
lockDir = join(tempDir, '.locks')
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
})
it('serializes concurrent callers for the same lock name', async () => {
const events: string[] = []
let releaseFirst!: () => void
const firstMayFinish = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const first = withProcessLock(
'openclaw-lifecycle',
{ lockDir },
async () => {
events.push('first:start')
await firstMayFinish
events.push('first:end')
},
)
while (!events.includes('first:start')) await Bun.sleep(1)
const second = withProcessLock(
'openclaw-lifecycle',
{
lockDir,
retryMinTimeoutMs: 5,
retryMaxTimeoutMs: 5,
},
async () => {
events.push('second')
},
)
await Bun.sleep(25)
expect(events).toEqual(['first:start'])
releaseFirst()
await Promise.all([first, second])
expect(events).toEqual(['first:start', 'first:end', 'second'])
})
it('releases the lock when the callback throws', async () => {
await expect(
withProcessLock('openclaw-lifecycle', { lockDir }, async () => {
throw new Error('boom')
}),
).rejects.toThrow('boom')
await expect(
withProcessLock('openclaw-lifecycle', { lockDir }, async () => 'ok'),
).resolves.toBe('ok')
})
it('fails with a structured timeout error when acquisition takes too long', async () => {
let releaseFirst!: () => void
const firstMayFinish = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const first = withProcessLock(
'openclaw-lifecycle',
{ lockDir },
async () => {
await firstMayFinish
},
)
await Bun.sleep(10)
try {
await expect(
withProcessLock(
'openclaw-lifecycle',
{
lockDir,
timeoutMs: 25,
retryMinTimeoutMs: 5,
retryMaxTimeoutMs: 5,
},
async () => undefined,
),
).rejects.toBeInstanceOf(ProcessLockTimeoutError)
} finally {
releaseFirst()
await first
}
})
it('sanitizes lock names into the lock directory', async () => {
const path = resolveProcessLockPath(lockDir, '../OpenClaw Lifecycle!')
expect(path).toBe(join(lockDir, 'OpenClaw-Lifecycle.lock'))
await withProcessLock(
'../OpenClaw Lifecycle!',
{ lockDir },
async () => undefined,
)
const entries = await readdir(lockDir)
expect(entries).not.toContain('..')
})
})

View File

@@ -16,7 +16,6 @@
"globals": "^16.4.0",
"lefthook": "^2.0.12",
"picocolors": "^1.1.1",
"rimraf": "^6.0.1",
"typedoc": "^0.28.15",
"typescript": "^5.9.2",
},
@@ -196,6 +195,7 @@
"klavis": "^2.15.0",
"pino": "^9.6.0",
"posthog-node": "^4.17.0",
"proper-lockfile": "^4.1.2",
"puppeteer-core": "24.23.0",
"ws": "^8.18.0",
"zod": "^3.24.2",
@@ -205,6 +205,7 @@
"@types/bun": "1.3.5",
"@types/debug": "^4.1.12",
"@types/node": "^24.3.3",
"@types/proper-lockfile": "^4.1.4",
"@types/sinon": "^21.0.0",
"@types/ws": "^8.5.13",
"async-mutex": "^0.5.0",
@@ -1829,12 +1830,16 @@
"@types/pg-pool": ["@types/pg-pool@2.0.7", "", { "dependencies": { "@types/pg": "*" } }, "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng=="],
"@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="],
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/request": ["@types/request@2.48.13", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="],
"@types/retry": ["@types/retry@0.12.5", "", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="],
"@types/sinon": ["@types/sinon@21.0.0", "", { "dependencies": { "@types/sinonjs__fake-timers": "*" } }, "sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw=="],
"@types/sinonjs__fake-timers": ["@types/sinonjs__fake-timers@15.0.1", "", {}, "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w=="],
@@ -2669,7 +2674,7 @@
"giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="],
"glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -3103,7 +3108,7 @@
"lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="],
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
@@ -3479,7 +3484,7 @@
"path-root-regex": ["path-root-regex@0.1.2", "", {}, "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ=="],
"path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
"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=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
@@ -3569,6 +3574,8 @@
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
@@ -3829,13 +3836,15 @@
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"retry-request": ["retry-request@7.0.2", "", { "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" } }, "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rimraf": ["rimraf@6.1.2", "", { "dependencies": { "glob": "^13.0.0", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
@@ -3921,7 +3930,7 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"signedsource": ["signedsource@1.0.0", "", {}, "sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww=="],
@@ -4415,8 +4424,6 @@
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-exporter-base": "0.203.0", "@opentelemetry/otlp-transformer": "0.203.0", "@opentelemetry/sdk-logs": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-s0hys1ljqlMTbXx2XiplmMJg9wG570Z5lH7wMvrZX6lcODI56sG4HL03jklF63tBeyNwK2RV1/ntXGo3HgG4Qw=="],
"@google/gemini-cli-core/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"@google/gemini-cli-core/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@google/gemini-cli-core/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -4491,6 +4498,8 @@
"@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
@@ -4791,8 +4800,6 @@
"@sentry/bundler-plugin-core/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"@sentry/bundler-plugin-core/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="],
"@sentry/node/@opentelemetry/core": ["@opentelemetry/core@2.4.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-KtcyFHssTn5ZgDu6SXmUznS80OFs/wN7y6MyFRRcKU6TOw8hNcGxKvt8hsdaLJfhzUszNSjURetq5Qpkad14Gw=="],
@@ -4885,6 +4892,8 @@
"eventid/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
@@ -4895,6 +4904,8 @@
"find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"fx-runner/commander": ["commander@2.9.0", "", { "dependencies": { "graceful-readlink": ">= 1.0.0" } }, "sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A=="],
@@ -4913,8 +4924,6 @@
"giget/nypm": ["nypm@0.6.4", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw=="],
"glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"global-agent/serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
"global-directory/ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="],
@@ -4935,8 +4944,6 @@
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -5051,6 +5058,8 @@
"read-pkg/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"sinon/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
@@ -5351,8 +5360,6 @@
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw=="],
"@google/gemini-cli-core/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=="],
"@google/gemini-cli-core/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@google/gemini-cli-core/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
@@ -5529,8 +5536,6 @@
"@prisma/instrumentation/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
"@sentry/bundler-plugin-core/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=="],
"@sentry/node/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.210.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CMtLxp+lYDriveZejpBND/2TmadrrhUfChyxzmkFtHaMDdSKfP59MAYyA0ICBvEBdm3iXwLcaj/8Ic/pnGw9Yg=="],
"@sentry/node/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
@@ -5565,8 +5570,6 @@
"giget/nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"global-agent/serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"graphql-config/@graphql-tools/url-loader/@graphql-tools/executor-graphql-ws": ["@graphql-tools/executor-graphql-ws@2.0.7", "", { "dependencies": { "@graphql-tools/executor-common": "^0.0.6", "@graphql-tools/utils": "^10.9.1", "@whatwg-node/disposablestack": "^0.0.6", "graphql-ws": "^6.0.6", "isomorphic-ws": "^5.0.0", "tslib": "^2.8.1", "ws": "^8.18.3" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-J27za7sKF6RjhmvSOwOQFeNhNHyP4f4niqPnerJmq73OtLx9Y2PGOhkXOEB0PjhvPJceuttkD2O1yMgEkTGs3Q=="],
@@ -5761,24 +5764,16 @@
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw=="],
"@google/gemini-cli-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@google/genai/google-auth-library/gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@google/genai/google-auth-library/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"@google/genai/google-auth-library/gaxios/rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@types/request/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"fx-runner/which/is-absolute/is-relative": ["is-relative@0.1.3", "", {}, "sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"graphql-config/@graphql-tools/url-loader/@graphql-tools/executor-graphql-ws/@graphql-tools/executor-common": ["@graphql-tools/executor-common@0.0.6", "", { "dependencies": { "@envelop/core": "^5.3.0", "@graphql-tools/utils": "^10.9.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow=="],
"graphql-config/@graphql-tools/url-loader/@graphql-tools/executor-http/@graphql-hive/signal": ["@graphql-hive/signal@1.0.0", "", {}, "sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag=="],
@@ -5831,8 +5826,6 @@
"@google/genai/google-auth-library/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"graphql-config/@graphql-tools/url-loader/@graphql-tools/wrap/@graphql-tools/delegate/@graphql-tools/batch-execute": ["@graphql-tools/batch-execute@9.0.19", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA=="],
"publish-browser-extension/listr2/cli-truncate/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -5844,9 +5837,5 @@
"@browseros/build-tools/@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=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
}
}

View File

@@ -12,10 +12,12 @@
"dev:watch": "./tools/dev/run.sh watch",
"dev:watch:new": "./tools/dev/run.sh watch --new",
"dev:manual": "./tools/dev/run.sh watch --manual",
"dev:setup": "./tools/dev/setup.sh",
"dev:setup": "./tools/dev/run.sh setup",
"dev:cleanup": "./tools/dev/run.sh cleanup",
"dev:reset": "./tools/dev/run.sh reset",
"install:browseros-dogfood": "make -C tools/dogfood install",
"test:env": "./tools/dev/run.sh test",
"test:cleanup": "./tools/dev/run.sh cleanup",
"test:cleanup": "./tools/dev/run.sh cleanup --quick --yes",
"start:server": "bun run --filter @browseros/server --elide-lines=0 start",
"start:agent": "bun run --filter @browseros/agent dev",
"build": "bun run build:server && bun run build:agent",
@@ -34,8 +36,7 @@
"lint": "bunx biome check",
"lint:fix": "bunx biome check --write --unsafe",
"gen:cdp": "bun scripts/codegen/cdp-protocol.ts",
"generate:models": "bun scripts/generate-models.ts",
"clean": "rimraf dist"
"generate:models": "bun scripts/generate-models.ts"
},
"repository": "browseros-ai/BrowserOS-server",
"author": "BrowserOS",
@@ -56,7 +57,6 @@
"globals": "^16.4.0",
"lefthook": "^2.0.12",
"picocolors": "^1.1.1",
"rimraf": "^6.0.1",
"typedoc": "^0.28.15",
"typescript": "^5.9.2"
},

View File

@@ -1,7 +1,10 @@
package cmd
import (
"bufio"
"fmt"
"io"
"os"
"time"
"browseros-dev/proc"
@@ -12,44 +15,88 @@ import (
var cleanupCmd = &cobra.Command{
Use: "cleanup",
Short: "Kill port processes and remove orphaned temp directories",
Long: "Kills processes on dev/test ports and removes orphaned browseros-* temp directories.",
Long: "Stops old dev watch processes, clears dev/test ports, and removes orphaned browseros-* temp directories.",
RunE: runCleanup,
}
var (
cleanupPorts bool
cleanupTemps bool
cleanupQuick bool
cleanupYes bool
)
type safeCleanupOptions struct {
ports bool
temps bool
}
func init() {
cleanupCmd.Flags().BoolVar(&cleanupPorts, "ports", false, "Only kill port processes")
cleanupCmd.Flags().BoolVar(&cleanupTemps, "temps", false, "Only remove temp directories")
cleanupCmd.Flags().BoolVar(&cleanupQuick, "quick", false, "Run safe cleanup only")
cleanupCmd.Flags().BoolVar(&cleanupYes, "yes", false, "Answer yes to the safe cleanup prompt")
rootCmd.AddCommand(cleanupCmd)
}
// runCleanup performs the non-destructive daily cleanup path for local dev.
func runCleanup(cmd *cobra.Command, args []string) error {
doPorts := !cleanupTemps || cleanupPorts
doTemps := !cleanupPorts || cleanupTemps
out := cmd.OutOrStdout()
if !cleanupYes && !cleanupQuick {
ok, err := confirmYesNo(out, bufio.NewReader(os.Stdin), resetPrompt{
Title: "Run safe cleanup?",
Body: "Stops old dev watch processes, clears dev ports, and removes temporary /tmp browser profiles. This does not touch ~/.browseros-dev, Lima, containers, images, or saved dev data.",
Action: "Run safe cleanup",
})
if err != nil {
return err
}
if !ok {
fmt.Fprintln(out, dimStyle.Sprint("Skipped."))
return nil
}
}
return runSafeCleanup(out, safeCleanupOptions{
ports: !cleanupTemps || cleanupPorts,
temps: !cleanupPorts || cleanupTemps,
})
}
if doPorts {
// runSafeCleanup is shared by cleanup and reset before any destructive repair steps.
func runSafeCleanup(out io.Writer, opts safeCleanupOptions) error {
if opts.ports {
ports := proc.DefaultLocalPorts()
proc.LogMsgf(proc.TagInfo, "Killing processes on ports %d, %d, %d...", ports.CDP, ports.Server, ports.Extension)
stopped, err := proc.StopAllWatchProcesses(3 * time.Second)
if err != nil {
return err
}
if stopped > 0 {
fmt.Fprintf(out, "%s stopped %d old dev watch process group(s)\n", successStyle.Sprint("Stopped:"), stopped)
}
killedBrowsers, err := proc.KillBrowserProcessesForDevProfiles(3 * time.Second)
if err != nil {
return err
}
if killedBrowsers > 0 {
fmt.Fprintf(out, "%s stopped %d BrowserOS dev/test profile process(es)\n", successStyle.Sprint("Stopped:"), killedBrowsers)
}
fmt.Fprintf(out, "%s ports %d, %d, %d\n", labelStyle.Sprint("Clearing:"), ports.CDP, ports.Server, ports.Extension)
if err := proc.KillPortsAndWait(ports, 3*time.Second); err != nil {
return err
}
proc.LogMsg(proc.TagInfo, "Ports cleared")
fmt.Fprintln(out, successStyle.Sprint("Ports cleared."))
}
if doTemps {
if opts.temps {
n := proc.CleanupTempDirs("browseros-test-", "browseros-dev-")
if n > 0 {
proc.LogMsgf(proc.TagInfo, "Removed %d temp directories", n)
fmt.Fprintf(out, "%s removed %d temp directories\n", successStyle.Sprint("Removed:"), n)
} else {
proc.LogMsg(proc.TagInfo, "No orphaned temp directories found")
fmt.Fprintln(out, dimStyle.Sprint("No orphaned temp directories found."))
}
}
fmt.Println()
proc.LogMsg(proc.TagInfo, "Cleanup complete")
fmt.Fprintln(out)
fmt.Fprintln(out, successStyle.Sprint("Cleanup complete."))
return nil
}

View File

@@ -0,0 +1,134 @@
package cmd
import (
"bufio"
"bytes"
"os"
"strings"
"testing"
)
func TestConfirmYesNoDefaultsNoAndExplainsAction(t *testing.T) {
var out bytes.Buffer
prompt := resetPrompt{
Title: "Stop VM?",
Body: "This shuts down browseros-vm. Data stays on disk.",
Action: "Stop browseros-vm",
}
ok, err := confirmYesNo(&out, bufio.NewReader(strings.NewReader("\n")), prompt)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected empty answer to default to no")
}
text := out.String()
for _, want := range []string{
"Stop VM?",
"This shuts down browseros-vm. Data stays on disk.",
"Stop browseros-vm",
"[y/N]",
} {
if !strings.Contains(text, want) {
t.Fatalf("missing %q in prompt:\n%s", want, text)
}
}
}
func TestConfirmTypedRequiresExactToken(t *testing.T) {
var out bytes.Buffer
ok, err := confirmTyped(
&out,
bufio.NewReader(strings.NewReader("delete\nDELETE\n")),
"Delete dev profile?",
"This removes ~/.browseros-dev.",
"DELETE",
)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected exact token to confirm")
}
text := out.String()
if !strings.Contains(text, "Type DELETE to continue") {
t.Fatalf("missing typed confirmation instruction:\n%s", text)
}
if !strings.Contains(text, "Confirmation did not match") {
t.Fatalf("missing retry warning:\n%s", text)
}
}
func TestResetOverviewTellsUserToUseSmallestReset(t *testing.T) {
var out bytes.Buffer
printResetOverview(&out, devPaths{Root: "/Users/me/.browseros-dev"})
text := out.String()
for _, want := range []string{
"BrowserOS dev reset",
"Pick the smallest reset",
"/Users/me/.browseros-dev",
"Stop VM",
"Delete VM",
"Remove OpenClaw container",
"Remove OpenClaw image",
"Delete dev profile",
} {
if !strings.Contains(text, want) {
t.Fatalf("missing %q in overview:\n%s", want, text)
}
}
}
func TestParseLimaListOutputAcceptsSingleObject(t *testing.T) {
entries, err := parseLimaListOutput([]byte(`{"name":"browseros-vm","status":"Running"}`))
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name != "browseros-vm" || entries[0].Status != "Running" {
t.Fatalf("unexpected entries: %#v", entries)
}
}
func TestParseLimaListOutputAcceptsJSONLines(t *testing.T) {
entries, err := parseLimaListOutput([]byte("{\"name\":\"one\",\"status\":\"Stopped\"}\n{\"name\":\"browseros-vm\",\"status\":\"Running\"}\n"))
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 || entries[1].Name != "browseros-vm" || entries[1].Status != "Running" {
t.Fatalf("unexpected entries: %#v", entries)
}
}
func TestValidateDevProfileRootRejectsUnsafePaths(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/", home, "/etc"} {
if err := validateDevProfileRootForDeletion(path); err == nil {
t.Fatalf("expected %s to be rejected", path)
}
}
}
func TestLimactlShellArgsUseGuestWorkdir(t *testing.T) {
args := limactlShellArgs("sh", "-lc", "true")
want := []string{"shell", "--workdir", "/", "browseros-vm", "--", "sh", "-lc", "true"}
if strings.Join(args, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("expected %#v, got %#v", want, args)
}
}
func TestParsePodmanMachineList(t *testing.T) {
machines, err := parsePodmanMachineList([]byte(`[{"Name":"podman-machine-default","Running":true}]`))
if err != nil {
t.Fatal(err)
}
if len(machines) != 1 || machines[0].Name != "podman-machine-default" || !machines[0].Running {
t.Fatalf("unexpected machines: %#v", machines)
}
}

View File

@@ -0,0 +1,456 @@
package cmd
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
const (
devDirName = ".browseros-dev"
limaVMName = "browseros-vm"
openClawImage = "ghcr.io/openclaw/openclaw:2026.4.12"
openClawContainerName = "browseros-openclaw-openclaw-gateway-1"
openClawSetupContainer = openClawContainerName + "-setup"
)
var resetCmd = &cobra.Command{
Use: "reset",
Short: "Guide destructive BrowserOS dev profile and VM resets",
Long: "Walks through safe cleanup, VM shutdown/deletion, OpenClaw container/image removal, and full ~/.browseros-dev reset.",
RunE: runReset,
}
type devPaths struct {
Root string
LimaHome string
}
type resetPrompt struct {
Title string
Body string
Action string
}
type limaListEntry struct {
Name string `json:"name"`
Status string `json:"status"`
}
type podmanMachineEntry struct {
Name string `json:"Name"`
Running bool `json:"Running"`
}
func init() {
rootCmd.AddCommand(resetCmd)
}
// runReset walks developers through escalating reset options without hiding the blast radius.
func runReset(cmd *cobra.Command, args []string) error {
out := cmd.OutOrStdout()
reader := bufio.NewReader(os.Stdin)
paths, err := resolveDevPaths()
if err != nil {
return err
}
printResetOverview(out, paths)
if ok, err := confirmYesNo(out, reader, resetPrompt{
Title: "Run safe cleanup first?",
Body: "This stops old dev watch processes, clears dev ports, and removes temporary /tmp browser profiles. It does not touch saved dev data.",
Action: "Run safe cleanup",
}); err != nil {
return err
} else if ok {
if err := runSafeCleanup(out, safeCleanupOptions{ports: true, temps: true}); err != nil {
return err
}
}
limactlPath, err := exec.LookPath("limactl")
if err != nil {
fmt.Fprintf(out, "%s Lima CLI not found; VM and OpenClaw reset steps are unavailable. Install with %s.\n", warnStyle.Sprint("Skipping:"), commandStyle.Sprint("brew install lima"))
if err := maybeResetLegacyPodman(out, reader); err != nil {
return err
}
return maybeDeleteDevProfile(out, reader, paths)
}
vm, err := findVM(limactlPath, paths.LimaHome)
if err != nil {
fmt.Fprintf(out, "%s could not inspect Lima VMs: %v\n", warnStyle.Sprint("Warning:"), err)
if err := maybeResetLegacyPodman(out, reader); err != nil {
return err
}
return maybeDeleteDevProfile(out, reader, paths)
}
if vm == nil {
fmt.Fprintf(out, "%s %s was not found in %s.\n", dimStyle.Sprint("Not found:"), limaVMName, pathStyle.Sprint(paths.LimaHome))
if err := maybeResetLegacyPodman(out, reader); err != nil {
return err
}
return maybeDeleteDevProfile(out, reader, paths)
}
fmt.Fprintf(out, "%s %s %s\n", labelStyle.Sprint("Found VM:"), commandStyle.Sprint(vm.Name), dimStyle.Sprintf("(%s)", vm.Status))
if strings.EqualFold(vm.Status, "Running") {
if err := maybeResetOpenClaw(out, reader, limactlPath, paths.LimaHome); err != nil {
return err
}
if ok, err := confirmYesNo(out, reader, resetPrompt{
Title: "Stop VM?",
Body: "This shuts down browseros-vm. The VM, containers, images, and profile data stay on disk.",
Action: "Stop browseros-vm",
}); err != nil {
return err
} else if ok {
if err := runLimactl(out, limactlPath, paths.LimaHome, "stop", limaVMName); err != nil {
return err
}
fmt.Fprintln(out, successStyle.Sprint("VM stopped."))
vm.Status = "Stopped"
}
} else {
fmt.Fprintln(out, dimStyle.Sprint("OpenClaw container/image reset needs the VM running; skipping those steps."))
}
if ok, err := confirmYesNo(out, reader, resetPrompt{
Title: "Delete VM?",
Body: "This deletes the Lima VM and its container store. ~/.browseros-dev remains. OpenClaw will be pulled again next time.",
Action: "Delete browseros-vm",
}); err != nil {
return err
} else if ok {
if err := runLimactl(out, limactlPath, paths.LimaHome, "delete", "--force", limaVMName); err != nil {
return err
}
fmt.Fprintln(out, successStyle.Sprint("VM deleted."))
}
if err := maybeResetLegacyPodman(out, reader); err != nil {
return err
}
return maybeDeleteDevProfile(out, reader, paths)
}
func resolveDevPaths() (devPaths, error) {
if override := strings.TrimSpace(os.Getenv("BROWSEROS_DIR")); override != "" {
root, err := filepath.Abs(override)
if err != nil {
return devPaths{}, err
}
return devPaths{Root: root, LimaHome: filepath.Join(root, "lima")}, nil
}
home, err := os.UserHomeDir()
if err != nil {
return devPaths{}, err
}
root := filepath.Join(home, devDirName)
return devPaths{Root: root, LimaHome: filepath.Join(root, "lima")}, nil
}
func printResetOverview(out io.Writer, paths devPaths) {
fmt.Fprintln(out, headerStyle.Sprint("BrowserOS dev reset"))
fmt.Fprintln(out)
fmt.Fprintf(out, "This can reset parts of %s. Pick the smallest reset that matches the problem.\n", pathStyle.Sprint(paths.Root))
fmt.Fprintln(out)
fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Stop VM:"), dimStyle.Sprint("Shuts down browseros-vm. Keeps data."))
fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Delete VM:"), dimStyle.Sprint("Removes Lima/container state. Keeps the dev profile."))
fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Remove OpenClaw container:"), dimStyle.Sprint("Keeps the downloaded OpenClaw image."))
fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Remove OpenClaw image:"), dimStyle.Sprint("Next startup pulls it again."))
fmt.Fprintf(out, " %s %s\n", warnStyle.Sprint("Delete dev profile:"), dimStyle.Sprint("Deletes the dev profile root and dev-local BrowserOS data."))
fmt.Fprintln(out)
}
func confirmYesNo(out io.Writer, r *bufio.Reader, prompt resetPrompt) (bool, error) {
fmt.Fprintln(out, labelStyle.Sprint(prompt.Title))
fmt.Fprintln(out, prompt.Body)
if prompt.Action != "" {
fmt.Fprintf(out, "%s %s\n", labelStyle.Sprint("Action:"), commandStyle.Sprint(prompt.Action))
}
fmt.Fprint(out, labelStyle.Sprint("Continue?")+" [y/N]: ")
line, err := r.ReadString('\n')
if err != nil && len(line) == 0 {
return false, err
}
line = strings.TrimSpace(strings.ToLower(line))
fmt.Fprintln(out)
return line == "y" || line == "yes", nil
}
func confirmTyped(out io.Writer, r *bufio.Reader, title string, body string, token string) (bool, error) {
fmt.Fprintln(out, warnStyle.Sprint(title))
fmt.Fprintln(out, body)
for {
fmt.Fprintf(out, "%s %s %s: ", labelStyle.Sprint("Type"), commandStyle.Sprint(token), labelStyle.Sprint("to continue"))
line, err := r.ReadString('\n')
if err != nil && len(line) == 0 {
return false, err
}
if strings.TrimSpace(line) == token {
fmt.Fprintln(out)
return true, nil
}
if strings.TrimSpace(line) == "" {
fmt.Fprintln(out)
return false, nil
}
fmt.Fprintln(out, warnStyle.Sprint("Confirmation did not match. Press Enter to skip or try again."))
}
}
func maybeResetOpenClaw(out io.Writer, reader *bufio.Reader, limactlPath string, limaHome string) error {
if ok, err := confirmYesNo(out, reader, resetPrompt{
Title: "Remove OpenClaw container?",
Body: "This removes the current gateway/setup containers. The downloaded OpenClaw image stays in the VM.",
Action: "nerdctl rm -f " + openClawContainerName + " " + openClawSetupContainer,
}); err != nil {
return err
} else if ok {
script := fmt.Sprintf(
"nerdctl rm -f %s %s >/dev/null 2>&1 || true",
openClawContainerName,
openClawSetupContainer,
)
if err := runInVM(out, limactlPath, limaHome, "sh", "-lc", script); err != nil {
return err
}
fmt.Fprintln(out, successStyle.Sprint("OpenClaw containers removed if present."))
}
if ok, err := confirmYesNo(out, reader, resetPrompt{
Title: "Remove OpenClaw image?",
Body: "This deletes ghcr.io/openclaw/openclaw:2026.4.12 from the VM. Next startup pulls it again.",
Action: "nerdctl image rm " + openClawImage,
}); err != nil {
return err
} else if ok {
script := fmt.Sprintf("nerdctl image rm %s >/dev/null 2>&1 || true", openClawImage)
if err := runInVM(out, limactlPath, limaHome, "sh", "-lc", script); err != nil {
return err
}
fmt.Fprintln(out, successStyle.Sprint("OpenClaw image removed if present."))
}
return nil
}
func maybeDeleteDevProfile(out io.Writer, reader *bufio.Reader, paths devPaths) error {
ok, err := confirmTyped(
out,
reader,
"Delete dev profile?",
fmt.Sprintf("This deletes %s. It removes BrowserOS dev data plus VM/OpenClaw state.", pathStyle.Sprint(paths.Root)),
"DELETE",
)
if err != nil || !ok {
return err
}
if err := validateDevProfileRootForDeletion(paths.Root); err != nil {
return err
}
if err := os.RemoveAll(paths.Root); err != nil {
return err
}
fmt.Fprintf(out, "%s %s\n", successStyle.Sprint("Deleted:"), pathStyle.Sprint(paths.Root))
return nil
}
func maybeResetLegacyPodman(out io.Writer, reader *bufio.Reader) error {
podmanPath, err := exec.LookPath("podman")
if err != nil {
return nil
}
machines, err := listPodmanMachines(podmanPath)
if err != nil {
fmt.Fprintf(out, "%s could not inspect legacy Podman machines: %v\n", warnStyle.Sprint("Warning:"), err)
return nil
}
if len(machines) == 0 {
return nil
}
fmt.Fprintln(out, headerStyle.Sprint("Legacy Podman VM cleanup"))
fmt.Fprintln(out, "BrowserOS used Podman before the Lima VM runtime. These machines are legacy for this dev flow.")
for _, machine := range machines {
state := "Stopped"
if machine.Running {
state = "Running"
}
fmt.Fprintf(out, " %s %s\n", commandStyle.Sprint(machine.Name), dimStyle.Sprintf("(%s)", state))
}
fmt.Fprintln(out, dimStyle.Sprint("Future reset flows can add more legacy cleanup checks here."))
fmt.Fprintln(out)
for i := range machines {
machine := machines[i]
if machine.Running {
if ok, err := confirmYesNo(out, reader, resetPrompt{
Title: "Stop legacy Podman machine?",
Body: fmt.Sprintf("This stops legacy Podman machine %s. It does not delete the machine.", machine.Name),
Action: "podman machine stop " + machine.Name,
}); err != nil {
return err
} else if ok {
if err := runCommand(out, podmanPath, "machine", "stop", machine.Name); err != nil {
return err
}
fmt.Fprintf(out, "%s %s\n", successStyle.Sprint("Stopped:"), commandStyle.Sprint(machine.Name))
machines[i].Running = false
}
}
if ok, err := confirmYesNo(out, reader, resetPrompt{
Title: "Delete legacy Podman machine?",
Body: fmt.Sprintf("This deletes legacy Podman machine %s. Use this when cleaning up the old VM runtime.", machine.Name),
Action: "podman machine rm --force " + machine.Name,
}); err != nil {
return err
} else if ok {
if err := runCommand(out, podmanPath, "machine", "rm", "--force", machine.Name); err != nil {
return err
}
fmt.Fprintf(out, "%s %s\n", successStyle.Sprint("Deleted:"), commandStyle.Sprint(machine.Name))
}
}
return nil
}
func listPodmanMachines(podmanPath string) ([]podmanMachineEntry, error) {
cmd := exec.Command(podmanPath, "machine", "ls", "--format", "json")
output, err := cmd.Output()
if err != nil {
return nil, err
}
return parsePodmanMachineList(output)
}
func parsePodmanMachineList(output []byte) ([]podmanMachineEntry, error) {
if strings.TrimSpace(string(output)) == "" {
return nil, nil
}
var machines []podmanMachineEntry
if err := json.Unmarshal(output, &machines); err != nil {
return nil, err
}
return machines, nil
}
func validateDevProfileRootForDeletion(root string) error {
cleanRoot, err := filepath.Abs(root)
if err != nil {
return err
}
if cleanRoot == string(filepath.Separator) {
return fmt.Errorf("refusing to delete filesystem root")
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
cleanHome, err := filepath.Abs(home)
if err != nil {
return err
}
if cleanRoot == cleanHome {
return fmt.Errorf("refusing to delete home directory %s", cleanRoot)
}
if !isPathInside(cleanRoot, cleanHome) {
return fmt.Errorf("refusing to delete path outside home directory: %s", cleanRoot)
}
return nil
}
func isPathInside(path string, parent string) bool {
rel, err := filepath.Rel(parent, path)
if err != nil {
return false
}
return rel != "." && rel != "" && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
}
func findVM(limactlPath string, limaHome string) (*limaListEntry, error) {
cmd := limactlCommand(limactlPath, limaHome, "list", "--format", "json")
output, err := cmd.Output()
if err != nil {
return nil, err
}
entries, err := parseLimaListOutput(output)
if err != nil {
return nil, err
}
for i := range entries {
if entries[i].Name == limaVMName {
return &entries[i], nil
}
}
return nil, nil
}
func parseLimaListOutput(output []byte) ([]limaListEntry, error) {
trimmed := strings.TrimSpace(string(output))
if trimmed == "" {
return nil, nil
}
var entries []limaListEntry
if err := json.Unmarshal([]byte(trimmed), &entries); err == nil {
return entries, nil
}
var single limaListEntry
if err := json.Unmarshal([]byte(trimmed), &single); err == nil {
return []limaListEntry{single}, nil
}
for _, line := range strings.Split(trimmed, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var entry limaListEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
return nil, err
}
entries = append(entries, entry)
}
return entries, nil
}
func runLimactl(out io.Writer, limactlPath string, limaHome string, args ...string) error {
cmd := limactlCommand(limactlPath, limaHome, args...)
cmd.Stdout = out
cmd.Stderr = out
return cmd.Run()
}
func runInVM(out io.Writer, limactlPath string, limaHome string, args ...string) error {
shellArgs := limactlShellArgs(args...)
return runLimactl(out, limactlPath, limaHome, shellArgs...)
}
func limactlShellArgs(args ...string) []string {
return append([]string{"shell", "--workdir", "/", limaVMName, "--"}, args...)
}
func limactlCommand(limactlPath string, limaHome string, args ...string) *exec.Cmd {
cmd := exec.Command(limactlPath, args...)
cmd.Env = append(os.Environ(), "LIMA_HOME="+limaHome)
return cmd
}
func runCommand(out io.Writer, path string, args ...string) error {
cmd := exec.Command(path, args...)
cmd.Stdout = out
cmd.Stderr = out
return cmd.Run()
}

View File

@@ -0,0 +1,81 @@
package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"browseros-dev/proc"
"github.com/spf13/cobra"
)
var setupIfNeeded bool
const setupModeIfNeeded = true
var setupCmd = &cobra.Command{
Use: "setup",
Short: "Install dev dependencies and generate required code",
Long: "Installs Bun dependencies and generates agent GraphQL code needed by the dev environment.",
RunE: func(cmd *cobra.Command, args []string) error {
root, err := proc.FindMonorepoRoot()
if err != nil {
return err
}
return runDevSetup(cmd.Context(), root, setupIfNeeded)
},
}
type setupPlan struct {
RunInstall bool
RunCodegen bool
}
func init() {
setupCmd.Flags().BoolVar(&setupIfNeeded, "if-needed", false, "Skip generated code refresh when it already exists")
rootCmd.AddCommand(setupCmd)
}
func buildSetupPlan(root string, ifNeeded bool) setupPlan {
return setupPlan{
RunInstall: true,
RunCodegen: !ifNeeded || !generatedGraphQLExists(root),
}
}
func generatedGraphQLExists(root string) bool {
for _, file := range []string{"gql.ts", "graphql.ts", "schema.graphql"} {
info, err := os.Stat(filepath.Join(root, "apps/agent/generated/graphql", file))
if err != nil || info.IsDir() {
return false
}
}
return true
}
// runDevSetup prepares the repo for local development. Dependency install always
// runs because Bun is fast and this keeps watch resilient after branch changes.
func runDevSetup(ctx context.Context, root string, ifNeeded bool) error {
plan := buildSetupPlan(root, ifNeeded)
if plan.RunInstall {
proc.LogMsg(proc.TagSetup, "Installing dependencies...")
if err := proc.RunBlocking(ctx, root, proc.TagSetup, "bun", "install", "--frozen-lockfile"); err != nil {
return fmt.Errorf("installing dependencies: %w", err)
}
}
if plan.RunCodegen {
proc.LogMsg(proc.TagSetup, "Generating agent code...")
if err := proc.RunBlocking(ctx, root, proc.TagSetup, "bun", "run", "codegen:agent"); err != nil {
return fmt.Errorf("generating agent code: %w", err)
}
} else {
proc.LogMsg(proc.TagSetup, "Agent code already generated")
}
proc.LogMsg(proc.TagSetup, "Setup ready")
return nil
}

View File

@@ -0,0 +1,76 @@
package cmd
import (
"os"
"path/filepath"
"testing"
)
func TestBuildSetupPlanAlwaysInstallsDependencies(t *testing.T) {
root := t.TempDir()
plan := buildSetupPlan(root, true)
if !plan.RunInstall {
t.Fatal("expected dependency install to always run")
}
}
func TestBuildSetupPlanIfNeededSkipsExistingGeneratedGraphQL(t *testing.T) {
root := t.TempDir()
writeGeneratedGraphQLSentinels(t, root)
plan := buildSetupPlan(root, true)
if plan.RunCodegen {
t.Fatal("expected --if-needed setup to skip codegen when generated GraphQL exists")
}
}
func TestBuildSetupPlanIfNeededRunsCodegenWhenGeneratedGraphQLEmpty(t *testing.T) {
root := t.TempDir()
generatedDir := filepath.Join(root, "apps/agent/generated/graphql")
if err := os.MkdirAll(generatedDir, 0o755); err != nil {
t.Fatal(err)
}
plan := buildSetupPlan(root, true)
if !plan.RunCodegen {
t.Fatal("expected --if-needed setup to run codegen when generated GraphQL is empty")
}
}
func TestBuildSetupPlanIfNeededRunsCodegenWhenGeneratedGraphQLMissing(t *testing.T) {
root := t.TempDir()
plan := buildSetupPlan(root, true)
if !plan.RunCodegen {
t.Fatal("expected --if-needed setup to run codegen when generated GraphQL is missing")
}
}
func TestBuildSetupPlanExplicitSetupRunsCodegen(t *testing.T) {
root := t.TempDir()
writeGeneratedGraphQLSentinels(t, root)
plan := buildSetupPlan(root, false)
if !plan.RunCodegen {
t.Fatal("expected explicit setup to refresh codegen")
}
}
func writeGeneratedGraphQLSentinels(t *testing.T, root string) {
t.Helper()
generatedDir := filepath.Join(root, "apps/agent/generated/graphql")
if err := os.MkdirAll(generatedDir, 0o755); err != nil {
t.Fatal(err)
}
for _, file := range []string{"gql.ts", "graphql.ts", "schema.graphql"} {
if err := os.WriteFile(filepath.Join(generatedDir, file), []byte("generated"), 0o644); err != nil {
t.Fatal(err)
}
}
}

View File

@@ -0,0 +1,13 @@
package cmd
import "github.com/fatih/color"
var (
headerStyle = color.New(color.Bold, color.FgCyan)
commandStyle = color.New(color.FgHiGreen)
successStyle = color.New(color.FgGreen, color.Bold)
warnStyle = color.New(color.FgYellow, color.Bold)
labelStyle = color.New(color.Bold)
pathStyle = color.New(color.FgCyan)
dimStyle = color.New(color.Faint)
)

View File

@@ -48,6 +48,26 @@ func runWatch(cmd *cobra.Command, args []string) error {
p := defaultPorts
var reservations *proc.PortReservations
userDataDir := "/tmp/browseros-dev"
mode := "watch"
if watchManual {
mode = "manual"
}
var runLock *proc.WatchRunLock
acquireRunLock := func(ports proc.Ports) error {
lock, stopped, err := proc.AcquireWatchRunLock(proc.WatchRunIdentity{
Mode: mode,
Profile: userDataDir,
Ports: ports,
}, 3*time.Second)
if err != nil {
return err
}
runLock = lock
if stopped {
proc.LogMsgf(proc.TagInfo, "Stopped existing dev watch for profile %s", userDataDir)
}
return nil
}
if watchNew {
proc.LogMsg(proc.TagInfo, "Selecting random available ports...")
@@ -62,17 +82,16 @@ func runWatch(cmd *cobra.Command, args []string) error {
}
userDataDir = dir
proc.LogMsgf(proc.TagInfo, "Created fresh profile: %s", userDataDir)
if err := acquireRunLock(p); err != nil {
return err
}
} else {
if err := os.MkdirAll(userDataDir, 0o755); err != nil {
return fmt.Errorf("creating user-data dir: %w", err)
}
stopped, err := proc.StopExistingWatchProcesses(3 * time.Second)
if err != nil {
if err := acquireRunLock(p); err != nil {
return err
}
if stopped > 0 {
proc.LogMsgf(proc.TagInfo, "Stopped %d existing dev watch process group(s)", stopped)
}
proc.LogMsg(proc.TagInfo, "Killing processes on preferred ports...")
if err := proc.KillPortsAndWait(defaultPorts, 3*time.Second); err != nil {
return err
@@ -89,13 +108,18 @@ func runWatch(cmd *cobra.Command, args []string) error {
p.CDP, p.Server, p.Extension)
}
}
defer func() {
if err := runLock.Close(); err != nil {
proc.LogMsgf(proc.TagInfo, "Warning: closing run lock: %v", err)
}
}()
defer reservations.ReleaseAll()
fmt.Println()
mode := "watch"
if watchManual {
mode = "manual"
if err := runDevSetup(cmd.Context(), root, setupModeIfNeeded); err != nil {
return err
}
fmt.Println()
proc.LogMsgf(proc.TagInfo, "Mode: %s", proc.BoldColor.Sprint(mode))
proc.LogMsgf(proc.TagInfo, "Ports: CDP=%d Server=%d Extension=%d", p.CDP, p.Server, p.Extension)
proc.LogMsgf(proc.TagInfo, "Profile: %s", userDataDir)

View File

@@ -14,6 +14,7 @@ type Tag struct {
var (
TagBuild = Tag{"build", color.New(color.FgYellow)}
TagSetup = Tag{"setup", color.New(color.FgHiYellow)}
TagAgent = Tag{"agent", color.New(color.FgMagenta)}
TagServer = Tag{"server", color.New(color.FgCyan)}
TagBrowser = Tag{"browser", color.New(color.FgBlue)}

View File

@@ -1,7 +1,12 @@
package proc
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
@@ -11,24 +16,134 @@ import (
"time"
)
// StopExistingWatchProcesses terminates older default-profile watch supervisors.
// Port cleanup cannot see a previous watch process while it is still waiting
// for CDP, but that process will wake up later and race the new supervisor.
func StopExistingWatchProcesses(timeout time.Duration) (int, error) {
currentPGID, err := syscall.Getpgid(0)
var errWatchRunLocked = errors.New("dev watch run is already locked")
const maxTCPPort = 65535
type WatchRunIdentity struct {
Mode string `json:"mode"`
Profile string `json:"profile"`
Ports Ports `json:"ports"`
}
type WatchRunState struct {
PID int `json:"pid"`
PGID int `json:"pgid"`
StartedAt time.Time `json:"started_at"`
Identity WatchRunIdentity `json:"identity"`
}
type WatchRunLock struct {
file *os.File
statePath string
}
type watchRunPathsResult struct {
Lock string
State string
}
// AcquireWatchRunLock claims ownership of the current dev watch identity.
// If the same run identity is already active, it terminates the recorded
// process group from the state file and waits for the OS lock to be released.
func AcquireWatchRunLock(identity WatchRunIdentity, timeout time.Duration) (*WatchRunLock, bool, error) {
baseDir, err := DefaultWatchRunBaseDir()
if err != nil {
return 0, fmt.Errorf("reading current process group: %w", err)
return nil, false, err
}
return AcquireWatchRunLockInDir(baseDir, identity, timeout)
}
// AcquireWatchRunLockInDir is AcquireWatchRunLock with an explicit base
// directory so tests can exercise flock behavior without touching user state.
func AcquireWatchRunLockInDir(baseDir string, identity WatchRunIdentity, timeout time.Duration) (*WatchRunLock, bool, error) {
identity = normalizeWatchRunIdentity(identity)
if err := validateWatchRunIdentity(identity); err != nil {
return nil, false, err
}
if baseDir == "" {
return nil, false, fmt.Errorf("watch run base dir is empty")
}
groups, err := currentWatchProcessGroups(currentPGID)
paths := watchRunPaths(baseDir, identity)
lock, err := tryAcquireWatchRunLock(paths.Lock, paths.State)
if err == nil {
if err := lock.writeState(identity); err != nil {
lock.Close()
return nil, false, err
}
return lock, false, nil
}
if !errors.Is(err, errWatchRunLocked) {
return nil, false, err
}
state, err := readWatchRunStateWithRetry(paths.State, 250*time.Millisecond)
if err != nil {
return nil, false, fmt.Errorf("dev watch lock is held but state is unreadable at %s: %w", paths.State, err)
}
if state.Identity != identity {
return nil, false, fmt.Errorf("dev watch lock state identity mismatch at %s", paths.State)
}
if state.PGID <= 0 {
return nil, false, fmt.Errorf("dev watch lock state is missing a process group at %s", paths.State)
}
if err := signalProcessGroup(state.PGID, syscall.SIGTERM); err != nil {
return nil, false, err
}
lock, err = waitForWatchRunLock(paths, identity, timeout)
if err == nil {
return lock, true, nil
}
if !errors.Is(err, errWatchRunLocked) {
return nil, false, err
}
if err := signalProcessGroup(state.PGID, syscall.SIGKILL); err != nil {
return nil, false, err
}
lock, err = waitForWatchRunLock(paths, identity, time.Second)
if err != nil {
if errors.Is(err, errWatchRunLocked) {
return nil, false, fmt.Errorf("previous dev watch process group %d did not exit after SIGKILL; inspect %s before retrying", state.PGID, paths.Lock)
}
return nil, false, err
}
return lock, true, nil
}
// DefaultWatchRunBaseDir returns the shared location for dev watch lock files.
// Individual runs are separated by a hash of profile, ports, and mode.
func DefaultWatchRunBaseDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".browseros-dev", "runs"), nil
}
// StopAllWatchProcesses terminates every recorded dev watch run.
func StopAllWatchProcesses(timeout time.Duration) (int, error) {
baseDir, err := DefaultWatchRunBaseDir()
if err != nil {
return 0, err
}
if len(groups) == 0 {
return StopAllWatchProcessesInDir(baseDir, timeout)
}
// StopAllWatchProcessesInDir is StopAllWatchProcesses with an explicit state directory for tests.
func StopAllWatchProcessesInDir(baseDir string, timeout time.Duration) (int, error) {
pgids, err := liveWatchRunPGIDs(baseDir)
if err != nil {
return 0, err
}
if len(pgids) == 0 {
return 0, nil
}
for _, pgid := range groups {
for _, pgid := range pgids {
if err := signalProcessGroup(pgid, syscall.SIGTERM); err != nil {
return 0, err
}
@@ -36,12 +151,9 @@ func StopExistingWatchProcesses(timeout time.Duration) (int, error) {
deadline := time.Now().Add(timeout)
for {
remaining, err := currentWatchProcessGroups(currentPGID)
if err != nil {
return 0, err
}
remaining := livePGIDs(pgids)
if len(remaining) == 0 {
return len(groups), nil
return len(pgids), nil
}
if time.Now().After(deadline) {
for _, pgid := range remaining {
@@ -49,68 +161,290 @@ func StopExistingWatchProcesses(timeout time.Duration) (int, error) {
return 0, err
}
}
return len(groups), nil
return len(pgids), nil
}
time.Sleep(100 * time.Millisecond)
}
}
func currentWatchProcessGroups(currentPGID int) ([]int, error) {
// KillBrowserProcessesForDevProfiles kills BrowserOS instances using temporary dev/test profiles.
func KillBrowserProcessesForDevProfiles(timeout time.Duration) (int, error) {
pids, err := currentBrowserProfilePIDs()
if err != nil {
return 0, err
}
if len(pids) == 0 {
return 0, nil
}
for _, pid := range pids {
if err := signalProcess(pid, syscall.SIGTERM); err != nil {
return 0, err
}
}
deadline := time.Now().Add(timeout)
for {
remaining, err := currentBrowserProfilePIDs()
if err != nil {
return 0, err
}
if len(remaining) == 0 {
return len(pids), nil
}
if time.Now().After(deadline) {
for _, pid := range remaining {
if err := signalProcess(pid, syscall.SIGKILL); err != nil {
return 0, err
}
}
return len(pids), nil
}
time.Sleep(100 * time.Millisecond)
}
}
func (l *WatchRunLock) Close() error {
if l == nil || l.file == nil {
return nil
}
// Keep the lock file path stable. Unlinking it during handoff can let
// another opener lock a different inode while an owner still holds this one.
removeErr := os.Remove(l.statePath)
unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
closeErr := l.file.Close()
l.file = nil
if removeErr != nil && !os.IsNotExist(removeErr) {
return removeErr
}
if unlockErr != nil {
return unlockErr
}
return closeErr
}
// ReadWatchRunState reads the metadata used to terminate a previous owner.
// The state file is not the lock; it is only trusted after flock says a run is active.
func ReadWatchRunState(path string) (WatchRunState, error) {
data, err := os.ReadFile(path)
if err != nil {
return WatchRunState{}, err
}
var state WatchRunState
if err := json.Unmarshal(data, &state); err != nil {
return WatchRunState{}, fmt.Errorf("parse watch run state: %w", err)
}
return state, nil
}
func readWatchRunStateWithRetry(path string, timeout time.Duration) (WatchRunState, error) {
deadline := time.Now().Add(timeout)
var lastErr error
for {
state, err := ReadWatchRunState(path)
if err == nil {
return state, nil
}
lastErr = err
if time.Now().After(deadline) {
return WatchRunState{}, lastErr
}
time.Sleep(50 * time.Millisecond)
}
}
func liveWatchRunPGIDs(baseDir string) ([]int, error) {
statePaths, err := filepath.Glob(filepath.Join(baseDir, "watch-*.json"))
if err != nil {
return nil, err
}
seen := map[int]struct{}{}
for _, statePath := range statePaths {
state, err := ReadWatchRunState(statePath)
if err != nil || state.PGID <= 0 || !processGroupLive(state.PGID) {
continue
}
seen[state.PGID] = struct{}{}
}
pgids := make([]int, 0, len(seen))
for pgid := range seen {
pgids = append(pgids, pgid)
}
sort.Ints(pgids)
return pgids, nil
}
func livePGIDs(pgids []int) []int {
remaining := make([]int, 0, len(pgids))
for _, pgid := range pgids {
if processGroupLive(pgid) {
remaining = append(remaining, pgid)
}
}
return remaining
}
func processGroupLive(pgid int) bool {
if pgid <= 0 {
return false
}
err := syscall.Kill(-pgid, 0)
return err == nil || err == syscall.EPERM
}
func currentBrowserProfilePIDs() ([]int, error) {
output, err := exec.Command("ps", "-axo", "pid=,pgid=,command=").Output()
if err != nil {
return nil, fmt.Errorf("listing processes: %w", err)
}
return watchProcessGroupsFromPS(string(output), currentPGID), nil
return browserProfilePIDsFromPS(string(output)), nil
}
func watchProcessGroupsFromPS(output string, currentPGID int) []int {
seen := map[int]struct{}{}
func browserProfilePIDsFromPS(output string) []int {
var pids []int
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
pgid, err := strconv.Atoi(fields[1])
if err != nil || pgid == currentPGID {
pid, err := strconv.Atoi(fields[0])
if err != nil {
continue
}
if isDefaultWatchCommand(fields[2:]) {
seen[pgid] = struct{}{}
command := strings.Join(fields[2:], " ")
if isDevBrowserProcess(command) {
pids = append(pids, pid)
}
}
groups := make([]int, 0, len(seen))
for pgid := range seen {
groups = append(groups, pgid)
}
sort.Ints(groups)
return groups
sort.Ints(pids)
return pids
}
func isDefaultWatchCommand(commandFields []string) bool {
if len(commandFields) < 2 {
func isDevBrowserProcess(command string) bool {
if !strings.Contains(command, "BrowserOS.app/Contents/MacOS/BrowserOS") {
return false
}
if filepath.Base(commandFields[0]) != "browseros-dev" {
return false
return strings.Contains(command, "--user-data-dir=/tmp/browseros-dev") ||
strings.Contains(command, "browseros-dev-") ||
strings.Contains(command, "browseros-test-")
}
func watchRunPaths(baseDir string, identity WatchRunIdentity) watchRunPathsResult {
identity = normalizeWatchRunIdentity(identity)
sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%d\x00%d",
identity.Mode,
identity.Profile,
identity.Ports.CDP,
identity.Ports.Server,
identity.Ports.Extension,
)))
key := hex.EncodeToString(sum[:])
return watchRunPathsResult{
Lock: filepath.Join(baseDir, "watch-"+key+".lock"),
State: filepath.Join(baseDir, "watch-"+key+".json"),
}
if commandFields[1] != "watch" {
return false
}
func normalizeWatchRunIdentity(identity WatchRunIdentity) WatchRunIdentity {
identity.Profile = filepath.Clean(identity.Profile)
return identity
}
func tryAcquireWatchRunLock(lockPath string, statePath string) (*WatchRunLock, error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
return nil, err
}
for _, field := range commandFields[2:] {
if field == "--new" {
return false
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
file.Close()
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
return nil, errWatchRunLocked
}
return nil, err
}
return true
return &WatchRunLock{file: file, statePath: statePath}, nil
}
func (l *WatchRunLock) writeState(identity WatchRunIdentity) error {
pgid, err := syscall.Getpgid(0)
if err != nil {
return fmt.Errorf("reading current process group: %w", err)
}
state := WatchRunState{
PID: os.Getpid(),
PGID: pgid,
StartedAt: time.Now(),
Identity: identity,
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
tmp := l.statePath + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, l.statePath)
}
func waitForWatchRunLock(paths watchRunPathsResult, identity WatchRunIdentity, timeout time.Duration) (*WatchRunLock, error) {
deadline := time.Now().Add(timeout)
for {
lock, err := tryAcquireWatchRunLock(paths.Lock, paths.State)
if err == nil {
if err := lock.writeState(identity); err != nil {
lock.Close()
return nil, err
}
return lock, nil
}
if !errors.Is(err, errWatchRunLocked) {
return nil, err
}
if time.Now().After(deadline) {
return nil, errWatchRunLocked
}
time.Sleep(100 * time.Millisecond)
}
}
func validateWatchRunIdentity(identity WatchRunIdentity) error {
if identity.Mode == "" {
return fmt.Errorf("watch run mode is empty")
}
if identity.Profile == "" {
return fmt.Errorf("watch run profile is empty")
}
if !isValidTCPPort(identity.Ports.CDP) || !isValidTCPPort(identity.Ports.Server) || !isValidTCPPort(identity.Ports.Extension) {
return fmt.Errorf("watch run ports are invalid: %+v", identity.Ports)
}
return nil
}
func isValidTCPPort(port int) bool {
return port > 0 && port <= maxTCPPort
}
func signalProcessGroup(pgid int, signal syscall.Signal) error {
if pgid <= 0 {
return nil
return fmt.Errorf("invalid process group %d", pgid)
}
if err := syscall.Kill(-pgid, signal); err != nil && err != syscall.ESRCH {
return fmt.Errorf("signaling process group %d: %w", pgid, err)
}
return nil
}
func signalProcess(pid int, signal syscall.Signal) error {
if pid <= 0 {
return fmt.Errorf("invalid process %d", pid)
}
if err := syscall.Kill(pid, signal); err != nil && err != syscall.ESRCH {
return fmt.Errorf("signaling process %d: %w", pid, err)
}
return nil
}

View File

@@ -1,32 +1,204 @@
package proc
import "testing"
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"
)
func TestWatchProcessGroupsFromPSSelectsOtherWatchGroups(t *testing.T) {
output := `
111 111 /tmp/one/browseros-dev watch
222 222 /tmp/two/browseros-dev watch --new
333 333 /tmp/one/browseros-dev cleanup
444 444 rg browseros-dev watch
555 555 bun run dev:watch
`
const watchLockHelperEnv = "BROWSEROS_DEV_WATCH_LOCK_HELPER"
groups := watchProcessGroupsFromPS(output, 999)
func TestMain(m *testing.M) {
if os.Getenv(watchLockHelperEnv) == "1" {
runWatchLockHelper()
return
}
os.Exit(m.Run())
}
if len(groups) != 1 || groups[0] != 111 {
t.Fatalf("expected only pgid 111, got %#v", groups)
func TestWatchRunPathsStableAndDistinct(t *testing.T) {
baseDir := t.TempDir()
identity := WatchRunIdentity{
Mode: "watch",
Profile: "/tmp/browseros-dev",
Ports: Ports{CDP: 9005, Server: 9105, Extension: 9305},
}
first := watchRunPaths(baseDir, identity)
second := watchRunPaths(baseDir, identity)
if first != second {
t.Fatalf("expected stable paths, got %#v and %#v", first, second)
}
withDifferentPort := identity
withDifferentPort.Ports.Server = 9106
third := watchRunPaths(baseDir, withDifferentPort)
if third.Lock == first.Lock || third.State == first.State {
t.Fatalf("expected distinct paths for different ports, got %#v and %#v", first, third)
}
}
func TestWatchProcessGroupsFromPSDedupesProcessGroups(t *testing.T) {
func TestBrowserProfilePIDsFromPSSelectsOnlyDevAndTestProfiles(t *testing.T) {
output := `
111 111 /tmp/one/browseros-dev watch
112 111 /tmp/one/browseros-dev watch
111 111 /Applications/BrowserOS.app/Contents/MacOS/BrowserOS --user-data-dir=/tmp/browseros-dev
222 222 /Applications/BrowserOS.app/Contents/MacOS/BrowserOS --user-data-dir=/tmp/browseros-dev-abcd
333 333 /Applications/BrowserOS.app/Contents/MacOS/BrowserOS --user-data-dir=/var/folders/x/browseros-test-abcd
444 444 /Applications/BrowserOS.app/Contents/MacOS/BrowserOS --user-data-dir=/Users/me/Library/Application Support/BrowserOS
555 555 rg browseros-test-
`
groups := watchProcessGroupsFromPS(output, 999)
pids := browserProfilePIDsFromPS(output)
if len(groups) != 1 || groups[0] != 111 {
t.Fatalf("expected one pgid 111, got %#v", groups)
if len(pids) != 3 || pids[0] != 111 || pids[1] != 222 || pids[2] != 333 {
t.Fatalf("expected dev/test browser pids, got %#v", pids)
}
}
func TestAcquireWatchRunLockWritesStateAndReleases(t *testing.T) {
baseDir := t.TempDir()
identity := WatchRunIdentity{
Mode: "watch",
Profile: "/tmp/browseros-dev",
Ports: Ports{CDP: 9005, Server: 9105, Extension: 9305},
}
lock, stopped, err := AcquireWatchRunLockInDir(baseDir, identity, time.Second)
if err != nil {
t.Fatalf("AcquireWatchRunLockInDir returned error: %v", err)
}
if stopped {
t.Fatal("expected first acquisition not to stop another run")
}
paths := watchRunPaths(baseDir, identity)
state, err := ReadWatchRunState(paths.State)
if err != nil {
t.Fatalf("ReadWatchRunState returned error: %v", err)
}
if state.PID != os.Getpid() {
t.Fatalf("expected state PID %d, got %d", os.Getpid(), state.PID)
}
if state.PGID <= 0 {
t.Fatalf("expected positive PGID, got %d", state.PGID)
}
if state.Identity != identity {
t.Fatalf("expected identity %#v, got %#v", identity, state.Identity)
}
if err := lock.Close(); err != nil {
t.Fatalf("closing lock: %v", err)
}
if _, err := os.Stat(paths.State); !os.IsNotExist(err) {
t.Fatalf("expected state file to be removed on close, got %v", err)
}
if _, err := os.Stat(paths.Lock); err != nil {
t.Fatalf("expected lock file path to remain reusable, got %v", err)
}
lock, stopped, err = AcquireWatchRunLockInDir(baseDir, identity, time.Second)
if err != nil {
t.Fatalf("reacquiring lock returned error: %v", err)
}
if stopped {
t.Fatal("expected reacquisition after close not to stop another run")
}
if err := lock.Close(); err != nil {
t.Fatalf("closing reacquired lock: %v", err)
}
}
func TestAcquireWatchRunLockRejectsInvalidPorts(t *testing.T) {
identity := WatchRunIdentity{
Mode: "watch",
Profile: "/tmp/browseros-dev",
Ports: Ports{CDP: 9005, Server: 65536, Extension: 9305},
}
if _, _, err := AcquireWatchRunLockInDir(t.TempDir(), identity, time.Second); err == nil {
t.Fatal("expected invalid port error")
}
}
func TestAcquireWatchRunLockStopsExistingOwnerByStatePGID(t *testing.T) {
baseDir := t.TempDir()
readyPath := filepath.Join(baseDir, "ready")
identity := WatchRunIdentity{
Mode: "watch",
Profile: "/tmp/browseros-dev",
Ports: Ports{CDP: 9005, Server: 9105, Extension: 9305},
}
identityJSON, err := json.Marshal(identity)
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=TestMain")
cmd.Env = append(os.Environ(),
watchLockHelperEnv+"=1",
"BROWSEROS_DEV_WATCH_LOCK_BASE="+baseDir,
"BROWSEROS_DEV_WATCH_LOCK_READY="+readyPath,
"BROWSEROS_DEV_WATCH_LOCK_IDENTITY="+string(identityJSON),
)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
t.Fatalf("starting helper: %v", err)
}
defer cmd.Process.Kill()
waitForFile(t, readyPath, 3*time.Second)
lock, stopped, err := AcquireWatchRunLockInDir(baseDir, identity, 3*time.Second)
if err != nil {
t.Fatalf("AcquireWatchRunLockInDir returned error: %v", err)
}
defer lock.Close()
if !stopped {
t.Fatal("expected takeover to stop existing owner")
}
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("expected helper process to exit after takeover")
}
}
func runWatchLockHelper() {
baseDir := os.Getenv("BROWSEROS_DEV_WATCH_LOCK_BASE")
readyPath := os.Getenv("BROWSEROS_DEV_WATCH_LOCK_READY")
var identity WatchRunIdentity
if err := json.Unmarshal([]byte(os.Getenv("BROWSEROS_DEV_WATCH_LOCK_IDENTITY")), &identity); err != nil {
os.Exit(2)
}
lock, _, err := AcquireWatchRunLockInDir(baseDir, identity, time.Second)
if err != nil {
os.Exit(3)
}
defer lock.Close()
if err := os.WriteFile(readyPath, []byte("ready\n"), 0o644); err != nil {
os.Exit(4)
}
time.Sleep(30 * time.Second)
}
func waitForFile(t *testing.T, path string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for {
if _, err := os.Stat(path); err == nil {
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", path)
}
time.Sleep(50 * time.Millisecond)
}
}

View File

@@ -2,14 +2,4 @@
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$DIR/../.." && pwd)"
cd "$ROOT"
echo "[setup] Installing dependencies..."
bun install --frozen-lockfile
echo "[setup] Generating agent code..."
bun run codegen:agent
echo "[setup] Ready"
exec "$DIR/run.sh" setup "$@"