mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
84f342a87e
commit
2e52aa436e
@@ -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: "",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user