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>
This commit is contained in:
Feng
2026-08-13 11:18:15 +08:00
committed by GitHub
parent edb941a508
commit 7dbe21b9cf
6 changed files with 116 additions and 4 deletions
+50
View File
@@ -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-"),
+27
View File
@@ -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");
+2
View File
@@ -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,
+2
View File
@@ -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) {
+30 -1
View File
@@ -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");
+5 -3
View File
@@ -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,