style: apply oxfmt formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0a2a7fbcd1
commit
dc41f74e72
@ -1,23 +1,32 @@
|
|||||||
import dns from "node:dns/promises";
|
import dns from "node:dns/promises"
|
||||||
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js";
|
import {
|
||||||
import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js";
|
startBrowserBridgeServer,
|
||||||
|
stopBrowserBridgeServer,
|
||||||
|
} from "../../browser/bridge-server.js"
|
||||||
|
import {
|
||||||
|
type ResolvedBrowserConfig,
|
||||||
|
resolveProfile,
|
||||||
|
} from "../../browser/config.js"
|
||||||
import {
|
import {
|
||||||
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||||
} from "../../browser/constants.js";
|
} from "../../browser/constants.js"
|
||||||
import { BROWSER_BRIDGES } from "./browser-bridges.js";
|
import { BROWSER_BRIDGES } from "./browser-bridges.js"
|
||||||
import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
import {
|
||||||
|
DEFAULT_SANDBOX_BROWSER_IMAGE,
|
||||||
|
SANDBOX_AGENT_WORKSPACE_MOUNT,
|
||||||
|
} from "./constants.js"
|
||||||
import {
|
import {
|
||||||
buildSandboxCreateArgs,
|
buildSandboxCreateArgs,
|
||||||
dockerContainerState,
|
dockerContainerState,
|
||||||
execDocker,
|
execDocker,
|
||||||
readDockerPort,
|
readDockerPort,
|
||||||
remapPathForDinD,
|
remapPathForDinD,
|
||||||
} from "./docker.js";
|
} from "./docker.js"
|
||||||
import { updateBrowserRegistry } from "./registry.js";
|
import { updateBrowserRegistry } from "./registry.js"
|
||||||
import { slugifySessionKey } from "./shared.js";
|
import { slugifySessionKey } from "./shared.js"
|
||||||
import { isToolAllowed } from "./tool-policy.js";
|
import { isToolAllowed } from "./tool-policy.js"
|
||||||
import type { SandboxBrowserContext, SandboxConfig } from "./types.js";
|
import type { SandboxBrowserContext, SandboxConfig } from "./types.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a hostname to an IPv4 address for CDP connections.
|
* Resolve a hostname to an IPv4 address for CDP connections.
|
||||||
@ -27,57 +36,59 @@ import type { SandboxBrowserContext, SandboxConfig } from "./types.js";
|
|||||||
async function resolveHostToIp(host: string): Promise<string> {
|
async function resolveHostToIp(host: string): Promise<string> {
|
||||||
// If already an IP address (v4 or v6), return as-is
|
// If already an IP address (v4 or v6), return as-is
|
||||||
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(host) || host.includes(":")) {
|
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(host) || host.includes(":")) {
|
||||||
return host;
|
return host
|
||||||
}
|
}
|
||||||
// localhost is special-cased by Chrome
|
// localhost is special-cased by Chrome
|
||||||
if (host === "localhost") {
|
if (host === "localhost") {
|
||||||
return host;
|
return host
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await dns.lookup(host, { family: 4 });
|
const result = await dns.lookup(host, { family: 4 })
|
||||||
return result.address;
|
return result.address
|
||||||
} catch {
|
} catch {
|
||||||
// If DNS resolution fails, return original host and let caller handle the error
|
// If DNS resolution fails, return original host and let caller handle the error
|
||||||
return host;
|
return host
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForSandboxCdp(params: {
|
async function waitForSandboxCdp(params: {
|
||||||
cdpHost: string;
|
cdpHost: string
|
||||||
cdpPort: number;
|
cdpPort: number
|
||||||
timeoutMs: number;
|
timeoutMs: number
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const deadline = Date.now() + Math.max(0, params.timeoutMs);
|
const deadline = Date.now() + Math.max(0, params.timeoutMs)
|
||||||
// Resolve hostname to IP for Chrome CDP compatibility
|
// Resolve hostname to IP for Chrome CDP compatibility
|
||||||
const resolvedHost = await resolveHostToIp(params.cdpHost);
|
const resolvedHost = await resolveHostToIp(params.cdpHost)
|
||||||
const url = `http://${resolvedHost}:${params.cdpPort}/json/version`;
|
const url = `http://${resolvedHost}:${params.cdpPort}/json/version`
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController()
|
||||||
const t = setTimeout(() => ctrl.abort(), 1000);
|
const t = setTimeout(() => ctrl.abort(), 1000)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { signal: ctrl.signal });
|
const res = await fetch(url, { signal: ctrl.signal })
|
||||||
if (res.ok) return true;
|
if (res.ok) return true
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(t);
|
clearTimeout(t)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
await new Promise((r) => setTimeout(r, 150));
|
await new Promise((r) => setTimeout(r, 150))
|
||||||
}
|
}
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSandboxBrowserResolvedConfig(params: {
|
function buildSandboxBrowserResolvedConfig(params: {
|
||||||
controlPort: number;
|
controlPort: number
|
||||||
cdpHost: string;
|
cdpHost: string
|
||||||
cdpPort: number;
|
cdpPort: number
|
||||||
headless: boolean;
|
headless: boolean
|
||||||
evaluateEnabled: boolean;
|
evaluateEnabled: boolean
|
||||||
}): ResolvedBrowserConfig {
|
}): ResolvedBrowserConfig {
|
||||||
const isLoopback =
|
const isLoopback =
|
||||||
params.cdpHost === "127.0.0.1" || params.cdpHost === "localhost" || params.cdpHost === "::1";
|
params.cdpHost === "127.0.0.1" ||
|
||||||
|
params.cdpHost === "localhost" ||
|
||||||
|
params.cdpHost === "::1"
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
evaluateEnabled: params.evaluateEnabled,
|
evaluateEnabled: params.evaluateEnabled,
|
||||||
@ -96,120 +107,150 @@ function buildSandboxBrowserResolvedConfig(params: {
|
|||||||
profiles: {
|
profiles: {
|
||||||
clawd: { cdpPort: params.cdpPort, color: DEFAULT_CLAWD_BROWSER_COLOR },
|
clawd: { cdpPort: params.cdpPort, color: DEFAULT_CLAWD_BROWSER_COLOR },
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSandboxBrowserImage(image: string) {
|
async function ensureSandboxBrowserImage(image: string) {
|
||||||
const result = await execDocker(["image", "inspect", image], {
|
const result = await execDocker(["image", "inspect", image], {
|
||||||
allowFailure: true,
|
allowFailure: true,
|
||||||
});
|
})
|
||||||
if (result.code === 0) return;
|
if (result.code === 0) return
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`,
|
`Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureSandboxBrowser(params: {
|
export async function ensureSandboxBrowser(params: {
|
||||||
scopeKey: string;
|
scopeKey: string
|
||||||
workspaceDir: string;
|
workspaceDir: string
|
||||||
agentWorkspaceDir: string;
|
agentWorkspaceDir: string
|
||||||
cfg: SandboxConfig;
|
cfg: SandboxConfig
|
||||||
evaluateEnabled?: boolean;
|
evaluateEnabled?: boolean
|
||||||
}): Promise<SandboxBrowserContext | null> {
|
}): Promise<SandboxBrowserContext | null> {
|
||||||
if (!params.cfg.browser.enabled) return null;
|
if (!params.cfg.browser.enabled) return null
|
||||||
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
|
if (!isToolAllowed(params.cfg.tools, "browser")) return null
|
||||||
|
|
||||||
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey);
|
const slug =
|
||||||
const name = `${params.cfg.browser.containerPrefix}${slug}`;
|
params.cfg.scope === "shared"
|
||||||
const containerName = name.slice(0, 63);
|
? "shared"
|
||||||
const state = await dockerContainerState(containerName);
|
: slugifySessionKey(params.scopeKey)
|
||||||
|
const name = `${params.cfg.browser.containerPrefix}${slug}`
|
||||||
|
const containerName = name.slice(0, 63)
|
||||||
|
const state = await dockerContainerState(containerName)
|
||||||
if (!state.exists) {
|
if (!state.exists) {
|
||||||
await ensureSandboxBrowserImage(params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE);
|
await ensureSandboxBrowserImage(
|
||||||
|
params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
|
||||||
|
)
|
||||||
const args = buildSandboxCreateArgs({
|
const args = buildSandboxCreateArgs({
|
||||||
name: containerName,
|
name: containerName,
|
||||||
cfg: params.cfg.docker,
|
cfg: params.cfg.docker,
|
||||||
scopeKey: params.scopeKey,
|
scopeKey: params.scopeKey,
|
||||||
labels: { "moltbot.sandboxBrowser": "1" },
|
labels: { "moltbot.sandboxBrowser": "1" },
|
||||||
});
|
})
|
||||||
const mainMountSuffix =
|
const mainMountSuffix =
|
||||||
params.cfg.workspaceAccess === "ro" && params.workspaceDir === params.agentWorkspaceDir
|
params.cfg.workspaceAccess === "ro" &&
|
||||||
|
params.workspaceDir === params.agentWorkspaceDir
|
||||||
? ":ro"
|
? ":ro"
|
||||||
: "";
|
: ""
|
||||||
// Remap paths for Docker-in-Docker scenarios
|
// Remap paths for Docker-in-Docker scenarios
|
||||||
const hostWorkspaceDir = remapPathForDinD(params.workspaceDir);
|
const hostWorkspaceDir = remapPathForDinD(params.workspaceDir)
|
||||||
args.push("-v", `${hostWorkspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`);
|
args.push(
|
||||||
if (params.cfg.workspaceAccess !== "none" && params.workspaceDir !== params.agentWorkspaceDir) {
|
"-v",
|
||||||
const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : "";
|
`${hostWorkspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`,
|
||||||
const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir);
|
)
|
||||||
|
if (
|
||||||
|
params.cfg.workspaceAccess !== "none" &&
|
||||||
|
params.workspaceDir !== params.agentWorkspaceDir
|
||||||
|
) {
|
||||||
|
const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : ""
|
||||||
|
const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir)
|
||||||
args.push(
|
args.push(
|
||||||
"-v",
|
"-v",
|
||||||
`${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
|
`${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`);
|
args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`)
|
||||||
if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) {
|
if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) {
|
||||||
args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`);
|
args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`)
|
||||||
}
|
}
|
||||||
args.push("-e", `CLAWDBOT_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`);
|
args.push(
|
||||||
args.push("-e", `CLAWDBOT_BROWSER_ENABLE_NOVNC=${params.cfg.browser.enableNoVnc ? "1" : "0"}`);
|
"-e",
|
||||||
args.push("-e", `CLAWDBOT_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`);
|
`CLAWDBOT_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`,
|
||||||
args.push("-e", `CLAWDBOT_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`);
|
)
|
||||||
args.push("-e", `CLAWDBOT_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`);
|
args.push(
|
||||||
args.push(params.cfg.browser.image);
|
"-e",
|
||||||
await execDocker(args);
|
`CLAWDBOT_BROWSER_ENABLE_NOVNC=${
|
||||||
await execDocker(["start", containerName]);
|
params.cfg.browser.enableNoVnc ? "1" : "0"
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
args.push("-e", `CLAWDBOT_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`)
|
||||||
|
args.push("-e", `CLAWDBOT_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`)
|
||||||
|
args.push(
|
||||||
|
"-e",
|
||||||
|
`CLAWDBOT_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`,
|
||||||
|
)
|
||||||
|
args.push(params.cfg.browser.image)
|
||||||
|
await execDocker(args)
|
||||||
|
await execDocker(["start", containerName])
|
||||||
} else if (!state.running) {
|
} else if (!state.running) {
|
||||||
await execDocker(["start", containerName]);
|
await execDocker(["start", containerName])
|
||||||
}
|
}
|
||||||
|
|
||||||
const mappedCdp = await readDockerPort(containerName, params.cfg.browser.cdpPort);
|
const mappedCdp = await readDockerPort(
|
||||||
|
containerName,
|
||||||
|
params.cfg.browser.cdpPort,
|
||||||
|
)
|
||||||
if (!mappedCdp) {
|
if (!mappedCdp) {
|
||||||
throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`);
|
throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mappedNoVnc =
|
const mappedNoVnc =
|
||||||
params.cfg.browser.enableNoVnc && !params.cfg.browser.headless
|
params.cfg.browser.enableNoVnc && !params.cfg.browser.headless
|
||||||
? await readDockerPort(containerName, params.cfg.browser.noVncPort)
|
? await readDockerPort(containerName, params.cfg.browser.noVncPort)
|
||||||
: null;
|
: null
|
||||||
|
|
||||||
const existing = BROWSER_BRIDGES.get(params.scopeKey);
|
const existing = BROWSER_BRIDGES.get(params.scopeKey)
|
||||||
const existingProfile = existing ? resolveProfile(existing.bridge.state.resolved, "clawd") : null;
|
const existingProfile = existing
|
||||||
|
? resolveProfile(existing.bridge.state.resolved, "clawd")
|
||||||
|
: null
|
||||||
const shouldReuse =
|
const shouldReuse =
|
||||||
existing && existing.containerName === containerName && existingProfile?.cdpPort === mappedCdp;
|
existing &&
|
||||||
|
existing.containerName === containerName &&
|
||||||
|
existingProfile?.cdpPort === mappedCdp
|
||||||
if (existing && !shouldReuse) {
|
if (existing && !shouldReuse) {
|
||||||
await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined);
|
await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined)
|
||||||
BROWSER_BRIDGES.delete(params.scopeKey);
|
BROWSER_BRIDGES.delete(params.scopeKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridge = (() => {
|
const bridge = (() => {
|
||||||
if (shouldReuse && existing) return existing.bridge;
|
if (shouldReuse && existing) return existing.bridge
|
||||||
return null;
|
return null
|
||||||
})();
|
})()
|
||||||
|
|
||||||
const ensureBridge = async () => {
|
const ensureBridge = async () => {
|
||||||
if (bridge) return bridge;
|
if (bridge) return bridge
|
||||||
|
|
||||||
// Resolve hostname to IP for Chrome CDP compatibility
|
// Resolve hostname to IP for Chrome CDP compatibility
|
||||||
const resolvedCdpHost = await resolveHostToIp(params.cfg.browser.cdpHost);
|
const resolvedCdpHost = await resolveHostToIp(params.cfg.browser.cdpHost)
|
||||||
|
|
||||||
const onEnsureAttachTarget = params.cfg.browser.autoStart
|
const onEnsureAttachTarget = params.cfg.browser.autoStart
|
||||||
? async () => {
|
? async () => {
|
||||||
const state = await dockerContainerState(containerName);
|
const state = await dockerContainerState(containerName)
|
||||||
if (state.exists && !state.running) {
|
if (state.exists && !state.running) {
|
||||||
await execDocker(["start", containerName]);
|
await execDocker(["start", containerName])
|
||||||
}
|
}
|
||||||
const ok = await waitForSandboxCdp({
|
const ok = await waitForSandboxCdp({
|
||||||
cdpHost: resolvedCdpHost,
|
cdpHost: resolvedCdpHost,
|
||||||
cdpPort: mappedCdp,
|
cdpPort: mappedCdp,
|
||||||
timeoutMs: params.cfg.browser.autoStartTimeoutMs,
|
timeoutMs: params.cfg.browser.autoStartTimeoutMs,
|
||||||
});
|
})
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Sandbox browser CDP did not become reachable on ${resolvedCdpHost}:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`,
|
`Sandbox browser CDP did not become reachable on ${resolvedCdpHost}:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined
|
||||||
|
|
||||||
return await startBrowserBridgeServer({
|
return await startBrowserBridgeServer({
|
||||||
resolved: buildSandboxBrowserResolvedConfig({
|
resolved: buildSandboxBrowserResolvedConfig({
|
||||||
@ -217,21 +258,22 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
cdpHost: resolvedCdpHost,
|
cdpHost: resolvedCdpHost,
|
||||||
cdpPort: mappedCdp,
|
cdpPort: mappedCdp,
|
||||||
headless: params.cfg.browser.headless,
|
headless: params.cfg.browser.headless,
|
||||||
evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED,
|
evaluateEnabled:
|
||||||
|
params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||||
}),
|
}),
|
||||||
onEnsureAttachTarget,
|
onEnsureAttachTarget,
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
const resolvedBridge = await ensureBridge();
|
const resolvedBridge = await ensureBridge()
|
||||||
if (!shouldReuse) {
|
if (!shouldReuse) {
|
||||||
BROWSER_BRIDGES.set(params.scopeKey, {
|
BROWSER_BRIDGES.set(params.scopeKey, {
|
||||||
bridge: resolvedBridge,
|
bridge: resolvedBridge,
|
||||||
containerName,
|
containerName,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now()
|
||||||
await updateBrowserRegistry({
|
await updateBrowserRegistry({
|
||||||
containerName,
|
containerName,
|
||||||
sessionKey: params.scopeKey,
|
sessionKey: params.scopeKey,
|
||||||
@ -240,16 +282,18 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
image: params.cfg.browser.image,
|
image: params.cfg.browser.image,
|
||||||
cdpPort: mappedCdp,
|
cdpPort: mappedCdp,
|
||||||
noVncPort: mappedNoVnc ?? undefined,
|
noVncPort: mappedNoVnc ?? undefined,
|
||||||
});
|
})
|
||||||
|
|
||||||
const noVncUrl =
|
const noVncUrl =
|
||||||
mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless
|
mappedNoVnc &&
|
||||||
|
params.cfg.browser.enableNoVnc &&
|
||||||
|
!params.cfg.browser.headless
|
||||||
? `http://${params.cfg.browser.cdpHost}:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote`
|
? `http://${params.cfg.browser.cdpHost}:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote`
|
||||||
: undefined;
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bridgeUrl: resolvedBridge.baseUrl,
|
bridgeUrl: resolvedBridge.baseUrl,
|
||||||
noVncUrl,
|
noVncUrl,
|
||||||
containerName,
|
containerName,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,24 @@
|
|||||||
import os from "node:os";
|
import os from "node:os"
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process"
|
||||||
|
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js"
|
||||||
import { formatCliCommand } from "../../cli/command-format.js";
|
import { formatCliCommand } from "../../cli/command-format.js"
|
||||||
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
import {
|
||||||
import { readRegistry, updateRegistry } from "./registry.js";
|
DEFAULT_SANDBOX_IMAGE,
|
||||||
import { computeSandboxConfigHash } from "./config-hash.js";
|
SANDBOX_AGENT_WORKSPACE_MOUNT,
|
||||||
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
|
} from "./constants.js"
|
||||||
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
|
import { readRegistry, updateRegistry } from "./registry.js"
|
||||||
|
import { computeSandboxConfigHash } from "./config-hash.js"
|
||||||
|
import {
|
||||||
|
resolveSandboxAgentId,
|
||||||
|
resolveSandboxScopeKey,
|
||||||
|
slugifySessionKey,
|
||||||
|
} from "./shared.js"
|
||||||
|
import type {
|
||||||
|
SandboxConfig,
|
||||||
|
SandboxDockerConfig,
|
||||||
|
SandboxWorkspaceAccess,
|
||||||
|
} from "./types.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For Docker-in-Docker scenarios, remap container paths to host paths.
|
* For Docker-in-Docker scenarios, remap container paths to host paths.
|
||||||
@ -19,305 +30,334 @@ import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from
|
|||||||
* - CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR: host path for ~/clawd
|
* - CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR: host path for ~/clawd
|
||||||
*/
|
*/
|
||||||
export function remapPathForDinD(containerPath: string): string {
|
export function remapPathForDinD(containerPath: string): string {
|
||||||
const hostConfigDir = process.env.CLAWDBOT_SANDBOX_HOST_CONFIG_DIR;
|
const hostConfigDir = process.env.CLAWDBOT_SANDBOX_HOST_CONFIG_DIR
|
||||||
const hostWorkspaceDir = process.env.CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR;
|
const hostWorkspaceDir = process.env.CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR
|
||||||
|
|
||||||
// Both must be set for DinD mode, or neither (partial config is invalid)
|
// Both must be set for DinD mode, or neither (partial config is invalid)
|
||||||
if (!hostConfigDir || !hostWorkspaceDir) {
|
if (!hostConfigDir || !hostWorkspaceDir) {
|
||||||
return containerPath;
|
return containerPath
|
||||||
}
|
}
|
||||||
|
|
||||||
const home = os.homedir();
|
const home = os.homedir()
|
||||||
const containerConfigDir = `${home}/.clawdbot`;
|
const containerConfigDir = `${home}/.clawdbot`
|
||||||
const containerWorkspaceDir = `${home}/clawd`;
|
const containerWorkspaceDir = `${home}/clawd`
|
||||||
|
|
||||||
// Remap config directory paths
|
// Remap config directory paths
|
||||||
if (hostConfigDir && containerPath.startsWith(containerConfigDir)) {
|
if (hostConfigDir && containerPath.startsWith(containerConfigDir)) {
|
||||||
return containerPath.replace(containerConfigDir, hostConfigDir);
|
return containerPath.replace(containerConfigDir, hostConfigDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remap workspace directory paths
|
// Remap workspace directory paths
|
||||||
if (hostWorkspaceDir && containerPath.startsWith(containerWorkspaceDir)) {
|
if (hostWorkspaceDir && containerPath.startsWith(containerWorkspaceDir)) {
|
||||||
return containerPath.replace(containerWorkspaceDir, hostWorkspaceDir);
|
return containerPath.replace(containerWorkspaceDir, hostWorkspaceDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
return containerPath;
|
return containerPath
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
|
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
export function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
|
export function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
|
||||||
return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
|
return new Promise<{ stdout: string stderr: string code: number }>(
|
||||||
const child = spawn("docker", args, {
|
(resolve, reject) => {
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
const child = spawn("docker", args, {
|
||||||
});
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
let stdout = "";
|
})
|
||||||
let stderr = "";
|
let stdout = ""
|
||||||
child.stdout?.on("data", (chunk) => {
|
let stderr = ""
|
||||||
stdout += chunk.toString();
|
child.stdout?.on("data", (chunk) => {
|
||||||
});
|
stdout += chunk.toString()
|
||||||
child.stderr?.on("data", (chunk) => {
|
})
|
||||||
stderr += chunk.toString();
|
child.stderr?.on("data", (chunk) => {
|
||||||
});
|
stderr += chunk.toString()
|
||||||
child.on("close", (code) => {
|
})
|
||||||
const exitCode = code ?? 0;
|
child.on("close", (code) => {
|
||||||
if (exitCode !== 0 && !opts?.allowFailure) {
|
const exitCode = code ?? 0
|
||||||
reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`));
|
if (exitCode !== 0 && !opts?.allowFailure) {
|
||||||
return;
|
reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`))
|
||||||
}
|
return
|
||||||
resolve({ stdout, stderr, code: exitCode });
|
}
|
||||||
});
|
resolve({ stdout, stderr, code: exitCode })
|
||||||
});
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readDockerPort(containerName: string, port: number) {
|
export async function readDockerPort(containerName: string, port: number) {
|
||||||
const result = await execDocker(["port", containerName, `${port}/tcp`], {
|
const result = await execDocker(["port", containerName, `${port}/tcp`], {
|
||||||
allowFailure: true,
|
allowFailure: true,
|
||||||
});
|
})
|
||||||
if (result.code !== 0) return null;
|
if (result.code !== 0) return null
|
||||||
const line = result.stdout.trim().split(/\r?\n/)[0] ?? "";
|
const line = result.stdout.trim().split(/\r?\n/)[0] ?? ""
|
||||||
const match = line.match(/:(\d+)\s*$/);
|
const match = line.match(/:(\d+)\s*$/)
|
||||||
if (!match) return null;
|
if (!match) return null
|
||||||
const mapped = Number.parseInt(match[1] ?? "", 10);
|
const mapped = Number.parseInt(match[1] ?? "", 10)
|
||||||
return Number.isFinite(mapped) ? mapped : null;
|
return Number.isFinite(mapped) ? mapped : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dockerImageExists(image: string) {
|
async function dockerImageExists(image: string) {
|
||||||
const result = await execDocker(["image", "inspect", image], {
|
const result = await execDocker(["image", "inspect", image], {
|
||||||
allowFailure: true,
|
allowFailure: true,
|
||||||
});
|
})
|
||||||
if (result.code === 0) return true;
|
if (result.code === 0) return true
|
||||||
const stderr = result.stderr.trim();
|
const stderr = result.stderr.trim()
|
||||||
if (stderr.includes("No such image")) {
|
if (stderr.includes("No such image")) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
throw new Error(`Failed to inspect sandbox image: ${stderr}`);
|
throw new Error(`Failed to inspect sandbox image: ${stderr}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureDockerImage(image: string) {
|
export async function ensureDockerImage(image: string) {
|
||||||
const exists = await dockerImageExists(image);
|
const exists = await dockerImageExists(image)
|
||||||
if (exists) return;
|
if (exists) return
|
||||||
if (image === DEFAULT_SANDBOX_IMAGE) {
|
if (image === DEFAULT_SANDBOX_IMAGE) {
|
||||||
await execDocker(["pull", "debian:bookworm-slim"]);
|
await execDocker(["pull", "debian:bookworm-slim"])
|
||||||
await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]);
|
await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE])
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`);
|
throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dockerContainerState(name: string) {
|
export async function dockerContainerState(name: string) {
|
||||||
const result = await execDocker(["inspect", "-f", "{{.State.Running}}", name], {
|
const result = await execDocker(
|
||||||
allowFailure: true,
|
["inspect", "-f", "{{.State.Running}}", name],
|
||||||
});
|
{
|
||||||
if (result.code !== 0) return { exists: false, running: false };
|
allowFailure: true,
|
||||||
return { exists: true, running: result.stdout.trim() === "true" };
|
},
|
||||||
|
)
|
||||||
|
if (result.code !== 0) return { exists: false, running: false }
|
||||||
|
return { exists: true, running: result.stdout.trim() === "true" }
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDockerLimit(value?: string | number) {
|
function normalizeDockerLimit(value?: string | number) {
|
||||||
if (value === undefined || value === null) return undefined;
|
if (value === undefined || value === null) return undefined
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
return Number.isFinite(value) ? String(value) : undefined;
|
return Number.isFinite(value) ? String(value) : undefined
|
||||||
}
|
}
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim()
|
||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUlimitValue(
|
function formatUlimitValue(
|
||||||
name: string,
|
name: string,
|
||||||
value: string | number | { soft?: number; hard?: number },
|
value: string | number | { soft?: number hard?: number },
|
||||||
) {
|
) {
|
||||||
if (!name.trim()) return null;
|
if (!name.trim()) return null
|
||||||
if (typeof value === "number" || typeof value === "string") {
|
if (typeof value === "number" || typeof value === "string") {
|
||||||
const raw = String(value).trim();
|
const raw = String(value).trim()
|
||||||
return raw ? `${name}=${raw}` : null;
|
return raw ? `${name}=${raw}` : null
|
||||||
}
|
}
|
||||||
const soft = typeof value.soft === "number" ? Math.max(0, value.soft) : undefined;
|
const soft =
|
||||||
const hard = typeof value.hard === "number" ? Math.max(0, value.hard) : undefined;
|
typeof value.soft === "number" ? Math.max(0, value.soft) : undefined
|
||||||
if (soft === undefined && hard === undefined) return null;
|
const hard =
|
||||||
if (soft === undefined) return `${name}=${hard}`;
|
typeof value.hard === "number" ? Math.max(0, value.hard) : undefined
|
||||||
if (hard === undefined) return `${name}=${soft}`;
|
if (soft === undefined && hard === undefined) return null
|
||||||
return `${name}=${soft}:${hard}`;
|
if (soft === undefined) return `${name}=${hard}`
|
||||||
|
if (hard === undefined) return `${name}=${soft}`
|
||||||
|
return `${name}=${soft}:${hard}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSandboxCreateArgs(params: {
|
export function buildSandboxCreateArgs(params: {
|
||||||
name: string;
|
name: string
|
||||||
cfg: SandboxDockerConfig;
|
cfg: SandboxDockerConfig
|
||||||
scopeKey: string;
|
scopeKey: string
|
||||||
createdAtMs?: number;
|
createdAtMs?: number
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>
|
||||||
configHash?: string;
|
configHash?: string
|
||||||
}) {
|
}) {
|
||||||
const createdAtMs = params.createdAtMs ?? Date.now();
|
const createdAtMs = params.createdAtMs ?? Date.now()
|
||||||
const args = ["create", "--name", params.name];
|
const args = ["create", "--name", params.name]
|
||||||
args.push("--label", "moltbot.sandbox=1");
|
args.push("--label", "moltbot.sandbox=1")
|
||||||
args.push("--label", `moltbot.sessionKey=${params.scopeKey}`);
|
args.push("--label", `moltbot.sessionKey=${params.scopeKey}`)
|
||||||
args.push("--label", `moltbot.createdAtMs=${createdAtMs}`);
|
args.push("--label", `moltbot.createdAtMs=${createdAtMs}`)
|
||||||
if (params.configHash) {
|
if (params.configHash) {
|
||||||
args.push("--label", `moltbot.configHash=${params.configHash}`);
|
args.push("--label", `moltbot.configHash=${params.configHash}`)
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(params.labels ?? {})) {
|
for (const [key, value] of Object.entries(params.labels ?? {})) {
|
||||||
if (key && value) args.push("--label", `${key}=${value}`);
|
if (key && value) args.push("--label", `${key}=${value}`)
|
||||||
}
|
}
|
||||||
if (params.cfg.readOnlyRoot) args.push("--read-only");
|
if (params.cfg.readOnlyRoot) args.push("--read-only")
|
||||||
for (const entry of params.cfg.tmpfs) {
|
for (const entry of params.cfg.tmpfs) {
|
||||||
args.push("--tmpfs", entry);
|
args.push("--tmpfs", entry)
|
||||||
}
|
}
|
||||||
if (params.cfg.network) args.push("--network", params.cfg.network);
|
if (params.cfg.network) args.push("--network", params.cfg.network)
|
||||||
if (params.cfg.user) args.push("--user", params.cfg.user);
|
if (params.cfg.user) args.push("--user", params.cfg.user)
|
||||||
for (const cap of params.cfg.capDrop) {
|
for (const cap of params.cfg.capDrop) {
|
||||||
args.push("--cap-drop", cap);
|
args.push("--cap-drop", cap)
|
||||||
}
|
}
|
||||||
args.push("--security-opt", "no-new-privileges");
|
args.push("--security-opt", "no-new-privileges")
|
||||||
if (params.cfg.seccompProfile) {
|
if (params.cfg.seccompProfile) {
|
||||||
args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`);
|
args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`)
|
||||||
}
|
}
|
||||||
if (params.cfg.apparmorProfile) {
|
if (params.cfg.apparmorProfile) {
|
||||||
args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`);
|
args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`)
|
||||||
}
|
}
|
||||||
for (const entry of params.cfg.dns ?? []) {
|
for (const entry of params.cfg.dns ?? []) {
|
||||||
if (entry.trim()) args.push("--dns", entry);
|
if (entry.trim()) args.push("--dns", entry)
|
||||||
}
|
}
|
||||||
for (const entry of params.cfg.extraHosts ?? []) {
|
for (const entry of params.cfg.extraHosts ?? []) {
|
||||||
if (entry.trim()) args.push("--add-host", entry);
|
if (entry.trim()) args.push("--add-host", entry)
|
||||||
}
|
}
|
||||||
if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) {
|
if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) {
|
||||||
args.push("--pids-limit", String(params.cfg.pidsLimit));
|
args.push("--pids-limit", String(params.cfg.pidsLimit))
|
||||||
}
|
}
|
||||||
const memory = normalizeDockerLimit(params.cfg.memory);
|
const memory = normalizeDockerLimit(params.cfg.memory)
|
||||||
if (memory) args.push("--memory", memory);
|
if (memory) args.push("--memory", memory)
|
||||||
const memorySwap = normalizeDockerLimit(params.cfg.memorySwap);
|
const memorySwap = normalizeDockerLimit(params.cfg.memorySwap)
|
||||||
if (memorySwap) args.push("--memory-swap", memorySwap);
|
if (memorySwap) args.push("--memory-swap", memorySwap)
|
||||||
if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) {
|
if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) {
|
||||||
args.push("--cpus", String(params.cfg.cpus));
|
args.push("--cpus", String(params.cfg.cpus))
|
||||||
}
|
}
|
||||||
for (const [name, value] of Object.entries(params.cfg.ulimits ?? {}) as Array<
|
for (const [name, value] of Object.entries(
|
||||||
[string, string | number | { soft?: number; hard?: number }]
|
params.cfg.ulimits ?? {},
|
||||||
>) {
|
) as Array<[string, string | number | { soft?: number hard?: number }]>) {
|
||||||
const formatted = formatUlimitValue(name, value);
|
const formatted = formatUlimitValue(name, value)
|
||||||
if (formatted) args.push("--ulimit", formatted);
|
if (formatted) args.push("--ulimit", formatted)
|
||||||
}
|
}
|
||||||
if (params.cfg.binds?.length) {
|
if (params.cfg.binds?.length) {
|
||||||
for (const bind of params.cfg.binds) {
|
for (const bind of params.cfg.binds) {
|
||||||
args.push("-v", bind);
|
args.push("-v", bind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return args;
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSandboxContainer(params: {
|
async function createSandboxContainer(params: {
|
||||||
name: string;
|
name: string
|
||||||
cfg: SandboxDockerConfig;
|
cfg: SandboxDockerConfig
|
||||||
workspaceDir: string;
|
workspaceDir: string
|
||||||
workspaceAccess: SandboxWorkspaceAccess;
|
workspaceAccess: SandboxWorkspaceAccess
|
||||||
agentWorkspaceDir: string;
|
agentWorkspaceDir: string
|
||||||
scopeKey: string;
|
scopeKey: string
|
||||||
configHash?: string;
|
configHash?: string
|
||||||
}) {
|
}) {
|
||||||
const { name, cfg, workspaceDir, scopeKey } = params;
|
const { name, cfg, workspaceDir, scopeKey } = params
|
||||||
await ensureDockerImage(cfg.image);
|
await ensureDockerImage(cfg.image)
|
||||||
|
|
||||||
const args = buildSandboxCreateArgs({
|
const args = buildSandboxCreateArgs({
|
||||||
name,
|
name,
|
||||||
cfg,
|
cfg,
|
||||||
scopeKey,
|
scopeKey,
|
||||||
configHash: params.configHash,
|
configHash: params.configHash,
|
||||||
});
|
})
|
||||||
args.push("--workdir", cfg.workdir);
|
args.push("--workdir", cfg.workdir)
|
||||||
const mainMountSuffix =
|
const mainMountSuffix =
|
||||||
params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir ? ":ro" : "";
|
params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir
|
||||||
|
? ":ro"
|
||||||
|
: ""
|
||||||
// Remap paths for Docker-in-Docker scenarios
|
// Remap paths for Docker-in-Docker scenarios
|
||||||
const hostWorkspaceDir = remapPathForDinD(workspaceDir);
|
const hostWorkspaceDir = remapPathForDinD(workspaceDir)
|
||||||
args.push("-v", `${hostWorkspaceDir}:${cfg.workdir}${mainMountSuffix}`);
|
args.push("-v", `${hostWorkspaceDir}:${cfg.workdir}${mainMountSuffix}`)
|
||||||
if (params.workspaceAccess !== "none" && workspaceDir !== params.agentWorkspaceDir) {
|
if (
|
||||||
const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : "";
|
params.workspaceAccess !== "none" &&
|
||||||
const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir);
|
workspaceDir !== params.agentWorkspaceDir
|
||||||
|
) {
|
||||||
|
const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : ""
|
||||||
|
const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir)
|
||||||
args.push(
|
args.push(
|
||||||
"-v",
|
"-v",
|
||||||
`${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
|
`${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
args.push(cfg.image, "sleep", "infinity");
|
args.push(cfg.image, "sleep", "infinity")
|
||||||
|
|
||||||
await execDocker(args);
|
await execDocker(args)
|
||||||
await execDocker(["start", name]);
|
await execDocker(["start", name])
|
||||||
|
|
||||||
if (cfg.setupCommand?.trim()) {
|
if (cfg.setupCommand?.trim()) {
|
||||||
await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]);
|
await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readContainerConfigHash(containerName: string): Promise<string | null> {
|
async function readContainerConfigHash(
|
||||||
|
containerName: string,
|
||||||
|
): Promise<string | null> {
|
||||||
const result = await execDocker(
|
const result = await execDocker(
|
||||||
["inspect", "-f", '{{ index .Config.Labels "moltbot.configHash" }}', containerName],
|
[
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
'{{ index .Config.Labels "moltbot.configHash" }}',
|
||||||
|
containerName,
|
||||||
|
],
|
||||||
{ allowFailure: true },
|
{ allowFailure: true },
|
||||||
);
|
)
|
||||||
if (result.code !== 0) return null;
|
if (result.code !== 0) return null
|
||||||
const raw = result.stdout.trim();
|
const raw = result.stdout.trim()
|
||||||
if (!raw || raw === "<no value>") return null;
|
if (!raw || raw === "<no value>") return null
|
||||||
return raw;
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSandboxRecreateHint(params: { scope: SandboxConfig["scope"]; sessionKey: string }) {
|
function formatSandboxRecreateHint(params: {
|
||||||
|
scope: SandboxConfig["scope"]
|
||||||
|
sessionKey: string
|
||||||
|
}) {
|
||||||
if (params.scope === "session") {
|
if (params.scope === "session") {
|
||||||
return formatCliCommand(`moltbot sandbox recreate --session ${params.sessionKey}`);
|
return formatCliCommand(
|
||||||
|
`moltbot sandbox recreate --session ${params.sessionKey}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (params.scope === "agent") {
|
if (params.scope === "agent") {
|
||||||
const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main";
|
const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main"
|
||||||
return formatCliCommand(`moltbot sandbox recreate --agent ${agentId}`);
|
return formatCliCommand(`moltbot sandbox recreate --agent ${agentId}`)
|
||||||
}
|
}
|
||||||
return formatCliCommand("moltbot sandbox recreate --all");
|
return formatCliCommand("moltbot sandbox recreate --all")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureSandboxContainer(params: {
|
export async function ensureSandboxContainer(params: {
|
||||||
sessionKey: string;
|
sessionKey: string
|
||||||
workspaceDir: string;
|
workspaceDir: string
|
||||||
agentWorkspaceDir: string;
|
agentWorkspaceDir: string
|
||||||
cfg: SandboxConfig;
|
cfg: SandboxConfig
|
||||||
}) {
|
}) {
|
||||||
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey);
|
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey)
|
||||||
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
|
const slug =
|
||||||
const name = `${params.cfg.docker.containerPrefix}${slug}`;
|
params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey)
|
||||||
const containerName = name.slice(0, 63);
|
const name = `${params.cfg.docker.containerPrefix}${slug}`
|
||||||
|
const containerName = name.slice(0, 63)
|
||||||
const expectedHash = computeSandboxConfigHash({
|
const expectedHash = computeSandboxConfigHash({
|
||||||
docker: params.cfg.docker,
|
docker: params.cfg.docker,
|
||||||
workspaceAccess: params.cfg.workspaceAccess,
|
workspaceAccess: params.cfg.workspaceAccess,
|
||||||
workspaceDir: params.workspaceDir,
|
workspaceDir: params.workspaceDir,
|
||||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||||
});
|
})
|
||||||
const now = Date.now();
|
const now = Date.now()
|
||||||
const state = await dockerContainerState(containerName);
|
const state = await dockerContainerState(containerName)
|
||||||
let hasContainer = state.exists;
|
let hasContainer = state.exists
|
||||||
let running = state.running;
|
let running = state.running
|
||||||
let currentHash: string | null = null;
|
let currentHash: string | null = null
|
||||||
let hashMismatch = false;
|
let hashMismatch = false
|
||||||
let registryEntry:
|
let registryEntry: {
|
||||||
| {
|
lastUsedAtMs: number
|
||||||
lastUsedAtMs: number;
|
configHash?: string
|
||||||
configHash?: string;
|
} | undefined
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
if (hasContainer) {
|
if (hasContainer) {
|
||||||
const registry = await readRegistry();
|
const registry = await readRegistry()
|
||||||
registryEntry = registry.entries.find((entry) => entry.containerName === containerName);
|
registryEntry = registry.entries.find(
|
||||||
currentHash = await readContainerConfigHash(containerName);
|
(entry) => entry.containerName === containerName,
|
||||||
|
)
|
||||||
|
currentHash = await readContainerConfigHash(containerName)
|
||||||
if (!currentHash) {
|
if (!currentHash) {
|
||||||
currentHash = registryEntry?.configHash ?? null;
|
currentHash = registryEntry?.configHash ?? null
|
||||||
}
|
}
|
||||||
hashMismatch = !currentHash || currentHash !== expectedHash;
|
hashMismatch = !currentHash || currentHash !== expectedHash
|
||||||
if (hashMismatch) {
|
if (hashMismatch) {
|
||||||
const lastUsedAtMs = registryEntry?.lastUsedAtMs;
|
const lastUsedAtMs = registryEntry?.lastUsedAtMs
|
||||||
const isHot =
|
const isHot =
|
||||||
running &&
|
running &&
|
||||||
(typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS);
|
(typeof lastUsedAtMs !== "number" ||
|
||||||
|
now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS)
|
||||||
if (isHot) {
|
if (isHot) {
|
||||||
const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey });
|
const hint = formatSandboxRecreateHint({
|
||||||
|
scope: params.cfg.scope,
|
||||||
|
sessionKey: scopeKey,
|
||||||
|
})
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
`Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`,
|
`Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`,
|
||||||
);
|
)
|
||||||
} else {
|
} else {
|
||||||
await execDocker(["rm", "-f", containerName], { allowFailure: true });
|
await execDocker(["rm", "-f", containerName], { allowFailure: true })
|
||||||
hasContainer = false;
|
hasContainer = false
|
||||||
running = false;
|
running = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -330,9 +370,9 @@ export async function ensureSandboxContainer(params: {
|
|||||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||||
scopeKey,
|
scopeKey,
|
||||||
configHash: expectedHash,
|
configHash: expectedHash,
|
||||||
});
|
})
|
||||||
} else if (!running) {
|
} else if (!running) {
|
||||||
await execDocker(["start", containerName]);
|
await execDocker(["start", containerName])
|
||||||
}
|
}
|
||||||
await updateRegistry({
|
await updateRegistry({
|
||||||
containerName,
|
containerName,
|
||||||
@ -340,7 +380,8 @@ export async function ensureSandboxContainer(params: {
|
|||||||
createdAtMs: now,
|
createdAtMs: now,
|
||||||
lastUsedAtMs: now,
|
lastUsedAtMs: now,
|
||||||
image: params.cfg.docker.image,
|
image: params.cfg.docker.image,
|
||||||
configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash,
|
configHash:
|
||||||
});
|
hashMismatch && running ? (currentHash ?? undefined) : expectedHash,
|
||||||
return containerName;
|
})
|
||||||
|
return containerName
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user