mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
5 Commits
fix/debug-
...
fix/dev-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd7c985e3 | ||
|
|
548c9dd996 | ||
|
|
d38b01a8c7 | ||
|
|
ff36c8412b | ||
|
|
fd5aba249b |
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
130
packages/browseros-agent/apps/server/src/lib/process-lock.ts
Normal file
130
packages/browseros-agent/apps/server/src/lib/process-lock.ts
Normal 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))
|
||||
)
|
||||
}
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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('..')
|
||||
})
|
||||
})
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
134
packages/browseros-agent/tools/dev/cmd/cleanup_test.go
Normal file
134
packages/browseros-agent/tools/dev/cmd/cleanup_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
456
packages/browseros-agent/tools/dev/cmd/reset.go
Normal file
456
packages/browseros-agent/tools/dev/cmd/reset.go
Normal 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()
|
||||
}
|
||||
81
packages/browseros-agent/tools/dev/cmd/setup.go
Normal file
81
packages/browseros-agent/tools/dev/cmd/setup.go
Normal 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
|
||||
}
|
||||
76
packages/browseros-agent/tools/dev/cmd/setup_test.go
Normal file
76
packages/browseros-agent/tools/dev/cmd/setup_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
packages/browseros-agent/tools/dev/cmd/style.go
Normal file
13
packages/browseros-agent/tools/dev/cmd/style.go
Normal 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)
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user