refactor: share browser and sandbox helpers

This commit is contained in:
Peter Steinberger
2026-03-26 18:43:05 +00:00
parent 2b6375faf9
commit e774fe1286
11 changed files with 202 additions and 287 deletions

View File

@@ -6,7 +6,7 @@ import type {
SandboxFsStat,
SandboxResolvedPath,
} from "openclaw/plugin-sdk/sandbox";
import { resolveWritableRenameTargetsForBridge } from "openclaw/plugin-sdk/sandbox";
import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox";
import type { OpenShellSandboxBackend } from "./backend.js";
import { movePathWithCopyFallback } from "./mirror.js";
@@ -24,19 +24,16 @@ export function createOpenShellFsBridge(params: {
}
class OpenShellFsBridge implements SandboxFsBridge {
private readonly resolveRenameTargets = createWritableRenameTargetResolver(
(target) => this.resolveTarget(target),
(target, action) => this.ensureWritable(target, action),
);
constructor(
private readonly sandbox: SandboxContext,
private readonly backend: OpenShellSandboxBackend,
) {}
private resolveRenameTargets(params: { from: string; to: string; cwd?: string }) {
return resolveWritableRenameTargetsForBridge(
params,
(target) => this.resolveTarget(target),
(target, action) => this.ensureWritable(target, action),
);
}
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
const target = this.resolveTarget(params);
return {

View File

@@ -46,6 +46,7 @@ export {
uploadDirectoryToSshTarget,
} from "./sandbox/ssh.js";
export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js";
export { createWritableRenameTargetResolver } from "./sandbox/fs-bridge-rename-targets.js";
export { resolveWritableRenameTargets } from "./sandbox/fs-bridge-rename-targets.js";
export { resolveWritableRenameTargetsForBridge } from "./sandbox/fs-bridge-rename-targets.js";

View File

@@ -30,3 +30,10 @@ export function resolveWritableRenameTargetsForBridge<T extends { containerPath:
ensureWritable,
});
}
export function createWritableRenameTargetResolver<T extends { containerPath: string }>(
resolveTarget: (params: { filePath: string; cwd?: string }) => T,
ensureWritable: (target: T, action: string) => void,
): (params: { from: string; to: string; cwd?: string }) => { from: T; to: T } {
return (params) => resolveWritableRenameTargetsForBridge(params, resolveTarget, ensureWritable);
}

View File

@@ -2,7 +2,7 @@ import path from "node:path";
import { isPathInside } from "../../infra/path-guards.js";
import type { SandboxBackendCommandParams, SandboxBackendCommandResult } from "./backend.js";
import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js";
import { resolveWritableRenameTargetsForBridge } from "./fs-bridge-rename-targets.js";
import { createWritableRenameTargetResolver } from "./fs-bridge-rename-targets.js";
import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.js";
import {
isPathInsideContainerRoot,
@@ -36,19 +36,16 @@ export function createRemoteShellSandboxFsBridge(params: {
}
class RemoteShellSandboxFsBridge implements SandboxFsBridge {
private readonly resolveRenameTargets = createWritableRenameTargetResolver(
(target) => this.resolveTarget(target),
(target, action) => this.ensureWritable(target, action),
);
constructor(
private readonly sandbox: SandboxContext,
private readonly runtime: RemoteShellSandboxHandle,
) {}
private resolveRenameTargets(params: { from: string; to: string; cwd?: string }) {
return resolveWritableRenameTargetsForBridge(
params,
(target) => this.resolveTarget(target),
(target, action) => this.ensureWritable(target, action),
);
}
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
const target = this.resolveTarget(params);
return {

View File

@@ -11,12 +11,17 @@ import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { isValidIPv4 } from "../gateway/net.js";
import {
detectBrowserOpenSupport,
openUrl,
openUrlInBackground,
resolveBrowserOpenCommand,
} from "../infra/browser-open.js";
import { detectBinary } from "../infra/detect-binary.js";
import {
inspectBestEffortPrimaryTailnetIPv4,
pickBestEffortPrimaryLanIPv4,
} from "../infra/network-discovery-display.js";
import { isWSL } from "../infra/wsl.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
@@ -26,6 +31,7 @@ import { VERSION } from "../version.js";
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
export { detectBinary };
export { detectBrowserOpenSupport, openUrl, openUrlInBackground, resolveBrowserOpenCommand };
export function guardCancel<T>(value: T | symbol, runtime: RuntimeEnv): T {
if (isCancel(value)) {
@@ -128,79 +134,6 @@ export function applyWizardMetadata(
};
}
type BrowserOpenSupport = {
ok: boolean;
reason?: string;
command?: string;
};
type BrowserOpenCommand = {
argv: string[] | null;
reason?: string;
command?: string;
/**
* Whether the URL must be wrapped in quotes when appended to argv.
* Needed for Windows `cmd /c start` where `&` splits commands.
*/
quoteUrl?: boolean;
};
export async function resolveBrowserOpenCommand(): Promise<BrowserOpenCommand> {
const platform = process.platform;
const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
const isSsh =
Boolean(process.env.SSH_CLIENT) ||
Boolean(process.env.SSH_TTY) ||
Boolean(process.env.SSH_CONNECTION);
if (isSsh && !hasDisplay && platform !== "win32") {
return { argv: null, reason: "ssh-no-display" };
}
if (platform === "win32") {
return {
argv: ["cmd", "/c", "start", ""],
command: "cmd",
quoteUrl: true,
};
}
if (platform === "darwin") {
const hasOpen = await detectBinary("open");
return hasOpen ? { argv: ["open"], command: "open" } : { argv: null, reason: "missing-open" };
}
if (platform === "linux") {
const wsl = await isWSL();
if (!hasDisplay && !wsl) {
return { argv: null, reason: "no-display" };
}
if (wsl) {
const hasWslview = await detectBinary("wslview");
if (hasWslview) {
return { argv: ["wslview"], command: "wslview" };
}
if (!hasDisplay) {
return { argv: null, reason: "wsl-no-wslview" };
}
}
const hasXdgOpen = await detectBinary("xdg-open");
return hasXdgOpen
? { argv: ["xdg-open"], command: "xdg-open" }
: { argv: null, reason: "missing-xdg-open" };
}
return { argv: null, reason: "unsupported-platform" };
}
export async function detectBrowserOpenSupport(): Promise<BrowserOpenSupport> {
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv) {
return { ok: false, reason: resolved.reason };
}
return { ok: true, command: resolved.command };
}
export function formatControlUiSshHint(params: {
port: number;
basePath?: string;
@@ -234,57 +167,6 @@ function resolveSshTargetHint(): string {
return `${user}@${host}`;
}
export async function openUrl(url: string): Promise<boolean> {
if (shouldSkipBrowserOpenInTests()) {
return false;
}
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv) {
return false;
}
const quoteUrl = resolved.quoteUrl === true;
const command = [...resolved.argv];
if (quoteUrl) {
if (command.at(-1) === "") {
// Preserve the empty title token for `start` when using verbatim args.
command[command.length - 1] = '""';
}
command.push(`"${url}"`);
} else {
command.push(url);
}
try {
await runCommandWithTimeout(command, {
timeoutMs: 5_000,
windowsVerbatimArguments: quoteUrl,
});
return true;
} catch {
// ignore; we still print the URL for manual open
return false;
}
}
export async function openUrlInBackground(url: string): Promise<boolean> {
if (shouldSkipBrowserOpenInTests()) {
return false;
}
if (process.platform !== "darwin") {
return false;
}
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv || resolved.command !== "open") {
return false;
}
const command = ["open", "-g", url];
try {
await runCommandWithTimeout(command, { timeoutMs: 5_000 });
return true;
} catch {
return false;
}
}
export async function ensureWorkspaceAndSessions(
workspaceDir: string,
runtime: RuntimeEnv,
@@ -340,13 +222,6 @@ export async function handleReset(scope: ResetScope, workspaceDir: string, runti
}
}
function shouldSkipBrowserOpenInTests(): boolean {
if (process.env.VITEST) {
return true;
}
return process.env.NODE_ENV === "test";
}
export async function probeGatewayReachable(params: {
url: string;
token?: string;

View File

@@ -1,6 +1,7 @@
import { VERSION } from "../version.js";
import type { ConfigUiHints } from "./schema.hints.js";
import { buildBaseHints, mapSensitivePaths } from "./schema.hints.js";
import { asSchemaObject, cloneSchema } from "./schema.shared.js";
import { applyDerivedTags } from "./schema.tags.js";
import { OpenClawSchema } from "./zod-schema.js";
@@ -12,6 +13,9 @@ type JsonSchemaObject = Record<string, unknown> & {
additionalProperties?: JsonSchemaObject | boolean;
};
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
asSchemaObject<JsonSchemaObject>(value);
export type BaseConfigSchemaResponse = {
schema: ConfigSchema;
uiHints: ConfigUiHints;
@@ -21,23 +25,9 @@ export type BaseConfigSchemaResponse = {
type BaseConfigSchemaStablePayload = Omit<BaseConfigSchemaResponse, "generatedAt">;
function cloneSchema<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
function asSchemaObject(value: unknown): JsonSchemaObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonSchemaObject;
}
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
const root = asJsonSchemaObject(next);
if (!root || !root.properties) {
return next;
}
@@ -47,7 +37,7 @@ function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
if (Array.isArray(root.required)) {
root.required = root.required.filter((key) => key !== "$schema");
}
const channelsNode = asSchemaObject(root.properties.channels);
const channelsNode = asJsonSchemaObject(root.properties.channels);
if (channelsNode) {
channelsNode.properties = {};
channelsNode.required = [];

View File

@@ -8,6 +8,20 @@ type JsonSchemaObject = {
oneOf?: JsonSchemaObject[];
};
export function cloneSchema<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
export function asSchemaObject<T extends object>(value: unknown): T | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as T;
}
export function schemaHasChildren(schema: JsonSchemaObject): boolean {
if (schema.properties && Object.keys(schema.properties).length > 0) {
return true;

View File

@@ -3,7 +3,12 @@ import { CHANNEL_IDS } from "../channels/registry.js";
import { GENERATED_BASE_CONFIG_SCHEMA } from "./schema.base.generated.js";
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
import { applySensitiveHints } from "./schema.hints.js";
import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js";
import {
asSchemaObject,
cloneSchema,
findWildcardHintMatch,
schemaHasChildren,
} from "./schema.shared.js";
import { applyDerivedTags } from "./schema.tags.js";
export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
@@ -20,6 +25,9 @@ type JsonSchemaObject = JsonSchemaNode & {
items?: JsonSchemaObject | JsonSchemaObject[];
};
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
asSchemaObject<JsonSchemaObject>(value);
const FORBIDDEN_LOOKUP_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
const LOOKUP_SCHEMA_STRING_KEYS = new Set([
"$id",
@@ -53,20 +61,6 @@ const LOOKUP_SCHEMA_BOOLEAN_KEYS = new Set([
]);
const MAX_LOOKUP_PATH_SEGMENTS = 32;
function cloneSchema<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
function asSchemaObject(value: unknown): JsonSchemaObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonSchemaObject;
}
function isObjectSchema(schema: JsonSchemaObject): boolean {
const type = schema.type;
if (type === "object") {
@@ -284,14 +278,14 @@ function applyHeartbeatTargetHints(
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
const pluginsNode = asSchemaObject(root?.properties?.plugins);
const entriesNode = asSchemaObject(pluginsNode?.properties?.entries);
const root = asJsonSchemaObject(next);
const pluginsNode = asJsonSchemaObject(root?.properties?.plugins);
const entriesNode = asJsonSchemaObject(pluginsNode?.properties?.entries);
if (!entriesNode) {
return next;
}
const entryBase = asSchemaObject(entriesNode.additionalProperties);
const entryBase = asJsonSchemaObject(entriesNode.additionalProperties);
const entryProperties = entriesNode.properties ?? {};
entriesNode.properties = entryProperties;
@@ -302,9 +296,9 @@ function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]):
const entrySchema = entryBase
? cloneSchema(entryBase)
: ({ type: "object" } as JsonSchemaObject);
const entryObject = asSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
const baseConfigSchema = asSchemaObject(entryObject.properties?.config);
const pluginSchema = asSchemaObject(plugin.configSchema);
const entryObject = asJsonSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
const baseConfigSchema = asJsonSchemaObject(entryObject.properties?.config);
const pluginSchema = asJsonSchemaObject(plugin.configSchema);
const nextConfigSchema =
baseConfigSchema &&
pluginSchema &&
@@ -325,8 +319,8 @@ function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]):
function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
const channelsNode = asSchemaObject(root?.properties?.channels);
const root = asJsonSchemaObject(next);
const channelsNode = asJsonSchemaObject(root?.properties?.channels);
if (!channelsNode) {
return next;
}
@@ -337,8 +331,8 @@ function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]
if (!channel.configSchema) {
continue;
}
const existing = asSchemaObject(channelProps[channel.id]);
const incoming = asSchemaObject(channel.configSchema);
const existing = asJsonSchemaObject(channelProps[channel.id]);
const incoming = asJsonSchemaObject(channel.configSchema);
if (existing && incoming && isObjectSchema(existing) && isObjectSchema(incoming)) {
channelProps[channel.id] = mergeObjectSchema(existing, incoming);
} else {
@@ -502,7 +496,7 @@ function resolveLookupChildSchema(
const properties = schema.properties;
if (properties && Object.hasOwn(properties, segment)) {
return asSchemaObject(properties[segment]);
return asJsonSchemaObject(properties[segment]);
}
const itemIndex = /^\d+$/.test(segment) ? Number.parseInt(segment, 10) : undefined;
@@ -621,7 +615,7 @@ export function lookupConfigSchema(
return null;
}
let current = asSchemaObject(response.schema);
let current = asJsonSchemaObject(response.schema);
if (!current) {
return null;
}

127
src/infra/browser-open.ts Normal file
View File

@@ -0,0 +1,127 @@
import { runCommandWithTimeout } from "../process/exec.js";
import { detectBinary } from "./detect-binary.js";
import { isWSL } from "./wsl.js";
export type BrowserOpenCommand = {
argv: string[] | null;
reason?: string;
command?: string;
quoteUrl?: boolean;
};
export type BrowserOpenSupport = {
ok: boolean;
reason?: string;
command?: string;
};
function shouldSkipBrowserOpenInTests(): boolean {
if (process.env.VITEST) {
return true;
}
return process.env.NODE_ENV === "test";
}
export async function resolveBrowserOpenCommand(): Promise<BrowserOpenCommand> {
const platform = process.platform;
const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
const isSsh =
Boolean(process.env.SSH_CLIENT) ||
Boolean(process.env.SSH_TTY) ||
Boolean(process.env.SSH_CONNECTION);
if (isSsh && !hasDisplay && platform !== "win32") {
return { argv: null, reason: "ssh-no-display" };
}
if (platform === "win32") {
return {
argv: ["cmd", "/c", "start", ""],
command: "cmd",
quoteUrl: true,
};
}
if (platform === "darwin") {
const hasOpen = await detectBinary("open");
return hasOpen ? { argv: ["open"], command: "open" } : { argv: null, reason: "missing-open" };
}
if (platform === "linux") {
const wsl = await isWSL();
if (!hasDisplay && !wsl) {
return { argv: null, reason: "no-display" };
}
if (wsl) {
const hasWslview = await detectBinary("wslview");
if (hasWslview) {
return { argv: ["wslview"], command: "wslview" };
}
if (!hasDisplay) {
return { argv: null, reason: "wsl-no-wslview" };
}
}
const hasXdgOpen = await detectBinary("xdg-open");
return hasXdgOpen
? { argv: ["xdg-open"], command: "xdg-open" }
: { argv: null, reason: "missing-xdg-open" };
}
return { argv: null, reason: "unsupported-platform" };
}
export async function detectBrowserOpenSupport(): Promise<BrowserOpenSupport> {
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv) {
return { ok: false, reason: resolved.reason };
}
return { ok: true, command: resolved.command };
}
export async function openUrl(url: string): Promise<boolean> {
if (shouldSkipBrowserOpenInTests()) {
return false;
}
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv) {
return false;
}
const quoteUrl = resolved.quoteUrl === true;
const command = [...resolved.argv];
if (quoteUrl) {
if (command.at(-1) === "") {
command[command.length - 1] = '""';
}
command.push(`"${url}"`);
} else {
command.push(url);
}
try {
await runCommandWithTimeout(command, {
timeoutMs: 5_000,
windowsVerbatimArguments: quoteUrl,
});
return true;
} catch {
return false;
}
}
export async function openUrlInBackground(url: string): Promise<boolean> {
if (shouldSkipBrowserOpenInTests()) {
return false;
}
if (process.platform !== "darwin") {
return false;
}
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv || resolved.command !== "open") {
return false;
}
try {
await runCommandWithTimeout(["open", "-g", url], { timeoutMs: 5_000 });
return true;
} catch {
return false;
}
}

View File

@@ -26,6 +26,7 @@ export {
buildRemoteCommand,
buildSshSandboxArgv,
createRemoteShellSandboxFsBridge,
createWritableRenameTargetResolver,
createSshSandboxSessionFromConfigText,
createSshSandboxSessionFromSettings,
disposeSshSandboxSession,

View File

@@ -1,94 +1,6 @@
import { isRemoteEnvironment } from "../infra/remote-env.js";
import { isWSL } from "../infra/wsl.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { detectBinary } from "./setup-binary.js";
export {
openUrl,
resolveBrowserOpenCommand,
type BrowserOpenCommand,
} from "../infra/browser-open.js";
export { isRemoteEnvironment } from "../infra/remote-env.js";
function shouldSkipBrowserOpenInTests(): boolean {
if (process.env.VITEST) {
return true;
}
return process.env.NODE_ENV === "test";
}
type BrowserOpenCommand = {
argv: string[] | null;
command?: string;
quoteUrl?: boolean;
};
async function resolveBrowserOpenCommand(): Promise<BrowserOpenCommand> {
const platform = process.platform;
const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
const isSsh =
Boolean(process.env.SSH_CLIENT) ||
Boolean(process.env.SSH_TTY) ||
Boolean(process.env.SSH_CONNECTION);
if (isSsh && !hasDisplay && platform !== "win32") {
return { argv: null };
}
if (platform === "win32") {
return {
argv: ["cmd", "/c", "start", ""],
command: "cmd",
quoteUrl: true,
};
}
if (platform === "darwin") {
const hasOpen = await detectBinary("open");
return hasOpen ? { argv: ["open"], command: "open" } : { argv: null };
}
if (platform === "linux") {
const wsl = await isWSL();
if (!hasDisplay && !wsl) {
return { argv: null };
}
if (wsl) {
const hasWslview = await detectBinary("wslview");
if (hasWslview) {
return { argv: ["wslview"], command: "wslview" };
}
if (!hasDisplay) {
return { argv: null };
}
}
const hasXdgOpen = await detectBinary("xdg-open");
return hasXdgOpen ? { argv: ["xdg-open"], command: "xdg-open" } : { argv: null };
}
return { argv: null };
}
export async function openUrl(url: string): Promise<boolean> {
if (shouldSkipBrowserOpenInTests()) {
return false;
}
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv) {
return false;
}
const quoteUrl = resolved.quoteUrl === true;
const command = [...resolved.argv];
if (quoteUrl) {
if (command.at(-1) === "") {
command[command.length - 1] = '""';
}
command.push(`"${url}"`);
} else {
command.push(url);
}
try {
await runCommandWithTimeout(command, {
timeoutMs: 5_000,
windowsVerbatimArguments: quoteUrl,
});
return true;
} catch {
return false;
}
}