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
209 changes: 209 additions & 0 deletions extensions/cli/src/e2e/headless-permission-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* E2E test for headless mode permission errors
* Verifies that when a tool requires permission in headless mode,
* an appropriate error message is displayed suggesting the --auto flag
*/
import * as http from "http";

import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
cleanupTestContext,
createTestContext,
runCLI,
} from "../test-helpers/cli-helpers.js";
import {
cleanupMockLLMServer,
setupMockLLMTest,
type MockLLMServer,
} from "../test-helpers/mock-llm-server.js";

describe("E2E: Headless Mode Permission Errors", () => {
let context: any;
let mockServer: MockLLMServer;

beforeEach(async () => {
context = await createTestContext();
});

afterEach(async () => {
if (mockServer) {
await cleanupMockLLMServer(mockServer);
}
await cleanupTestContext(context);
});
it("should display error message with --auto suggestion when tool requires permission", async () => {
// Set up mock LLM to return a tool call that requires permission (Write tool)
mockServer = await setupMockLLMTest(context, {
customHandler: (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});

req.on("end", () => {
if (req.method === "POST" && req.url === "/chat/completions") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});

// Send a Write tool call (requires permission by default)
res.write(
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_write","type":"function","function":{"name":"Write"}}]},"index":0}]}\n\n`,
);
res.write(
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"filepath\\":\\"test.txt\\",\\"content\\":\\"hello\\"}"}}]},"index":0}]}\n\n`,
);

// Send usage data
res.write(
`data: {"usage":{"prompt_tokens":10,"completion_tokens":20}}\n\n`,
);

// End the stream
res.write(`data: [DONE]\n\n`);
res.end();
} else {
res.writeHead(404);
res.end("Not found");
}
});
},
});

// Run CLI in headless mode without permission flags
const result = await runCLI(context, {
args: ["-p", "--config", context.configPath, "Create a file test.txt"],
timeout: 15000,
});

// Expect non-zero exit code
expect(result.exitCode).toBe(1);

// Verify the error message contains the expected information
expect(result.stderr).toContain("requires permission");
expect(result.stderr).toContain("headless mode");
expect(result.stderr).toContain("--auto");
expect(result.stderr).toContain("--allow");
expect(result.stderr).toContain("--exclude");
}, 20000);

it("should succeed with --auto flag when tool requires permission", async () => {
// Set up mock LLM to return a Write tool call
mockServer = await setupMockLLMTest(context, {
customHandler: (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});

req.on("end", () => {
if (req.method === "POST" && req.url === "/chat/completions") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});

// Send a Write tool call
res.write(
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_write","type":"function","function":{"name":"Write"}}]},"index":0}]}\n\n`,
);
res.write(
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"filepath\\":\\"test.txt\\",\\"content\\":\\"hello\\"}"}}]},"index":0}]}\n\n`,
);

// Send usage data
res.write(
`data: {"usage":{"prompt_tokens":10,"completion_tokens":20}}\n\n`,
);

// End the stream
res.write(`data: [DONE]\n\n`);
res.end();
} else {
res.writeHead(404);
res.end("Not found");
}
});
},
});

// Run CLI with --auto flag
const result = await runCLI(context, {
args: [
"-p",
"--auto",
"--config",
context.configPath,
"Create a file test.txt",
],
timeout: 15000,
});

// Should succeed
expect(result.exitCode).toBe(0);
expect(result.stderr).not.toContain("requires permission");
}, 20000);

it("should succeed with --allow flag for specific tool", async () => {
// Set up mock LLM to return a Write tool call
mockServer = await setupMockLLMTest(context, {
customHandler: (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});

req.on("end", () => {
if (req.method === "POST" && req.url === "/chat/completions") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});

// Send a Write tool call
res.write(
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_write","type":"function","function":{"name":"Write"}}]},"index":0}]}\n\n`,
);
res.write(
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"filepath\\":\\"test.txt\\",\\"content\\":\\"hello\\"}"}}]},"index":0}]}\n\n`,
);

// Send usage data
res.write(
`data: {"usage":{"prompt_tokens":10,"completion_tokens":20}}\n\n`,
);

// End the stream
res.write(`data: [DONE]\n\n`);
res.end();
} else {
res.writeHead(404);
res.end("Not found");
}
});
},
});

