diff --git a/CHANGELOG.md b/CHANGELOG.md index 0377bf452..b4c2767b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ Docs: https://docs.openclaw.ai - Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526) - Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606) - Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc. +- Agents/Sandbox workdir mapping: map container workdir paths (for example `/workspace`) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (Related #30711) - Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204. - BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204. - Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204. diff --git a/src/agents/bash-tools.shared.test.ts b/src/agents/bash-tools.shared.test.ts new file mode 100644 index 000000000..7e455a693 --- /dev/null +++ b/src/agents/bash-tools.shared.test.ts @@ -0,0 +1,77 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveSandboxWorkdir } from "./bash-tools.shared.js"; + +async function withTempDir(run: (dir: string) => Promise) { + const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-bash-workdir-")); + try { + await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +describe("resolveSandboxWorkdir", () => { + it("maps container root workdir to host workspace", async () => { + await withTempDir(async (workspaceDir) => { + const warnings: string[] = []; + const resolved = await resolveSandboxWorkdir({ + workdir: "/workspace", + sandbox: { + containerName: "sandbox-1", + workspaceDir, + containerWorkdir: "/workspace", + }, + warnings, + }); + + expect(resolved.hostWorkdir).toBe(workspaceDir); + expect(resolved.containerWorkdir).toBe("/workspace"); + expect(warnings).toEqual([]); + }); + }); + + it("maps nested container workdir under the container workspace", async () => { + await withTempDir(async (workspaceDir) => { + const nested = path.join(workspaceDir, "scripts", "runner"); + await mkdir(nested, { recursive: true }); + const warnings: string[] = []; + const resolved = await resolveSandboxWorkdir({ + workdir: "/workspace/scripts/runner", + sandbox: { + containerName: "sandbox-2", + workspaceDir, + containerWorkdir: "/workspace", + }, + warnings, + }); + + expect(resolved.hostWorkdir).toBe(nested); + expect(resolved.containerWorkdir).toBe("/workspace/scripts/runner"); + expect(warnings).toEqual([]); + }); + }); + + it("supports custom container workdir prefixes", async () => { + await withTempDir(async (workspaceDir) => { + const nested = path.join(workspaceDir, "project"); + await mkdir(nested, { recursive: true }); + const warnings: string[] = []; + const resolved = await resolveSandboxWorkdir({ + workdir: "/sandbox-root/project", + sandbox: { + containerName: "sandbox-3", + workspaceDir, + containerWorkdir: "/sandbox-root", + }, + warnings, + }); + + expect(resolved.hostWorkdir).toBe(nested); + expect(resolved.containerWorkdir).toBe("/sandbox-root/project"); + expect(warnings).toEqual([]); + }); + }); +}); diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index 07b122660..2a9bc8216 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -85,9 +85,14 @@ export async function resolveSandboxWorkdir(params: { warnings: string[]; }) { const fallback = params.sandbox.workspaceDir; + const mappedHostWorkdir = mapContainerWorkdirToHost({ + workdir: params.workdir, + sandbox: params.sandbox, + }); + const candidateWorkdir = mappedHostWorkdir ?? params.workdir; try { const resolved = await assertSandboxPath({ - filePath: params.workdir, + filePath: candidateWorkdir, cwd: process.cwd(), root: params.sandbox.workspaceDir, }); @@ -113,6 +118,36 @@ export async function resolveSandboxWorkdir(params: { } } +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 "."; + } + return path.posix.normalize(normalized); +} + export function resolveWorkdir(workdir: string, warnings: string[]) { const current = safeCwd(); const fallback = current ?? homedir();