From 7dbe21b9cff2c8a177c180de3bd2e3f0e95dafb6 Mon Sep 17 00:00:00 2001 From: Feng Date: Thu, 13 Aug 2026 11:18:15 +0800 Subject: [PATCH] fix(agents): hydrate CLI images from agent workspaces (#122684) * fix(agents): hydrate CLI images from agent workspace * fix(agents): preserve resolved CLI workspace owner * fix(agents): keep workspace owner in prepared params * fix(agents): resolve CLI owner before preparation * fix(agents): preserve CLI runtime policy owner --------- Co-authored-by: Adkid-Zephyr <169631528+Adkid-Zephyr@users.noreply.github.com> Co-authored-by: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com> --- src/agents/cli-runner.helpers.test.ts | 50 +++++++++++++++++++++++++++ src/agents/cli-runner.spawn.test.ts | 27 +++++++++++++++ src/agents/cli-runner/execute.ts | 2 ++ src/agents/cli-runner/helpers.ts | 2 ++ src/agents/cli-runner/prepare.test.ts | 31 ++++++++++++++++- src/agents/cli-runner/prepare.ts | 8 +++-- 6 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index 94b9d81fec3c..fb6eb946e692 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js"; import { buildInboundMediaNoteProjection } from "../auto-reply/media-note.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; import { escapeRegExp } from "../shared/regexp.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { @@ -107,6 +108,55 @@ describe("prepareCliPromptImagePayload prompt references", () => { } }); + it("hydrates structured media from the active agent workspace without widening sibling access", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-agent-image-")); + 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 = createSolidPngBuffer(1, 1, { r: 255, g: 0, b: 0 }); + 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: { default: true, workspace: workspaceDir }, + merlin: { workspace: siblingWorkspaceDir }, + }, + }, + }; + + try { + const localRoots = getAgentScopedMediaLocalRoots(config, "arthur"); + const prepared = await prepareCliPromptImagePayload({ + backend: { command: "claude", input: "stdin" }, + prompt: "describe the attachment", + workspaceDir, + localRoots, + media: [{ path: imagePath, contentType: "image/png" }], + }); + + expect(prepared.imagePaths).toHaveLength(1); + await expect(fs.readFile(prepared.imagePaths?.[0] ?? "")).resolves.toEqual(image); + await expect( + prepareCliPromptImagePayload({ + backend: { command: "claude", input: "stdin" }, + prompt: "describe the attachment", + workspaceDir, + localRoots, + media: [{ path: siblingImagePath, contentType: "image/png" }], + }), + ).rejects.toThrow("failed to hydrate 1 structured image attachment"); + } finally { + envSnapshot.restore(); + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + it("dedupes repeated refs and skips failed loads before sanitizing", async () => { const workspaceDir = await fs.mkdtemp( path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-ref-dedupe-"), diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 87edbb63b244..00c37632e840 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { markMcpLoopbackToolCallFinished, @@ -149,6 +150,32 @@ async function createCliPackageFixture(version: string): Promise<{ } describe("runCliAgent spawn path", () => { + it("hydrates a session-key-owned agent workspace image before spawning the CLI", async () => { + const stateDir = tempDirs.make("openclaw-cli-agent-image-"); + const workspaceDir = path.join(stateDir, "workspace-arthur"); + const imagePath = path.join(workspaceDir, "media", "inbound", "photo.png"); + const image = createSolidPngBuffer(1, 1, { r: 255, g: 0, b: 0 }); + await fs.mkdir(path.dirname(imagePath), { recursive: true }); + await fs.writeFile(imagePath, image); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); + const context = buildPreparedCliRunContext({ + sessionKey: "agent:arthur:main", + agentId: "arthur", + workspaceDir, + config: { + agents: { entries: { arthur: { default: true, workspace: workspaceDir } } }, + }, + backend: { imageArg: "--image" }, + }); + context.params.media = [{ path: imagePath, contentType: "image/png" }]; + + await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "ok" }); + const spawn = requireRecord(mockCallArg(supervisorSpawnMock), "CLI spawn"); + const hydratedPath = requireArgAfter(spawn.argv as string[], "--image"); + await expect(fs.readFile(hydratedPath)).resolves.toEqual(image); + }); + it("formats output digests without logging response content", () => { expect(formatCliBackendOutputDigest("one")).toBe("outBytes=3 outHash=7692c3ad3540"); expect(formatCliBackendOutputDigest("∑")).toBe("outBytes=3 outHash=be27c7179a61"); diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 84731e11f863..2c5a2c008a6c 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -6,6 +6,7 @@ import { isTruthyEnvValue } from "../../infra/env.js"; import { formatErrorMessage, toErrorObject } from "../../infra/errors.js"; import { sanitizeHostExecEnv } from "../../infra/host-env-security.js"; import { compareValidSemver } from "../../infra/semver.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import type { CliBackendThinkingLevel } from "../../plugins/cli-backend.types.js"; import { applySkillEnvOverridesFromSnapshot } from "../../skills/runtime/env-overrides.js"; import { appendBootstrapPromptWarning } from "../bootstrap-budget.js"; @@ -185,6 +186,7 @@ export async function executePreparedCliRun( prompt, imagePrompt: params.imagePrompt, workspaceDir: context.workspaceDir, + localRoots: getAgentScopedMediaLocalRoots(params.config ?? {}, params.agentId), images: params.images, imageOrder: params.imageOrder, media: params.media, diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 5053bdde8079..0dcedb85f084 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -388,6 +388,7 @@ export async function prepareCliPromptImagePayload(params: { prompt: string; imagePrompt?: string; workspaceDir: string; + localRoots?: readonly string[]; images?: ImageContent[]; imageOrder?: PromptImageOrderEntry[]; media?: MediaFact[]; @@ -411,6 +412,7 @@ export async function prepareCliPromptImagePayload(params: { existingImages: params.images, imageOrder: params.imageOrder, maxBytes: MAX_IMAGE_BYTES, + localRoots: params.localRoots, }) : undefined; if (imageResult?.failedMediaCount) { diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 69ca9b3cd8f6..d9085a226de8 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -419,6 +419,34 @@ describe("prepareCliRunContext", () => { fixture.cleanup(); }); + it("carries the session-key-derived workspace owner into prepared params", async () => { + const { dir } = fixture.session; + const arthurWorkspace = path.join(dir, "workspace-arthur"); + const normalizeConfig = vi.fn((config: CliBackendPlugin["config"]) => config); + setRawCliBackendForPrepareTest({ ...defaultTestCliBackend, normalizeConfig }); + const config = { + agents: { + list: [ + { id: "main", default: true, workspace: path.join(dir, "workspace-main") }, + { id: "arthur", workspace: arthurWorkspace }, + ], + }, + } satisfies OpenClawConfig; + const context = await fixture.prepare({ + sessionKey: "agent:arthur:main", + workspaceDir: arthurWorkspace, + config, + }); + + expect(normalizeConfig).toHaveBeenCalledWith(expect.any(Object), { + backendId: "test-cli", + agentId: "arthur", + config, + }); + expect(context.params.agentId).toBe("arthur"); + expect(context.workspaceDir).toBe(arthurWorkspace); + }); + it("honors an explicit auth agent directory independently of session identity", async () => { const { dir } = fixture.session; const modelOwnerAgentDir = path.join(dir, "ops-agent"); @@ -4289,7 +4317,7 @@ describe("prepareCliRunContext", () => { expect(getLiveSessionGeneration).toHaveBeenCalledWith({ backendId: "claude-cli", agentAccountId: undefined, - agentId: undefined, + agentId: "main", authProfileId: undefined, sessionId: "session-test", sessionKey: "agent:main:telegram:direct:peer", @@ -4303,6 +4331,7 @@ describe("prepareCliRunContext", () => { mode: "reuse", sessionId: "warm-claude-sid", }); + expect(context.params.agentId).toBe("main"); expect(context.requiredClaudeLiveSessionGeneration).toBe("warm-live-generation"); expect(context.openClawHistoryPrompt).toContain("earlier warm context"); expect(context.openClawHistoryPrompt).toContain("warm follow-up"); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 7f52f4e4600d..dae7acec53cd 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -444,7 +444,7 @@ export async function prepareCliRunContext( preparedRunAdmission: candidate.preparedRunAdmission, }); const { preparedRunAdmission: _preparedRunAdmission, ...rest } = candidate; - return { ...rest, admittedRunContext }; + return { ...rest, agentId: workspaceResolution.agentId, admittedRunContext }; }; const runtimeChatType = params.chatType ?? params.sessionEntry?.chatType; const workspaceResolution = resolveRunWorkspaceDir({ @@ -466,8 +466,10 @@ export async function prepareCliRunContext( const cwd = params.cwd ? resolveUserPath(params.cwd) : workspaceDir; const cwdHash = hashCliSessionText(cwd); + // params.agentId may identify a distinct runtime-policy requester. Backend + // config and managed process reuse must key from the resolved session owner. const backendResolved = resolveCliBackendConfig(params.provider, params.config, { - agentId: params.agentId, + agentId: workspaceResolution.agentId, }); if (!backendResolved) { throw new Error(`Unknown CLI backend: ${params.provider}`); @@ -1414,7 +1416,7 @@ export async function prepareCliRunContext( prepareDeps.getClaudeGeneration({ backendId: backendResolved.id, agentAccountId: params.agentAccountId, - agentId: params.agentId, + agentId: workspaceResolution.agentId, authProfileId: effectiveAuthProfileId, sessionId: params.sessionId, sessionKey: params.sessionKey,