/** * Internal exec workdir resolver. * Owns cwd selection and validation before exec approval, hooks, preflight, or * process launch can observe an invalid selected working directory. */ import fs from "node:fs/promises"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { ExecHost } from "../infra/exec-approvals.js"; import { safeStatSync } from "../infra/path-guards.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; import { assertSandboxPath } from "./sandbox-paths.js"; export type ExecWorkdirResolution = | { kind: "local"; hostCwd: string } | { kind: "sandbox"; hostCwd: string; containerCwd: string; scriptPreflightCwd: string | null } | { kind: "node"; remoteCwd?: string } | { kind: "unavailable"; requestedCwd: string }; type NormalizedWorkdirInput = | { kind: "omitted" } | { kind: "blank"; raw: string } | { kind: "specified"; value: string }; type SandboxWorkdir = { hostCwd: string; containerCwd: string; scriptPreflightCwd: string | null; }; type BackendHostWorkdirCandidate = { hostPath: string; failIfInvalid: boolean; }; type ExistingHostWorkspacePathResult = | { kind: "available"; workdir: SandboxWorkdir } | { kind: "missing"; relative: string } | { kind: "invalid" }; function normalizeExplicitWorkdirInput(workdir: string | undefined): NormalizedWorkdirInput { if (workdir === undefined) { return { kind: "omitted" }; } const value = normalizeOptionalString(workdir); return value ? { kind: "specified", value } : { kind: "blank", raw: workdir }; } function unavailable(requestedCwd: string): ExecWorkdirResolution { return { kind: "unavailable", requestedCwd }; } function resolveExistingHostWorkdir(workdir: string): string | null { const stats = safeStatSync(workdir); return stats?.isDirectory() ? workdir : null; } function isHostPathInsideRoot(params: { root: string; candidate: string }): boolean { const root = path.resolve(params.root); const candidate = path.resolve(params.candidate); const relative = path.relative(root, candidate); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function safeCurrentCwd(): string | null { try { return process.cwd(); } catch { return null; } } function mapContainerWorkdirToHost(params: { workdir: string; sandbox: BashSandboxConfig; }): string | undefined { const workdir = normalizeContainerPath(params.workdir); const containerRoot = normalizeContainerPath(params.sandbox.containerWorkdir); if (containerRoot === ".") { return undefined; } if (workdir === containerRoot) { return path.resolve(params.sandbox.workspaceDir); } if (!workdir.startsWith(`${containerRoot}/`)) { return undefined; } const rel = workdir .slice(containerRoot.length + 1) .split("/") .filter(Boolean); return path.resolve(params.sandbox.workspaceDir, ...rel); } function normalizeContainerPath(input: string): string { const normalized = input.trim().replace(/\\/g, "/"); if (!normalized) { return "."; } const posixPath = path.posix.normalize(normalized); return posixPath === "/" ? posixPath : posixPath.replace(/\/+$/g, ""); } function joinContainerWorkdir(containerWorkdir: string, relative: string): string { return relative ? path.posix.join(containerWorkdir, relative) : containerWorkdir; } function hasParentPathSegment(input: string): boolean { return input .replace(/\\/g, "/") .split("/") .some((segment) => segment === ".."); } function isContainerWorkdirInsideRoot(params: { root: string; workdir: string }): boolean { const root = normalizeContainerPath(params.root); const workdir = normalizeContainerPath(params.workdir); if (root === "/") { return path.posix.isAbsolute(workdir); } return workdir === root || workdir.startsWith(`${root}/`); } function resolveBackendWorkdirRoots(sandbox: BashSandboxConfig): string[] { const roots: string[] = []; const addRoot = (root: string | undefined) => { const normalized = normalizeContainerPath(root ?? ""); if (normalized === "." || !path.posix.isAbsolute(normalized) || roots.includes(normalized)) { return; } roots.push(normalized); }; addRoot(sandbox.containerWorkdir); for (const root of sandbox.workdirRoots ?? []) { addRoot(root); } return roots; } function resolveBackendContainerWorkdir(params: { workdir: string; sandbox: BashSandboxConfig; }): string | null { const containerRoot = normalizeContainerPath(params.sandbox.containerWorkdir); const backendRoots = resolveBackendWorkdirRoots(params.sandbox); const requested = normalizeContainerPath(params.workdir); if (path.posix.isAbsolute(requested)) { return backendRoots.some((root) => isContainerWorkdirInsideRoot({ root, workdir: requested })) ? requested : null; } if (requested === ".." || requested.startsWith("../")) { return null; } return joinContainerWorkdir(containerRoot, requested === "." ? "" : requested); } async function mapExistingHostWorkspacePath(params: { hostPath: string; sandbox: BashSandboxConfig; }): Promise { let resolved: Awaited>; try { resolved = await assertSandboxPath({ filePath: params.hostPath, cwd: params.sandbox.workspaceDir, root: params.sandbox.workspaceDir, }); } catch { return { kind: "invalid" }; } const stats = safeStatSync(resolved.resolved); if (!stats) { return { kind: "missing", relative: resolved.relative ? resolved.relative.split(path.sep).join(path.posix.sep) : "", }; } if (!stats.isDirectory()) { return { kind: "invalid" }; } const relative = resolved.relative ? resolved.relative.split(path.sep).join(path.posix.sep) : ""; return { kind: "available", workdir: { hostCwd: resolved.resolved, containerCwd: joinContainerWorkdir(params.sandbox.containerWorkdir, relative), scriptPreflightCwd: resolved.resolved, }, }; } async function validateBackendWorkdir(params: { workdir: SandboxWorkdir; sandbox: BashSandboxConfig; }): Promise { const containerCwd = await params.sandbox.validateWorkdir?.(params.workdir.containerCwd); return containerCwd ? { hostCwd: params.workdir.hostCwd, containerCwd, scriptPreflightCwd: params.workdir.scriptPreflightCwd, } : null; } function resolveBackendHostWorkdirCandidate(params: { workdir: string; sandbox: BashSandboxConfig; }): BackendHostWorkdirCandidate | null { if (!path.isAbsolute(params.workdir)) { return { hostPath: path.resolve(params.sandbox.workspaceDir, params.workdir), failIfInvalid: false, }; } const hostPath = path.resolve(params.workdir); if ( isHostPathInsideRoot({ root: params.sandbox.workspaceDir, candidate: hostPath, }) ) { return { hostPath, failIfInvalid: true }; } const containerMappedHostPath = mapContainerWorkdirToHost({ workdir: params.workdir, sandbox: params.sandbox, }); return containerMappedHostPath ? { hostPath: containerMappedHostPath, failIfInvalid: false } : null; } async function resolveBackendValidatedSandboxWorkdir(params: { workdir: string; sandbox: BashSandboxConfig; }): Promise { const workspaceHostCwd = resolveExistingHostWorkdir(params.sandbox.workspaceDir); if (!workspaceHostCwd) { return null; } const hostCandidate = resolveBackendHostWorkdirCandidate(params); if (hostCandidate) { const mappedWorkdir = await mapExistingHostWorkspacePath({ hostPath: hostCandidate.hostPath, sandbox: params.sandbox, }); if (mappedWorkdir.kind === "available") { return await validateBackendWorkdir({ workdir: mappedWorkdir.workdir, sandbox: params.sandbox, }); } if (mappedWorkdir.kind === "missing") { return await validateBackendWorkdir({ workdir: { hostCwd: workspaceHostCwd, containerCwd: joinContainerWorkdir( params.sandbox.containerWorkdir, mappedWorkdir.relative, ), scriptPreflightCwd: null, }, sandbox: params.sandbox, }); } if (hostCandidate.failIfInvalid && mappedWorkdir.kind === "invalid") { return null; } } const containerCwd = resolveBackendContainerWorkdir(params); if (containerCwd) { return await validateBackendWorkdir({ workdir: { hostCwd: workspaceHostCwd, containerCwd, scriptPreflightCwd: null, }, sandbox: params.sandbox, }); } return null; } async function resolveHostValidatedSandboxWorkdir(params: { workdir: string; sandbox: BashSandboxConfig; }): Promise { const mappedHostWorkdir = mapContainerWorkdirToHost({ workdir: params.workdir, sandbox: params.sandbox, }); const candidateWorkdir = mappedHostWorkdir ?? params.workdir; try { const resolved = await assertSandboxPath({ filePath: candidateWorkdir, cwd: params.sandbox.workspaceDir, root: params.sandbox.workspaceDir, }); const stats = await fs.stat(resolved.resolved); if (!stats.isDirectory()) { return null; } const relative = resolved.relative ? resolved.relative.split(path.sep).join(path.posix.sep) : ""; const containerCwd = joinContainerWorkdir(params.sandbox.containerWorkdir, relative); return { hostCwd: resolved.resolved, containerCwd, scriptPreflightCwd: resolved.resolved }; } catch { return null; } } async function resolveSandboxWorkdir(params: { workdir: string; sandbox: BashSandboxConfig; }): Promise { if (hasParentPathSegment(params.workdir)) { return null; } if (params.sandbox.workdirValidation === "backend") { return await resolveBackendValidatedSandboxWorkdir(params); } return await resolveHostValidatedSandboxWorkdir(params); } export function formatUnavailableWorkdirFailure(workdir: string): string { return [ `workdir "${workdir}" is unavailable or not a directory: command was not executed.`, 'workdir is treated as a literal path; shell expansions such as "~" are not applied.', "Use an existing directory, omit an explicit workdir to use the default cwd, or update the configured default cwd.", ].join(" "); } export async function resolveExecWorkdir(params: { host: ExecHost; workdir?: string; defaultCwd?: string; sandbox?: BashSandboxConfig; }): Promise { const explicitWorkdir = normalizeExplicitWorkdirInput(params.workdir); if (explicitWorkdir.kind === "blank") { return unavailable(explicitWorkdir.raw); } if (params.host === "node") { return explicitWorkdir.kind === "specified" ? { kind: "node", remoteCwd: explicitWorkdir.value } : { kind: "node" }; } const defaultCwd = normalizeOptionalString(params.defaultCwd); if (params.host === "sandbox") { const sandbox = params.sandbox; if (!sandbox) { throw new Error("exec internal error: sandbox workdir resolution requires sandbox config"); } const requestedCwd = explicitWorkdir.kind === "specified" ? explicitWorkdir.value : (defaultCwd ?? sandbox.containerWorkdir); const resolved = await resolveSandboxWorkdir({ workdir: requestedCwd, sandbox }); return resolved ? { kind: "sandbox", hostCwd: resolved.hostCwd, containerCwd: resolved.containerCwd, scriptPreflightCwd: resolved.scriptPreflightCwd, } : unavailable(requestedCwd); } const requestedCwd = explicitWorkdir.kind === "specified" ? explicitWorkdir.value : (defaultCwd ?? safeCurrentCwd()); if (!requestedCwd) { return unavailable("current working directory"); } const resolved = resolveExistingHostWorkdir(requestedCwd); return resolved ? { kind: "local", hostCwd: resolved } : unavailable(requestedCwd); }