Skip to content
Closed
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 @@ -12,6 +12,7 @@ Repo: https://github.com/openclaw/acpx

### Fixes

- Agents/kiro: run the built-in Kiro agent through `kiro-cli-chat acp`, sync the harness docs, and add coverage for the built-in command path. (#124) Thanks @vokako and @vincentkoc.
- Agents/gemini: default to `--acp` for Gemini CLI and fall back to `--experimental-acp` for pre-0.33 releases. (#113)
- 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ Built-ins:
| `iflow` | native (`iflow --experimental-acp`) | [iFlow CLI](https://github.com/iflow-ai/iflow-cli) |
| `kilocode` | `npx -y @kilocode/cli acp` | [Kilocode](https://kilocode.ai) |
| `kimi` | native (`kimi acp`) | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) |
| `kiro` | native (`kiro-cli acp`) | [Kiro CLI](https://kiro.dev) |
| `kiro` | native (`kiro-cli-chat acp`) | [Kiro CLI](https://kiro.dev) |
| `opencode` | `npx -y opencode-ai acp` | [OpenCode](https://opencode.ai) |
| `qwen` | native (`qwen --acp`) | [Qwen Code](https://github.com/QwenLM/qwen-code) |

Expand Down
2 changes: 1 addition & 1 deletion agents/Kiro.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Kiro

- Built-in name: `kiro`
- Default command: `kiro-cli acp`
- Default command: `kiro-cli-chat acp`
- Upstream: https://kiro.dev
4 changes: 2 additions & 2 deletions agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Built-in agents:
- `iflow -> iflow --experimental-acp`
- `kilocode -> npx -y @kilocode/cli acp`
- `kimi -> kimi acp`
- `kiro -> kiro-cli acp`
- `kiro -> kiro-cli-chat acp`
- `opencode -> npx -y opencode-ai acp`
- `qwen -> qwen --acp`

Expand All @@ -26,6 +26,6 @@ Harness-specific docs in this directory:
- [iFlow](Iflow.md): built-in `iflow -> iflow --experimental-acp`
- [Kilocode](Kilocode.md): built-in `kilocode -> npx -y @kilocode/cli acp`
- [Kimi](Kimi.md): built-in `kimi -> kimi acp`
- [Kiro](Kiro.md): built-in `kiro -> kiro-cli acp`
- [Kiro](Kiro.md): built-in `kiro -> kiro-cli-chat acp`
- [OpenCode](OpenCode.md): built-in `opencode -> npx -y opencode-ai acp`
- [Qwen](Qwen.md): built-in `qwen -> qwen --acp`
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"husky": "^9.1.7",
"lint-staged": "^16.3.2",
"markdownlint-cli2": "^0.21.0",
"oxfmt": "^0.36.0",
"oxfmt": "^0.37.0",
"oxlint": "^1.51.0",
"oxlint-tsgolint": "^0.16.0",
"tsdown": "^0.21.0-beta.2",
Expand Down
338 changes: 169 additions & 169 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion skills/acpx/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Friendly agent names resolve to commands:
- `droid` -> `droid exec --output-format acp`
- `kimi` -> `kimi acp`
- `opencode` -> `npx -y opencode-ai acp`
- `kiro` -> `kiro-cli acp`
- `kiro` -> `kiro-cli-chat acp`
- `kilocode` -> `npx -y @kilocode/cli acp`
- `qwen` -> `qwen --acp`

Expand Down
2 changes: 1 addition & 1 deletion src/agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const AGENT_REGISTRY: Record<string, string> = {
iflow: "iflow --experimental-acp",
kilocode: "npx -y @kilocode/cli acp",
kimi: "kimi acp",
kiro: "kiro-cli acp",
kiro: "kiro-cli-chat acp",
opencode: "npx -y opencode-ai acp",
qwen: "qwen --acp",
};
Expand Down
52 changes: 35 additions & 17 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
ClaudeAcpSessionCreateTimeoutError,
CopilotAcpUnsupportedError,
GeminiAcpStartupTimeoutError,
PermissionDeniedError,
PermissionPromptUnavailableError,
} from "./errors.js";
import { FileSystemHandlers } from "./filesystem.js";
Expand Down Expand Up @@ -1406,8 +1407,7 @@ export class AcpClient {
} catch (error) {
if (error instanceof PermissionPromptUnavailableError) {
this.notePromptPermissionFailure(params.sessionId, error);
this.permissionStats.requested += 1;
this.permissionStats.cancelled += 1;
this.recordPermissionDecision("cancelled");
return {
outcome: {
outcome: "cancelled",
Expand All @@ -1418,14 +1418,7 @@ export class AcpClient {
}

const decision = classifyPermissionDecision(params, response);
this.permissionStats.requested += 1;
if (decision === "approved") {
this.permissionStats.approved += 1;
} else if (decision === "denied") {
this.permissionStats.denied += 1;
} else {
this.permissionStats.cancelled += 1;
}
this.recordPermissionDecision(decision);

return response;
}
Expand Down Expand Up @@ -1484,16 +1477,19 @@ export class AcpClient {
}

private async handleReadTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return await this.filesystem.readTextFile(params);
try {
return await this.filesystem.readTextFile(params);
} catch (error) {
this.recordPermissionError(params.sessionId, error);
throw error;
}
}

private async handleWriteTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
try {
return await this.filesystem.writeTextFile(params);
} catch (error) {
if (error instanceof PermissionPromptUnavailableError) {
this.notePromptPermissionFailure(params.sessionId, error);
}
this.recordPermissionError(params.sessionId, error);
throw error;
}
}
Expand All @@ -1504,9 +1500,7 @@ export class AcpClient {
try {
return await this.terminalManager.createTerminal(params);
} catch (error) {
if (error instanceof PermissionPromptUnavailableError) {
this.notePromptPermissionFailure(params.sessionId, error);
}
this.recordPermissionError(params.sessionId, error);
throw error;
}
}
Expand All @@ -1533,6 +1527,30 @@ export class AcpClient {
return await this.terminalManager.releaseTerminal(params);
}

private recordPermissionDecision(decision: "approved" | "denied" | "cancelled"): void {
this.permissionStats.requested += 1;
if (decision === "approved") {
this.permissionStats.approved += 1;
return;
}
if (decision === "denied") {
this.permissionStats.denied += 1;
return;
}
this.permissionStats.cancelled += 1;
}

private recordPermissionError(sessionId: string, error: unknown): void {
if (error instanceof PermissionPromptUnavailableError) {
this.notePromptPermissionFailure(sessionId, error);
this.recordPermissionDecision("cancelled");
return;
}
if (error instanceof PermissionDeniedError) {
this.recordPermissionDecision("denied");
}
}

private async handleSessionUpdate(notification: SessionNotification): Promise<void> {
const sequence = ++this.observedSessionUpdates;
this.sessionUpdateChain = this.sessionUpdateChain.then(async () => {
Expand Down
46 changes: 46 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,52 @@ test("queued prompt failures remain visible in quiet mode", async () => {
});
});

test("non-queued write permission denial exits with code 5", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true });
await fs.writeFile(
path.join(homeDir, ".acpx", "config.json"),
`${JSON.stringify(
{
agents: {
codex: {
command: MOCK_AGENT_COMMAND,
},
},
},
null,
2,
)}\n`,
"utf8",
);

const session = await runCli(
["--cwd", cwd, "--format", "json", "codex", "sessions", "new"],
homeDir,
);
assert.equal(session.code, 0, session.stderr);

const writeResult = await runCli(
[
"--cwd",
cwd,
"--format",
"quiet",
"--approve-reads",
"codex",
"prompt",
`write ${path.join(cwd, "x.txt")} hi`,
],
homeDir,
);

assert.equal(writeResult.code, 5);
assert.match(writeResult.stdout, /error:\s*Internal error/i);
});
});

test("--json-strict suppresses session banners on stderr", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
Expand Down
96 changes: 95 additions & 1 deletion test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { PassThrough } from "node:stream";
import test from "node:test";
import type { RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk";
import { AcpClient, buildAgentSpawnOptions } from "../src/client.js";
import { AuthPolicyError, PermissionPromptUnavailableError } from "../src/errors.js";
import {
AuthPolicyError,
PermissionDeniedError,
PermissionPromptUnavailableError,
} from "../src/errors.js";

type ClientInternals = {
selectAuthMethod?: (methods: Array<{ id: string }>) =>
Expand All @@ -20,6 +24,22 @@ type ClientInternals = {
handlePermissionRequest?: (
params: RequestPermissionRequest,
) => Promise<RequestPermissionResponse>;
handleReadTextFile?: (params: {
sessionId: string;
path: string;
line?: number | null;
limit?: number | null;
}) => Promise<{ content: string }>;
handleWriteTextFile?: (params: {
sessionId: string;
path: string;
content: string;
}) => Promise<Record<string, never>>;
handleCreateTerminal?: (params: {
sessionId: string;
command: string;
args?: string[];
}) => Promise<{ terminalId: string }>;
notePromptPermissionFailure?: (
sessionId: string,
error: PermissionPromptUnavailableError,
Expand All @@ -34,8 +54,26 @@ type ClientInternals = {
exitCode: number | null,
signal: NodeJS.Signals | null,
) => void;
filesystem?: {
readTextFile: (params: {
sessionId: string;
path: string;
line?: number | null;
limit?: number | null;
}) => Promise<{ content: string }>;
writeTextFile: (params: {
sessionId: string;
path: string;
content: string;
}) => Promise<Record<string, never>>;
};
terminalManager?: {
shutdown: () => Promise<void>;
createTerminal?: (params: {
sessionId: string;
command: string;
args?: string[];
}) => Promise<{ terminalId: string }>;
};
cancel?: (sessionId: string) => Promise<void>;
connection?: unknown;
Expand Down Expand Up @@ -216,6 +254,62 @@ test("AcpClient handlePermissionRequest records approved decisions", async () =>
});
});

test("AcpClient client-method permission errors update permission stats", async () => {
const client = makeClient();
const internals = asInternals(client);

internals.filesystem = {
readTextFile: async () => {
throw new PermissionDeniedError("Permission denied for fs/read_text_file");
},
writeTextFile: async () => {
throw new PermissionDeniedError("Permission denied for fs/write_text_file");
},
};
internals.terminalManager = {
shutdown: async () => {},
createTerminal: async () => {
throw new PermissionPromptUnavailableError();
},
};

await assert.rejects(
async () =>
await internals.handleReadTextFile?.({
sessionId: "session-read",
path: "/tmp/read.txt",
}),
PermissionDeniedError,
);
await assert.rejects(
async () =>
await internals.handleWriteTextFile?.({
sessionId: "session-write",
path: "/tmp/write.txt",
content: "updated",
}),
PermissionDeniedError,
);
await assert.rejects(
async () =>
await internals.handleCreateTerminal?.({
sessionId: "session-terminal",
command: "echo",
args: ["hi"],
}),
PermissionPromptUnavailableError,
);

assert.deepEqual(client.getPermissionStats(), {
requested: 3,
approved: 0,
denied: 2,
cancelled: 1,
});
const noted = internals.consumePromptPermissionFailure?.("session-terminal");
assert(noted instanceof PermissionPromptUnavailableError);
});

test("AcpClient createSession forwards claudeCode options in _meta", async () => {
const client = makeClient({
sessionOptions: {
Expand Down
Loading