diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-submit.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-submit.ts index c4f017fd0838..cb8c7cd238c0 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-submit.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-submit.ts @@ -5,6 +5,7 @@ import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { ImageContent } from "../../../llm/types.js"; +import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; import { readPersistedMediaFacts } from "../../../media/media-facts.js"; import type { createTrajectoryRuntimeRecorder } from "../../../trajectory/runtime.js"; import { resolveImageSanitizationLimits } from "../../image-sanitization.js"; @@ -315,6 +316,8 @@ function emptyPromptImages(): PromptImageResult { export async function prepareEmbeddedAttemptPromptExecution(input: { attempt: PromptExecutionAttempt; + /** Prepared run owner; scopes media roots without re-resolving session identity. */ + mediaOwnerAgentId: string; effectiveFsWorkspaceOnly: boolean; effectiveWorkspace: string; prompt: string; @@ -350,6 +353,9 @@ export async function prepareEmbeddedAttemptPromptExecution(input: { maxBytes: MAX_IMAGE_BYTES, maxDimensionPx: resolveImageSanitizationLimits(attempt.config).maxDimensionPx, workspaceOnly: input.effectiveFsWorkspaceOnly, + localRoots: input.effectiveFsWorkspaceOnly + ? undefined + : getAgentScopedMediaLocalRoots(attempt.config ?? {}, input.mediaOwnerAgentId), sandbox: input.sandbox?.enabled && input.sandbox.fsBridge ? { root: input.sandbox.workspaceDir, bridge: input.sandbox.fsBridge } diff --git a/src/agents/embedded-agent-runner/run/attempt-settle.ts b/src/agents/embedded-agent-runner/run/attempt-settle.ts index 28e8ef7dff91..a72f44b51888 100644 --- a/src/agents/embedded-agent-runner/run/attempt-settle.ts +++ b/src/agents/embedded-agent-runner/run/attempt-settle.ts @@ -238,6 +238,7 @@ export async function runEmbeddedAttemptSettledPhase( toolResultPromptProjectionState, }, execution: { + mediaOwnerAgentId: input.setup.sessionAgentId, effectiveFsWorkspaceOnly: input.setup.effectiveFsWorkspaceOnly, effectiveWorkspace: input.setup.effectiveWorkspace, sandbox: input.setup.sandbox, diff --git a/src/agents/embedded-agent-runner/run/attempt-setup.test.ts b/src/agents/embedded-agent-runner/run/attempt-setup.test.ts index 1ea9377f52fd..ca2bc3c8a445 100644 --- a/src/agents/embedded-agent-runner/run/attempt-setup.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-setup.test.ts @@ -1,7 +1,10 @@ +import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { attachRuntimePromptMediaFacts } from "../../../media/media-facts.js"; import type { ProviderRuntimePluginHandle } from "../../../plugins/provider-hook-runtime.js"; +import { castAgentMessage } from "../../test-helpers/agent-message-fixtures.js"; import type { EmbeddedRunAttemptParams } from "./types.js"; const resolveProviderRuntimePluginHandle = vi.hoisted(() => vi.fn()); @@ -14,7 +17,14 @@ vi.mock("../../../plugins/provider-hook-runtime.js", async (importOriginal) => ( vi.mock("../../sandbox.js", () => ({ resolveSandboxContext })); -import { prepareEmbeddedAttemptSetup, resolveAttemptWorkspaceSandbox } from "./attempt-setup.js"; +import { + installEmbeddedAttemptContextGuards, + prepareEmbeddedAttemptSetup, + resolveAttemptWorkspaceSandbox, +} from "./attempt-setup.js"; + +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsTAAALEwEAmpwYAAAADUlEQVR4nGP4////KwAJ5gPoxLp9owAAAABJRU5ErkJggg=="; describe("prepareEmbeddedAttemptSetup", () => { beforeEach(() => { @@ -43,6 +53,59 @@ describe("prepareEmbeddedAttemptSetup", () => { expect(setup.sessionAgentId).toBe("marketing"); }); + it("hydrates recent history media from the prepared session agent workspace", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-history-")); + const imagePath = path.join(workspaceDir, "photo.png"); + await fs.writeFile(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); + const agent = {} as { + transformContext?: (messages: unknown[], signal?: AbortSignal) => Promise; + }; + const settingsManager = { getBlockImages: () => false }; + const guards = installEmbeddedAttemptContextGuards({ + activeSession: { agent, settingsManager } as never, + agentDir: workspaceDir, + attempt: { + config: { agents: { list: [{ id: "marketing", workspace: workspaceDir }] } }, + contextTokenBudget: 32_000, + model: { input: ["text", "image"] }, + modelId: "gpt-5.4", + provider: "openai", + } as unknown as EmbeddedRunAttemptParams, + computerContextEpoch: { value: 0 }, + dropThinkingBlocksForEstimate: false, + effectiveCwd: workspaceDir, + effectiveFsWorkspaceOnly: false, + effectiveWorkspace: workspaceDir, + getPrePromptMessageCount: () => 0, + getPromptCache: () => undefined, + getPromptCacheRetention: () => undefined, + getSystemPrompt: () => "", + isOpenAIResponsesApi: false, + repairToolUseResultPairing: false, + sessionAgentId: "marketing", + sessionManager: {} as never, + settingsManager: settingsManager as never, + }); + const message = attachRuntimePromptMediaFacts( + castAgentMessage({ role: "user", content: [{ type: "text", text: "describe" }] }), + [{ path: imagePath, contentType: "image/png" }], + ); + + try { + if (!agent.transformContext) { + throw new Error("expected installed history transform"); + } + const replay = await agent.transformContext([message]); + expect((replay[0] as { content?: unknown }).content).toEqual([ + { type: "text", text: "describe" }, + { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }, + ]); + } finally { + guards.remove(); + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + it("passes the resolved skill snapshot into sandbox synchronization", async () => { const skillsSnapshot = { prompt: "skills", diff --git a/src/agents/embedded-agent-runner/run/attempt-setup.ts b/src/agents/embedded-agent-runner/run/attempt-setup.ts index a5e18b13d5a0..cb042aa97413 100644 --- a/src/agents/embedded-agent-runner/run/attempt-setup.ts +++ b/src/agents/embedded-agent-runner/run/attempt-setup.ts @@ -22,6 +22,7 @@ import { freezeDiagnosticTraceContext, getActiveDiagnosticTraceContext, } from "../../../infra/diagnostic-trace-context.js"; +import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; import { isPluginMetadataSnapshotCompatible } from "../../../plugins/plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js"; import { @@ -432,6 +433,9 @@ export function installEmbeddedAttemptContextGuards(input: { maxBytes: MAX_IMAGE_BYTES, maxDimensionPx: resolveImageSanitizationLimits(attempt.config).maxDimensionPx, workspaceOnly: input.effectiveFsWorkspaceOnly, + localRoots: input.effectiveFsWorkspaceOnly + ? undefined + : getAgentScopedMediaLocalRoots(attempt.config ?? {}, input.sessionAgentId), sandbox: input.sandbox?.enabled && input.sandbox.fsBridge ? { root: input.sandbox.workspaceDir, bridge: input.sandbox.fsBridge } diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts b/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts index 0ebe08151c20..c2358b4ac687 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts @@ -1,15 +1,32 @@ // Settlement liveness: a wedged block-reply flush must not park the turn. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + resolveProviderContext, + type ProviderStreamOptions, +} from "../../../../packages/ai/src/provider-types.js"; import { bindStreamLlmRuntime } from "../../../llm/model-runtime-binding.js"; +import { attachRuntimePromptMediaFacts } from "../../../media/media-facts.js"; import { SessionManager } from "../../sessions/index.js"; +import { castAgentMessage } from "../../test-helpers/agent-message-fixtures.js"; import { RUN_LIVENESS_JOIN_TIMEOUT_MS } from "./abortable.js"; import { prepareEmbeddedAttemptTransport, settleEmbeddedAttemptStream, } from "./attempt-stream-settle.js"; +const registerProviderStreamForModel = vi.hoisted(() => vi.fn()); + +vi.mock("../../provider-stream.js", async (importOriginal) => ({ + ...(await importOriginal()), + registerProviderStreamForModel, +})); + type SettleInput = Parameters[0]; type PrepareTransportInput = Parameters[0]; +const MP4 = Buffer.from("0000001c6674797069736f6d0000000069736f6d0000000000000000", "hex"); function createSettleFixture(overrides?: Partial): SettleInput { const sessionManager = SessionManager.inMemory(); @@ -106,6 +123,10 @@ describe("settleEmbeddedAttemptStream liveness", () => { }); describe("prepareEmbeddedAttemptTransport", () => { + afterEach(() => { + registerProviderStreamForModel.mockReset(); + }); + it("applies the prepared transport to the live agent owner", async () => { const streamFn = vi.fn(); bindStreamLlmRuntime(streamFn, { @@ -167,4 +188,77 @@ describe("prepareEmbeddedAttemptTransport", () => { expect(result.effectiveAgentTransport).toBe("sse"); expect(session.agent.transport).toBe("sse"); }); + + it("materializes native video from the prepared session agent workspace", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transport-video-")); + const videoPath = path.join(workspaceDir, "history.mp4"); + await fs.writeFile(videoPath, MP4); + let providerOptions: ProviderStreamOptions | undefined; + const providerStream = vi.fn((_model, _context, options) => { + providerOptions = options as ProviderStreamOptions; + return {} as never; + }); + bindStreamLlmRuntime(providerStream, { + streamSimple: providerStream, + registry: { getApiProvider: () => undefined }, + } as never); + const session = { + agent: { + streamFn: providerStream, + transport: "auto", + }, + }; + const model = { + api: "test-api", + provider: "test-provider", + id: "test-model-video", + }; + registerProviderStreamForModel.mockReturnValue(providerStream); + + try { + await prepareEmbeddedAttemptTransport({ + attempt: { + config: { agents: { list: [{ id: "marketing", workspace: workspaceDir }] } }, + model, + modelId: model.id, + provider: model.provider, + runId: "run-native-video", + runtimePlan: { + auth: { forwardedAuthProfileId: undefined }, + transport: { resolveExtraParams: () => ({}) }, + }, + sessionId: "session-native-video", + }, + session, + settingsManager: { + getGlobalSettings: () => ({}), + getProjectSettings: () => ({}), + }, + sessionAgentId: "marketing", + workspaceDir, + workspaceOnly: false, + agentDir: workspaceDir, + abortSignal: new AbortController().signal, + getProviderRuntimeHandle: () => ({ provider: model.provider, modelId: model.id }), + sandboxSessionKey: "agent:marketing:test", + codeModeControlsEnabled: false, + providerPromptState: { state: {}, effectiveContextTokenBudget: 128_000 }, + } as unknown as PrepareTransportInput); + const message = attachRuntimePromptMediaFacts( + castAgentMessage({ role: "user", content: [{ type: "text", text: "inspect" }] }), + [{ kind: "video", path: videoPath, contentType: "video/mp4" }], + ); + const context = { systemPrompt: "system", messages: [message], tools: [] }; + + session.agent.streamFn(model as never, context as never, {}); + const provider = await resolveProviderContext(context as never, providerOptions); + + expect(provider.messages[0]?.content).toEqual([ + { type: "text", text: "inspect" }, + { type: "video", data: MP4.toString("base64"), mimeType: "video/mp4" }, + ]); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts b/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts index bcbca7aa248c..eeea3182699d 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts @@ -5,6 +5,7 @@ import { formatErrorMessage } from "../../../infra/errors.js"; import { createCodexNativeWebSearchWrapper } from "../../../llm/providers/stream-wrappers/openai.js"; import type { AssistantMessage } from "../../../llm/types.js"; +import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; import type { ProviderRuntimePluginHandle } from "../../../plugins/provider-hook-runtime.js"; import { resolveProviderTextTransforms } from "../../../plugins/provider-runtime.js"; import type { AgentRunAttemptFailureSource } from "../../agent-run-terminal-outcome.js"; @@ -519,6 +520,9 @@ export async function prepareEmbeddedAttemptTransport(input: { context, workspaceDir: input.workspaceDir, workspaceOnly: input.workspaceOnly, + localRoots: input.workspaceOnly + ? undefined + : getAgentScopedMediaLocalRoots(attempt.config ?? {}, input.sessionAgentId), sandbox: input.sandbox?.enabled && input.sandbox.fsBridge ? { root: input.sandbox.workspaceDir, bridge: input.sandbox.fsBridge } diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.media.test.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.media.test.ts index a9b38d55a340..c76d0e1c687c 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.media.test.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.media.test.ts @@ -10,6 +10,7 @@ import { prepareEmbeddedAttemptPromptExecution } from "./attempt-prompt-submit.j async function preparePluginHarnessPromptImages(params: { runParams: Parameters[0]["attempt"]; runtime: { + agentId?: string; workspaceDir: string; model: Parameters[0]["attempt"]["model"]; }; @@ -24,6 +25,7 @@ async function preparePluginHarnessPromptImages(params: { } const result = await prepareEmbeddedAttemptPromptExecution({ attempt: { ...params.runParams, model: params.runtime.model }, + mediaOwnerAgentId: params.runtime.agentId ?? "main", effectiveWorkspace: params.runtime.workspaceDir, effectiveFsWorkspaceOnly: false, prompt: "", @@ -195,6 +197,116 @@ describe("plugin harness prompt media", () => { } }); + it("hydrates named-agent workspace images without opening sibling workspaces", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-harness-agent-media-")); + const workspaceDir = path.join(stateDir, "workspace-arthur"); + const siblingWorkspaceDir = path.join(stateDir, "workspace-merlin"); + const imagePath = path.join(workspaceDir, "media", "inbound", "photo.png"); + const siblingImagePath = path.join(siblingWorkspaceDir, "media", "inbound", "photo.png"); + const image = Buffer.from(TINY_PNG_BASE64, "base64"); + await fs.mkdir(path.dirname(imagePath), { recursive: true }); + await fs.mkdir(path.dirname(siblingImagePath), { recursive: true }); + await fs.writeFile(imagePath, image); + await fs.writeFile(siblingImagePath, image); + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + const config = { + agents: { + entries: { + arthur: { workspace: workspaceDir }, + merlin: { workspace: siblingWorkspaceDir }, + }, + }, + }; + + try { + const hydrate = (mediaPath: string, sessionId: string) => + preparePluginHarnessPromptImages({ + runParams: { + config, + media: [{ path: mediaPath, contentType: "image/png" }], + sessionId, + sessionKey: `agent:arthur:telegram:direct:${sessionId}`, + }, + runtime: { + agentId: "arthur", + model: { input: ["text", "image"] }, + sessionId, + workspaceDir, + }, + pluginHarnessOwnsTransport: true, + } as unknown as Parameters[0]); + + const result = await hydrate(imagePath, "session-agent-media"); + + expect(result.images).toEqual([ + { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }, + ]); + await expect(hydrate(siblingImagePath, "session-agent-sibling-media")).rejects.toThrow( + "failed to hydrate 1 structured image attachment", + ); + } finally { + envSnapshot.restore(); + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("hydrates named-agent workspace images on the embedded prompt path", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-embedded-agent-media-")); + const workspaceDir = path.join(stateDir, "workspace-arthur"); + const siblingWorkspaceDir = path.join(stateDir, "workspace-merlin"); + const imagePath = path.join(workspaceDir, "media", "inbound", "photo.png"); + const siblingImagePath = path.join(siblingWorkspaceDir, "media", "inbound", "photo.png"); + const image = Buffer.from(TINY_PNG_BASE64, "base64"); + await fs.mkdir(path.dirname(imagePath), { recursive: true }); + await fs.mkdir(path.dirname(siblingImagePath), { recursive: true }); + await fs.writeFile(imagePath, image); + await fs.writeFile(siblingImagePath, image); + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + + try { + const hydrate = (mediaPath: string, sessionId: string) => + prepareEmbeddedAttemptPromptExecution({ + attempt: { + config: { + agents: { + entries: { + arthur: { workspace: workspaceDir }, + merlin: { workspace: siblingWorkspaceDir }, + }, + }, + }, + media: [{ path: mediaPath, contentType: "image/png" }], + model: { input: ["text", "image"] }, + sessionId, + }, + mediaOwnerAgentId: "arthur", + effectiveWorkspace: workspaceDir, + effectiveFsWorkspaceOnly: false, + prompt: "", + skipPromptSubmission: false, + } as unknown as Parameters[0]); + + const owned = await hydrate(imagePath, "session-embedded-media"); + + expect(owned.images).toEqual([ + { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }, + ]); + expect(owned.failedMediaCount).toBe(0); + + // The embedded path returns before the plugin-harness throw, so a refused + // sibling read shows up as a failure count rather than a rejection. + const sibling = await hydrate(siblingImagePath, "session-embedded-sibling-media"); + + expect(sibling.images).toEqual([]); + expect(sibling.failedMediaCount).toBe(1); + } finally { + envSnapshot.restore(); + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + it("surfaces a failed image hydration before plugin dispatch", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-harness-failed-media-")); try { diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index 4288ac8e0ad4..01ee4bfe5582 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -205,6 +205,7 @@ export async function dispatchEmbeddedRunAttempt(input: { }); return await prepareEmbeddedAttemptPromptExecution({ attempt: { ...params, model: runtime.model }, + mediaOwnerAgentId: workspace.sessionAgentId, effectiveFsWorkspaceOnly: workspace.effectiveFsWorkspaceOnly, effectiveWorkspace: workspace.effectiveWorkspace, prompt: "", diff --git a/src/media/local-media-access.test.ts b/src/media/local-media-access.test.ts index 70cd308493c9..d211613506ca 100644 --- a/src/media/local-media-access.test.ts +++ b/src/media/local-media-access.test.ts @@ -97,29 +97,65 @@ describe("assertLocalMediaAllowed", () => { } }); - it("allows workspace-* paths when scoped localRoots include the agent workspace", async () => { + it("allows only the explicitly scoped workspace-* path under a broad root", async () => { const tmpDir = path.join( os.tmpdir(), `ocl-local-media-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); const workspaceDir = path.join(tmpDir, "workspace"); const workspaceXiaoqianDir = path.join(tmpDir, "workspace-xiaoqian"); + const workspaceMerlinDir = path.join(tmpDir, "workspace-merlin"); await fs.mkdir(workspaceDir, { recursive: true }); await fs.mkdir(workspaceXiaoqianDir, { recursive: true }); + await fs.mkdir(workspaceMerlinDir, { recursive: true }); const mediaPath = path.join(workspaceXiaoqianDir, "report.html"); + const siblingMediaPath = path.join(workspaceMerlinDir, "secret.html"); await fs.writeFile(mediaPath, "test"); + await fs.writeFile(siblingMediaPath, "secret"); try { - // Simulate scoped roots that include the agent's workspace-* directory - await expect( - assertLocalMediaAllowed(mediaPath, [workspaceDir, workspaceXiaoqianDir]), - ).resolves.toBeUndefined(); + const roots = [tmpDir, workspaceDir, workspaceXiaoqianDir]; + await expect(assertLocalMediaAllowed(mediaPath, roots)).resolves.toBeUndefined(); + await expect(assertLocalMediaAllowed(siblingMediaPath, roots)).rejects.toMatchObject({ + code: "path-not-allowed", + }); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); + it.runIf(process.platform !== "win32")( + "keeps sibling isolation when the default workspace is symlinked", + async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ocl-local-media-symlink-")); + const stateDir = path.join(tmpDir, "state"); + const workspaceDir = path.join(stateDir, "workspace"); + const realWorkspaceDir = path.join(tmpDir, "main-real"); + const ownWorkspaceDir = path.join(stateDir, "workspace-xiaoqian"); + const siblingWorkspaceDir = path.join(stateDir, "workspace-merlin"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.mkdir(realWorkspaceDir, { recursive: true }); + await fs.mkdir(ownWorkspaceDir, { recursive: true }); + await fs.mkdir(siblingWorkspaceDir, { recursive: true }); + await fs.symlink(realWorkspaceDir, workspaceDir); + const ownMediaPath = path.join(ownWorkspaceDir, "report.html"); + const siblingMediaPath = path.join(siblingWorkspaceDir, "secret.html"); + await fs.writeFile(ownMediaPath, "test"); + await fs.writeFile(siblingMediaPath, "secret"); + + try { + const roots = [tmpDir, workspaceDir, ownWorkspaceDir]; + await expect(assertLocalMediaAllowed(ownMediaPath, roots)).resolves.toBeUndefined(); + await expect(assertLocalMediaAllowed(siblingMediaPath, roots)).rejects.toMatchObject({ + code: "path-not-allowed", + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }, + ); + it.runIf(process.platform !== "win32")( "reads through an in-root directory symlink but rejects a final symlink", async () => { diff --git a/src/media/local-media-access.ts b/src/media/local-media-access.ts index 6f2ea755585f..a73113e22e6c 100644 --- a/src/media/local-media-access.ts +++ b/src/media/local-media-access.ts @@ -144,29 +144,32 @@ async function resolveLocalMediaBoundary( } const roots = localRoots ?? getDefaultLocalRootsCore(); const resolved = await resolveLocalMediaPathForContainment(mediaPath); - - if (localRoots === undefined) { - // Unscoped default roots include workspace, but not sibling workspace-* agent sandboxes. - const workspaceRoot = roots.find((root) => path.basename(root) === "workspace"); - if (workspaceRoot) { - const stateDir = path.dirname(workspaceRoot); - const rel = path.relative(stateDir, resolved); - if (rel && isPathInside(stateDir, resolved)) { - const firstSegment = rel.split(path.sep)[0] ?? ""; - if (firstSegment.startsWith("workspace-")) { - throw new LocalMediaAccessError( - "path-not-allowed", - `Local media path is not under an allowed directory: ${mediaPath}`, - ); - } - } - } - } - const resolvedRoots = options?.resolvedRoots ?? (await options?.resolveRoots?.()) ?? (await resolveLocalMediaRoots(roots)); + const workspaceRootIndex = roots.findIndex((root) => path.basename(root) === "workspace"); + const workspaceRoot = roots[workspaceRootIndex]; + if (workspaceRoot) { + const stateDir = await resolveCanonicalBoundaryPath(path.dirname(workspaceRoot)); + const rel = path.relative(stateDir, resolved); + const firstSegment = rel.split(path.sep)[0] ?? ""; + if (rel && isPathInside(stateDir, resolved) && firstSegment.startsWith("workspace-")) { + const agentWorkspace = path.join(stateDir, firstSegment); + // Broad roots such as the shared temp directory must not authorize sibling workspaces. + const hasScopedWorkspaceRoot = + localRoots !== undefined && + resolvedRoots.some( + (root) => isPathInside(agentWorkspace, root) && isPathInside(root, resolved), + ); + if (!hasScopedWorkspaceRoot) { + throw new LocalMediaAccessError( + "path-not-allowed", + `Local media path is not under an allowed directory: ${mediaPath}`, + ); + } + } + } for (const [index, resolvedRoot] of resolvedRoots.entries()) { const root = roots[index] ?? resolvedRoot; if (resolvedRoot === path.parse(resolvedRoot).root) {