fix(agents): scope embedded prompt media roots to the owning agent (#124957)

* fix(agents): scope embedded prompt media roots to the owning agent (#123273)

* test(agents): exercise session-key-first media owner resolution per review

* fix(agents): hydrate embedded images from agent workspaces

Image attachments sent to a named (non-default) agent never reached the
model. The embedded prompt path hydrated images without an agent-scoped
media-root allowlist, so it fell back to the default roots, which grant
<stateDir>/workspace but not a named agent's workspace-<id>.

Both call sites now carry the owner already resolved for that path instead of
re-parsing session identity during prompt hydration. The plugin-harness path
passes workspace.sessionAgentId, and the settled embedded path passes
setup.sessionAgentId. The former threw on failure; the latter returned early
and silently dropped the image.

Roots are scoped only when workspaceOnly is off, because images.ts falls back
to [workspaceDir] when localRoots is absent. Passing agent-scoped roots
unconditionally would widen workspaceOnly rather than narrow it. Sibling
workspaces remain blocked because the scoped roots add only the owning agent's
workspace.

Related: #123273

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: sallyom <somalley@redhat.com>

* fix(agents): preserve scoped media on history replay

Signed-off-by: sallyom <somalley@redhat.com>

* fix(agents): scope native video replay media

Signed-off-by: sallyom <somalley@redhat.com>

* fix(media): preserve scoped workspace isolation

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: Sai Sashankh D <530713+sashankh@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: sallyom <somalley@redhat.com>
This commit is contained in:
Shubhankar Tripathy
2026-08-17 01:09:34 -05:00
committed by GitHub
parent 84f342a87e
commit 2e52aa436e
10 changed files with 349 additions and 25 deletions
@@ -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 }
@@ -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,
@@ -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<unknown[]>;
};
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",
@@ -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 }
@@ -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<typeof import("../../provider-stream.js")>()),
registerProviderStreamForModel,
}));
type SettleInput = Parameters<typeof settleEmbeddedAttemptStream>[0];
type PrepareTransportInput = Parameters<typeof prepareEmbeddedAttemptTransport>[0];
const MP4 = Buffer.from("0000001c6674797069736f6d0000000069736f6d0000000000000000", "hex");
function createSettleFixture(overrides?: Partial<SettleInput>): 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 });
}
});
});
@@ -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 }
@@ -10,6 +10,7 @@ import { prepareEmbeddedAttemptPromptExecution } from "./attempt-prompt-submit.j
async function preparePluginHarnessPromptImages(params: {
runParams: Parameters<typeof prepareEmbeddedAttemptPromptExecution>[0]["attempt"];
runtime: {
agentId?: string;
workspaceDir: string;
model: Parameters<typeof prepareEmbeddedAttemptPromptExecution>[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<typeof preparePluginHarnessPromptImages>[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<typeof prepareEmbeddedAttemptPromptExecution>[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 {
@@ -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: "",
+41 -5
View File
@@ -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, "<html>test</html>");
await fs.writeFile(siblingMediaPath, "<html>secret</html>");
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, "<html>test</html>");
await fs.writeFile(siblingMediaPath, "<html>secret</html>");
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 () => {
+22 -19
View File
@@ -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) {