Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Repo: https://github.com/openclaw/acpx
### Fixes

- ACP/prompt blocks: preserve structured ACP prompt blocks instead of flattening them during prompt handling to support images and non-text. (#103) Thanks @vincentkoc.
- Images/prompt validation: validate structured image prompt block MIME types and base64 payloads, emit human-readable CLI usage errors, and add an explicit non-CI live Cursor ACP smoke test path. Thanks @vincentkoc.

## 2026.3.10 (v0.1.16)

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"release:ci": "release-it --ci",
"test": "pnpm run build:test && node --test dist-test/test/*.test.js",
"test:coverage": "pnpm run build:test && node --experimental-test-coverage --test-coverage-lines=83 --test-coverage-branches=76 --test-coverage-functions=86 --test dist-test/test/*.test.js",
"test:live": "pnpm run build:test && node --test dist-test/test/cursor-live.integration.js",
"typecheck": "tsgo --noEmit",
"typecheck:tsc": "tsc --noEmit"
},
Expand Down
66 changes: 38 additions & 28 deletions src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ import {
type NormalizedOutputError,
} from "./error-normalization.js";
import { flushPerfMetricsCapture, installPerfMetricsCapture } from "./perf-metrics-capture.js";
import { mergePromptSourceWithText, parsePromptSource, textPrompt } from "./prompt-content.js";
import {
mergePromptSourceWithText,
parsePromptSource,
PromptInputValidationError,
textPrompt,
} from "./prompt-content.js";
import { runQueueOwnerFromEnv } from "./queue-owner-env.js";
import {
DEFAULT_HISTORY_LIMIT,
Expand Down Expand Up @@ -90,35 +95,42 @@ async function readPrompt(
filePath: string | undefined,
cwd: string,
): Promise<import("./types.js").PromptInput> {
if (filePath) {
const source =
filePath === "-"
? await readPromptInputFromStdin()
: await fs.readFile(path.resolve(cwd, filePath), "utf8");
const prompt = mergePromptSourceWithText(source, promptParts.join(" "));
if (prompt.length === 0) {
throw new InvalidArgumentError("Prompt from --file is empty");
try {
if (filePath) {
const source =
filePath === "-"
? await readPromptInputFromStdin()
: await fs.readFile(path.resolve(cwd, filePath), "utf8");
const prompt = mergePromptSourceWithText(source, promptParts.join(" "));
if (prompt.length === 0) {
throw new InvalidArgumentError("Prompt from --file is empty");
}
return prompt;
}
return prompt;
}

const joined = promptParts.join(" ").trim();
if (joined.length > 0) {
return textPrompt(joined);
}
const joined = promptParts.join(" ").trim();
if (joined.length > 0) {
return textPrompt(joined);
}

if (process.stdin.isTTY) {
throw new InvalidArgumentError(
"Prompt is required (pass as argument, --file, or pipe via stdin)",
);
}
if (process.stdin.isTTY) {
throw new InvalidArgumentError(
"Prompt is required (pass as argument, --file, or pipe via stdin)",
);
}

const prompt = parsePromptSource(await readPromptInputFromStdin());
if (prompt.length === 0) {
throw new InvalidArgumentError("Prompt from stdin is empty");
}
const prompt = parsePromptSource(await readPromptInputFromStdin());
if (prompt.length === 0) {
throw new InvalidArgumentError("Prompt from stdin is empty");
}

return prompt;
return prompt;
} catch (error) {
if (error instanceof PromptInputValidationError) {
throw new InvalidArgumentError(error.message);
}
throw error;
}
}

function applyPermissionExitCode(result: {
Expand Down Expand Up @@ -1609,9 +1621,7 @@ Examples:
defaultCode: "USAGE",
origin: "cli",
});
if (requestedOutputPolicy.format === "json") {
await emitRequestedError(error, normalized, requestedOutputPolicy);
}
await emitRequestedError(error, normalized, requestedOutputPolicy);
process.exit(exitCodeForOutputErrorCode(normalized.code));
}

Expand Down
99 changes: 93 additions & 6 deletions src/prompt-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,35 @@ import type { ContentBlock } from "@agentclientprotocol/sdk";

export type PromptInput = ContentBlock[];

export class PromptInputValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "PromptInputValidationError";
}
}

function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}

function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}

function isBase64Data(value: string): boolean {
if (value.length === 0 || value.length % 4 !== 0) {
return false;
}
return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
}

function isImageMimeType(value: string): boolean {
return /^image\/[A-Za-z0-9.+-]+$/i.test(value);
}

