From 0a0468c72092b7ceccc551b4353dd92f9c22cbb8 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 25 Jun 2026 23:42:58 +0200 Subject: [PATCH] feat: Add ACP logout support (#816) --- src/acp-agent.ts | 33 +++++++++++++++++++++++++++++++++ src/index.ts | 11 +++++++---- src/tests/acp-agent.test.ts | 18 ++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 8f19b9c8..ae6383db 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -17,6 +17,7 @@ import { ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + LogoutRequest, methods, ndJsonStream, NewSessionRequest, @@ -74,11 +75,13 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import { ContentBlockParam } from "@anthropic-ai/sdk/resources"; import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs"; +import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import type { Stats } from "node:fs"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { promisify } from "node:util"; import packageJson from "../package.json" with { type: "json" }; import { applyAskElicitationResponse, @@ -109,6 +112,8 @@ import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./u export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); +const execFileAsync = promisify(execFile); + const MAX_TITLE_LENGTH = 256; function sanitizeTitle(text: string): string { @@ -855,6 +860,9 @@ export class ClaudeAcpAgent { http: true, sse: true, }, + auth: { + logout: {}, + }, loadSession: true, sessionCapabilities: { additionalDirectories: {}, @@ -992,6 +1000,30 @@ export class ClaudeAcpAgent { throw new Error("Method not implemented."); } + async logout(_params: LogoutRequest): Promise { + // Clear in-memory gateway credentials supplied via `authenticate`. The + // gateway method never touches the on-disk credential store, so dropping + // this reference is the whole logout for that path. + this.gatewayAuthRequest = undefined; + + // For the Claude/Console login methods the credentials live in the native + // CLI's store (keychain or config dir), which only the binary can clear. + // `claude auth logout` is non-interactive and idempotent. + const cliPath = await claudeCliPath(); + try { + await execFileAsync(cliPath, ["auth", "logout"]); + } catch (error) { + const stderr = + typeof error === "object" && error && "stderr" in error + ? String((error as { stderr: unknown }).stderr).trim() + : undefined; + throw RequestError.internalError( + { stderr: stderr || undefined }, + `claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + async prompt(params: PromptRequest): Promise { const session = this.sessions[params.sessionId]; if (!session) { @@ -4572,6 +4604,7 @@ export function runAcp() { agent.setSessionConfigOption(ctx.params), ) .onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params)) + .onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params)) .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal), ) diff --git a/src/index.ts b/src/index.ts index 6b60e3ad..a6005663 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,10 +4,10 @@ import { resolveSettings } from "@anthropic-ai/claude-agent-sdk"; import { claudeCliPath, runAcp } from "./acp-agent.js"; import packageJson from "../package.json" with { type: "json" }; -if (process.argv.includes("--version") || process.argv.includes("-v")) { - console.log(packageJson.version); - process.exit(0); -} else if (process.argv.includes("--cli")) { +// `--cli` is checked first so that `--version`/`-v` (and any other flags) are +// forwarded to the wrapped native CLI rather than swallowed by our own version +// handler below. Our version flag only applies when not delegating. +if (process.argv.includes("--cli")) { const { spawn } = await import("node:child_process"); const args = process.argv.slice(2).filter((arg) => arg !== "--cli"); const child = spawn(await claudeCliPath(), args, { stdio: "inherit" }); @@ -37,6 +37,9 @@ if (process.argv.includes("--version") || process.argv.includes("-v")) { console.error(err); process.exit(1); }); +} else if (process.argv.includes("--version") || process.argv.includes("-v")) { + console.log(packageJson.version); + process.exit(0); } else { // Apply env vars from the managed-policy tier before any SDK call so the // SDK subprocess inherits them. Going through resolveSettings (vs. a raw diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 0db16106..72c7f01e 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -2523,6 +2523,24 @@ describe("stop reason propagation", () => { }); }); +describe("logout", () => { + function createMockAgent() { + const mockClient = { + sessionUpdate: async () => {}, + } as unknown as AcpClient; + return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + } + + it("advertises the logout capability during initialize", async () => { + const agent = createMockAgent(); + const response = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: {}, + }); + expect(response.agentCapabilities?.auth?.logout).toEqual({}); + }); +}); + describe("session/close", () => { function createMockAgent() { const mockClient = {