// Run CLI with --allow Write flag
const result = await runCLI(context, {
args: [
"-p",
"--allow",
"Write",
"--config",
context.configPath,
"Create a file test.txt",
],
timeout: 15000,
});

// Should succeed
expect(result.exitCode).toBe(0);
expect(result.stderr).not.toContain("requires permission");
}, 20000);
});
66 changes: 66 additions & 0 deletions extensions/cli/src/stream/streamChatResponse.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";

import type { PreprocessedToolCall } from "../tools/types.js";

import { handleHeadlessPermission } from "./streamChatResponse.helpers.js";

describe("streamChatResponse.helpers", () => {
describe("handleHeadlessPermission", () => {
it("should display error message and exit when tool requires permission in headless mode", async () => {
// Mock the tool call
const toolCall: PreprocessedToolCall = {
id: "call_123",
name: "Write",
arguments: { filepath: "test.txt", content: "hello" },
argumentsStr: '{"filepath":"test.txt","content":"hello"}',
startNotified: false,
tool: {
name: "Write",
displayName: "Write",
description: "Write to a file",
parameters: {
type: "object",
properties: {},
},
run: vi.fn(),
isBuiltIn: true,
},
};

// Mock safeStderr to capture output
const stderrOutputs: string[] = [];
vi.doMock("../init.js", () => ({
safeStderr: (message: string) => {
stderrOutputs.push(message);
},
}));

// Mock gracefulExit to prevent actual process exit
let exitCode: number | undefined;
vi.doMock("../util/exit.js", () => ({
gracefulExit: async (code: number) => {
exitCode = code;
},
}));

// Call the function (it should exit gracefully)
try {
await handleHeadlessPermission(toolCall);
} catch (error) {
// Expected to throw after exit
}

// Verify error message was displayed
const fullOutput = stderrOutputs.join("");
expect(fullOutput).toContain("requires permission");
expect(fullOutput).toContain("headless mode");
expect(fullOutput).toContain("--auto");
expect(fullOutput).toContain("--allow");
expect(fullOutput).toContain("--exclude");
expect(fullOutput).toContain("Write");

// Verify it tried to exit with code 1
expect(exitCode).toBe(1);
});
});
});
27 changes: 16 additions & 11 deletions extensions/cli/src/stream/streamChatResponse.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,25 +51,30 @@ export function handlePermissionDenied(
}

// Helper function to handle headless mode permission
export function handleHeadlessPermission(
export async function handleHeadlessPermission(
toolCall: PreprocessedToolCall,
): never {
): Promise<never> {
const allBuiltinTools = getAllBuiltinTools();
const tool = allBuiltinTools.find((t) => t.name === toolCall.name);
const toolName = tool?.displayName || toolCall.name;

console.error(
`Error: Tool '${toolName}' requires permission but cn is running in headless mode.`,
// Import safeStderr to bypass console blocking in headless mode
const { safeStderr } = await import("../init.js");
safeStderr(
`Error: Tool '${toolName}' requires permission but cn is running in headless mode.\n`,
);
console.error(`If you want to allow this tool, use --allow ${toolName}.`);
console.error(
`If you don't want the tool to be included, use --exclude ${toolName}.`,
safeStderr(
`If you want to allow all tools without asking, use cn -p --auto "your prompt".\n`,
);
safeStderr(`If you want to allow this tool, use --allow ${toolName}.\n`);
safeStderr(
`If you don't want the tool to be included, use --exclude ${toolName}.\n`,
);

// Use graceful exit to flush telemetry even in headless denial
// Note: We purposely trigger an async exit without awaiting in this sync path
import("../util/exit.js").then(({ gracefulExit }) => gracefulExit(1));
// Throw to satisfy the never return type; process will exit shortly
const { gracefulExit } = await import("../util/exit.js");
await gracefulExit(1);
// This line will never be reached, but TypeScript needs it for the 'never' return type
throw new Error("Exiting due to headless permission requirement");
}

Expand Down Expand Up @@ -133,7 +138,7 @@ export async function checkToolPermissionApproval(
return { approved: true };
} else if (permissionCheck.permission === "ask") {
if (isHeadless) {
handleHeadlessPermission(toolCall);
await handleHeadlessPermission(toolCall);
}
const userApproved = await requestUserPermission(toolCall, callbacks);
return userApproved
Expand Down
Loading