function isTextBlock(value: unknown): value is Extract<ContentBlock, { type: "text" }> {
const record = asRecord(value);
return record?.type === "text" && typeof record.text === "string";
Expand All @@ -18,8 +40,10 @@ function isImageBlock(value: unknown): value is Extract<ContentBlock, { type: "i
const record = asRecord(value);
return (
record?.type === "image" &&
typeof record.mimeType === "string" &&
typeof record.data === "string"
isNonEmptyString(record.mimeType) &&
isImageMimeType(record.mimeType) &&
typeof record.data === "string" &&
isBase64Data(record.data)
);
}

Expand All @@ -29,15 +53,15 @@ function isResourceLinkBlock(
const record = asRecord(value);
return (
record?.type === "resource_link" &&
typeof record.uri === "string" &&
isNonEmptyString(record.uri) &&
(record.title === undefined || typeof record.title === "string") &&
(record.name === undefined || typeof record.name === "string")
);
}

function isResourcePayload(value: unknown): boolean {
const record = asRecord(value);
if (!record || typeof record.uri !== "string") {
if (!record || !isNonEmptyString(record.uri)) {
return false;
}
return record.text === undefined || typeof record.text === "string";
Expand All @@ -57,6 +81,55 @@ function isContentBlock(value: unknown): value is ContentBlock {
);
}

function getContentBlockValidationError(value: unknown, index: number): string | undefined {
const record = asRecord(value);
if (!record || typeof record.type !== "string") {
return `prompt[${index}] must be an ACP content block object`;
}

switch (record.type) {
case "text":
return typeof record.text === "string"
? undefined
: `prompt[${index}] text block must include a string text field`;
case "image":
if (!isNonEmptyString(record.mimeType)) {
return `prompt[${index}] image block must include a non-empty mimeType`;
}
if (!isImageMimeType(record.mimeType)) {
return `prompt[${index}] image block mimeType must start with image/`;
}
if (typeof record.data !== "string" || record.data.length === 0) {
return `prompt[${index}] image block must include non-empty base64 data`;
}
if (!isBase64Data(record.data)) {
return `prompt[${index}] image block data must be valid base64`;
}
return undefined;
case "resource_link":
if (!isNonEmptyString(record.uri)) {
return `prompt[${index}] resource_link block must include a non-empty uri`;
}
if (record.title !== undefined && typeof record.title !== "string") {
return `prompt[${index}] resource_link block title must be a string when present`;
}
if (record.name !== undefined && typeof record.name !== "string") {
return `prompt[${index}] resource_link block name must be a string when present`;
}
return undefined;
case "resource":
if (!asRecord(record.resource)) {
return `prompt[${index}] resource block must include a resource object`;
}
if (!isResourcePayload(record.resource)) {
return `prompt[${index}] resource block resource must include a non-empty uri and optional text`;
}
return undefined;
default:
return `prompt[${index}] has unsupported content block type ${JSON.stringify(record.type)}`;
}
}

export function isPromptInput(value: unknown): value is PromptInput {
return Array.isArray(value) && value.every((entry) => isContentBlock(entry));
}
Expand All @@ -76,8 +149,22 @@ function parseStructuredPrompt(source: string): PromptInput | undefined {
}
try {
const parsed = JSON.parse(source) as unknown;
return isPromptInput(parsed) ? parsed : undefined;
} catch {
if (isPromptInput(parsed)) {
return parsed;
}
if (Array.isArray(parsed)) {
const detail =
parsed
.map((entry, index) => getContentBlockValidationError(entry, index))
.find((message) => message !== undefined) ??
"Structured prompt JSON must be an array of valid ACP content blocks";
throw new PromptInputValidationError(detail);
}
return undefined;
} catch (error) {
if (error instanceof PromptInputValidationError) {
throw error;
}
return undefined;
}
}
Expand Down
45 changes: 45 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,51 @@ test("prompt preserves structured ACP prompt blocks through the queue owner", as
});
});

test("exec rejects structured image prompts with invalid mime types", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });

const result = await runCli(
["--agent", MOCK_AGENT_COMMAND, "--cwd", cwd, "--format", "quiet", "exec"],
homeDir,
{
stdin: JSON.stringify([
{ type: "text", text: "inspect-prompt" },
{ type: "image", mimeType: "application/json", data: "aW1hZ2U=" },
]),
},
);

assert.equal(result.code, 2);
assert.match(
`${result.stdout}\n${result.stderr}`,
/image block mimeType must start with image\//i,
);
});
});

test("exec rejects structured image prompts with invalid base64 payloads", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });

const result = await runCli(
["--agent", MOCK_AGENT_COMMAND, "--cwd", cwd, "--format", "quiet", "exec"],
homeDir,
{
stdin: JSON.stringify([
{ type: "text", text: "inspect-prompt" },
{ type: "image", mimeType: "image/png", data: "%%%" },
]),
},
);

assert.equal(result.code, 2);
assert.match(`${result.stdout}\n${result.stderr}`, /image block data must be valid base64/i);
});
});

test("prompt subcommand accepts --file without being consumed by parent command", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
Expand Down
Loading
Loading