Compare commits

...

2 Commits

Author SHA1 Message Date
acp-release-bot[bot]
32abd34bc0 chore(main): release 0.31.2 (#605)
🤖 I have created a release *beep* *boop*
---


##
[0.31.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.1...v0.31.2)
(2026-04-28)


### Bug Fixes

* tolerate invalid values in settings.json
([#601](https://github.com/agentclientprotocol/claude-agent-acp/issues/601))
([67af018](67af018d02))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-04-28 10:21:32 +02:00
Alex Plãte
67af018d02 fix: tolerate invalid values in settings.json (#601)
A broken value in settings.json (e.g. an unknown permissions.defaultMode
or a non-string model) used to throw and prevent session creation. Match
the Claude Code SDK behavior by logging the bad value and falling back
to defaults instead, so a single typo no longer renders the agent
unusable.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-04-28 10:20:09 +02:00
7 changed files with 113 additions and 33 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.31.1"
".": "0.31.2"
}

View File

@@ -1,5 +1,12 @@
# Changelog
## [0.31.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.1...v0.31.2) (2026-04-28)
### Bug Fixes
* tolerate invalid values in settings.json ([#601](https://github.com/agentclientprotocol/claude-agent-acp/issues/601)) ([67af018](https://github.com/agentclientprotocol/claude-agent-acp/commit/67af018d0216238d8f3cd5bad04a55200518239b))
## [0.31.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.0...v0.31.1) (2026-04-27)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.31.1",
"version": "0.31.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.31.1",
"version": "0.31.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.20.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.31.1",
"version": "0.31.2",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",

View File

@@ -385,29 +385,36 @@ const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
bypass: "bypassPermissions",
};
export function resolvePermissionMode(defaultMode?: unknown): PermissionMode {
export function resolvePermissionMode(
defaultMode?: unknown,
logger: Logger = console,
): PermissionMode {
if (defaultMode === undefined) {
return "default";
}
if (typeof defaultMode !== "string") {
throw new Error("Invalid permissions.defaultMode: expected a string.");
logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
return "default";
}
const normalized = defaultMode.trim().toLowerCase();
if (normalized === "") {
throw new Error("Invalid permissions.defaultMode: expected a non-empty string.");
logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
return "default";
}
const mapped = PERMISSION_MODE_ALIASES[normalized];
if (!mapped) {
throw new Error(`Invalid permissions.defaultMode: ${defaultMode}.`);
logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
return "default";
}
if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
throw new Error(
"Invalid permissions.defaultMode: bypassPermissions is not available when running as root.",
logger.error(
"Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.",
);
return "default";
}
return mapped;
@@ -1720,6 +1727,7 @@ export class ClaudeAcpAgent implements Agent {
const permissionMode = resolvePermissionMode(
settingsManager.getSettings().permissions?.defaultMode,
this.logger,
);
// Extract options from _meta if provided
@@ -1858,7 +1866,12 @@ export class ClaudeAcpAgent implements Agent {
);
}
const models = await getAvailableModels(q, initializationResult.models, settingsManager);
const models = await getAvailableModels(
q,
initializationResult.models,
settingsManager,
this.logger,
);
const availableModes = [
{
@@ -2189,10 +2202,27 @@ function resolveModelPreference(models: ModelInfo[], preference: string): ModelI
return bestMatch;
}
function resolveSettingsModel(
models: ModelInfo[],
settingsModel: unknown,
logger: Logger,
): ModelInfo | null {
if (settingsModel === undefined) {
return null;
}
if (typeof settingsModel !== "string") {
const typeLabel = settingsModel === null ? "null" : typeof settingsModel;
logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`);
return null;
}
return resolveModelPreference(models, settingsModel);
}
async function getAvailableModels(
query: Query,
models: ModelInfo[],
settingsManager: SettingsManager,
logger: Logger,
): Promise<SessionModelState> {
const settings = settingsManager.getSettings();
@@ -2207,8 +2237,8 @@ async function getAvailableModels(
if (match) {
currentModel = match;
}
} else if (settings.model) {
const match = resolveModelPreference(models, settings.model);
} else {
const match = resolveSettingsModel(models, settings.model, logger);
if (match) {
currentModel = match;
}

View File

@@ -145,12 +145,12 @@ describe("ClaudeAcpAgent settings", () => {
expect(response.modes.currentModeId).toBe("default");
});
it("throws when permissions.defaultMode is not a string", async () => {
it("falls back to 'default' when permissions.defaultMode is invalid", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
permissions: {
defaultMode: 123,
defaultMode: "not-a-real-mode",
},
}),
);
@@ -158,18 +158,47 @@ describe("ClaudeAcpAgent settings", () => {
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQuery();
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
await expect(
(agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
// Bad mode is ignored at the usage site; session creation must not throw.
expect(getCapturedOptions().permissionMode).toBe("default");
expect(response.modes.currentModeId).toBe("default");
});
it("ignores model from settings when it is not a string", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
model: 123,
}),
).rejects.toThrow("Invalid permissions.defaultMode");
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const { setModelSpy } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
// Bad model is ignored at the usage site; falls back to the first SDK model.
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
expect(response.models.currentModelId).toBe("claude-sonnet-4-5");
});
it("resolves model aliases like opus[1m] to the correct model", async () => {

View File

@@ -1,5 +1,12 @@
import { describe, it, expect } from "vitest";
import { resolvePermissionMode } from "../acp-agent.js";
import { describe, it, expect, vi } from "vitest";
import { resolvePermissionMode, type Logger } from "../acp-agent.js";
function mockLogger() {
const error = vi.fn<(...args: any[]) => void>();
const log = vi.fn<(...args: any[]) => void>();
const logger: Logger = { log, error };
return { logger, error, log };
}
describe("resolvePermissionMode", () => {
it("returns 'default' when no mode is provided", () => {
@@ -26,18 +33,25 @@ describe("resolvePermissionMode", () => {
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
});
it("throws on non-string values", () => {
expect(() => resolvePermissionMode(123)).toThrow("expected a string");
expect(() => resolvePermissionMode(true)).toThrow("expected a string");
expect(() => resolvePermissionMode({})).toThrow("expected a string");
it("falls back to 'default' and logs on non-string values", () => {
for (const value of [123, true, {}]) {
const { logger, error } = mockLogger();
expect(resolvePermissionMode(value, logger)).toBe("default");
expect(error).toHaveBeenCalledWith(expect.stringContaining("expected a string"));
}
});
it("throws on empty string", () => {
expect(() => resolvePermissionMode("")).toThrow("expected a non-empty string");
expect(() => resolvePermissionMode(" ")).toThrow("expected a non-empty string");
it("falls back to 'default' and logs on empty string", () => {
for (const value of ["", " "]) {
const { logger, error } = mockLogger();
expect(resolvePermissionMode(value, logger)).toBe("default");
expect(error).toHaveBeenCalledWith(expect.stringContaining("expected a non-empty string"));
}
});
it("throws on unknown mode", () => {
expect(() => resolvePermissionMode("yolo")).toThrow("Invalid permissions.defaultMode: yolo");
it("falls back to 'default' and logs on unknown mode", () => {
const { logger, error } = mockLogger();
expect(resolvePermissionMode("yolo", logger)).toBe("default");
expect(error).toHaveBeenCalledWith(expect.stringContaining("yolo"));
});
});