diff --git a/src/agent-command.ts b/src/agent-command.ts index c19de913..aba46864 100644 --- a/src/agent-command.ts +++ b/src/agent-command.ts @@ -1,3 +1,5 @@ +import { HIDE_CLAUDE_AUTH_FLAG } from "./hide-claude-auth.js"; + /** * The command that starts this agent again with the flags it was started with, * for a client to run in a terminal (e.g. `terminal-auth` login). @@ -17,3 +19,15 @@ export function agentCommand(argv: readonly string[] = process.argv): { const compiled = script.includes("$bunfs") || script.includes("~BUN"); return { command: process.execPath, args: compiled ? flags : [script, ...flags] }; } + +/** + * The arguments for the Claude CLI under `--cli`: the flags this process was + * started with, minus the ones the adapter owns. The terminal-auth command + * reruns the agent with its own flags (`--hide-claude-auth --cli auth login`), + * and the CLI rejects the unknown option `--hide-claude-auth`. `argv.slice(2)` + * is the flags under both Node and a compiled executable (see + * {@link agentCommand}). + */ +export function cliArgs(argv: readonly string[] = process.argv): string[] { + return argv.slice(2).filter((arg) => arg !== "--cli" && arg !== HIDE_CLAUDE_AUTH_FLAG); +} diff --git a/src/auth-methods.ts b/src/auth-methods.ts index 8ff81756..bd9b9f0f 100644 --- a/src/auth-methods.ts +++ b/src/auth-methods.ts @@ -12,10 +12,12 @@ import { shouldHideClaudeAuth } from "./hide-claude-auth.js"; * * Without either, the client cannot run a login, and the list is empty. On a * remote machine (SSH, `NO_BROWSER`, `CLAUDE_CODE_REMOTE`, as the SDK's own - * isRemote check) the OAuth redirect to localhost cannot reach a browser, and - * `claude auth login` falls back to a manual flow that does not work over ACP, - * so the one method runs the TUI's `claude /login` instead. The Claude - * subscription method is left out when `shouldHideClaudeAuth()` says so. + * isRemote check) the subscription login runs the TUI's `claude /login`: + * clients such as Tidewave set `NO_BROWSER` to get it. The Anthropic Console + * login is offered there too, because since Claude Code 2.1.126 `auth login` + * accepts the OAuth code pasted into the terminal when the localhost callback + * can't be reached. The Claude subscription method is left out when + * `shouldHideClaudeAuth()` says so. */ export function terminalAuthMethods({ terminal, @@ -42,10 +44,8 @@ export function terminalAuthMethods({ process.env.SSH_TTY || process.env.CLAUDE_CODE_REMOTE ); - if (isRemote) { - if (shouldHideClaudeAuth()) return []; - return [ - withMeta( + const subscriptionLogin = isRemote + ? withMeta( { description: "Run `claude /login` in the terminal", name: "Log in with Claude", @@ -54,13 +54,8 @@ export function terminalAuthMethods({ args: ["--cli"], }, "Claude Login", - ), - ]; - } - const methods: AuthMethod[] = []; - if (!shouldHideClaudeAuth()) { - methods.push( - withMeta( + ) + : withMeta( { description: "Use Claude subscription ", name: "Claude Subscription", @@ -69,9 +64,9 @@ export function terminalAuthMethods({ args: ["--cli", "auth", "login", "--claudeai"], }, "Claude Login", - ), - ); - } + ); + const methods: AuthMethod[] = []; + if (!shouldHideClaudeAuth()) methods.push(subscriptionLogin); methods.push( withMeta( { diff --git a/src/hide-claude-auth.ts b/src/hide-claude-auth.ts index a65da913..94b47a6e 100644 --- a/src/hide-claude-auth.ts +++ b/src/hide-claude-auth.ts @@ -35,8 +35,10 @@ import { RequestError } from "@agentclientprotocol/sdk/unstable"; import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk"; +export const HIDE_CLAUDE_AUTH_FLAG = "--hide-claude-auth"; + export function shouldHideClaudeAuth(): boolean { - return process.argv.includes("--hide-claude-auth"); + return process.argv.includes(HIDE_CLAUDE_AUTH_FLAG); } /** diff --git a/src/index.ts b/src/index.ts index 1cc9e06a..39b98031 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { cliArgs } from "./agent-command.js"; import { claudeCliPath } from "./claude-cli.js"; import { applyManagedPolicyEnv } from "./managed-policy.js"; import { acpProtocolsFromEnv, serveAcp } from "./serve.js"; @@ -12,7 +13,7 @@ import { join } from "node:path"; // 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 args = cliArgs(); const child = spawn(await claudeCliPath(), args, { stdio: "inherit" }); const signals = diff --git a/src/tests/agent-command.test.ts b/src/tests/agent-command.test.ts index 290db8f9..c9803a75 100644 --- a/src/tests/agent-command.test.ts +++ b/src/tests/agent-command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { agentCommand } from "../agent-command.js"; +import { agentCommand, cliArgs } from "../agent-command.js"; describe("agentCommand", () => { it.each([ @@ -22,3 +22,44 @@ describe("agentCommand", () => { expect(agentCommand(argv)).toEqual({ command: process.execPath, args }); }); }); + +describe("cliArgs", () => { + /** + * The `--cli` passthrough hands the Claude CLI the adapter's flags without `--cli` and + * `--hide-claude-auth`, under Node and in a compiled executable, and still forwards `--version`. + */ + it.each([ + { + scenario: "Node, terminal-auth login rerun", + argv: [ + "/usr/bin/node", + "/opt/acp/dist/index.js", + "--hide-claude-auth", + "--cli", + "auth", + "login", + "--console", + ], + args: ["auth", "login", "--console"], + }, + { + scenario: "a compiled executable", + argv: [ + "bun", + "/$bunfs/root/claude-agent-acp", + "--hide-claude-auth", + "--cli", + "auth", + "status", + ], + args: ["auth", "status"], + }, + { + scenario: "--version after --cli", + argv: ["/usr/bin/node", "/opt/acp/dist/index.js", "--cli", "--version"], + args: ["--version"], + }, + ])("drops the adapter's own flags for $scenario", ({ argv, args }) => { + expect(cliArgs(argv)).toEqual(args); + }); +}); diff --git a/src/tests/v1/authorization.test.ts b/src/tests/v1/authorization.test.ts index b493a186..d904ba65 100644 --- a/src/tests/v1/authorization.test.ts +++ b/src/tests/v1/authorization.test.ts @@ -1635,61 +1635,57 @@ describe("authorization", () => { }); }); - it("SSH session falls back to single legacy login method", async () => { - const [agent] = await createAgentMock(); - vi.stubGlobal("process", { ...process, env: { ...process.env, SSH_TTY: "/dev/pts/0" } }); - - const initializeResponse = await agent.initialize({ - protocolVersion: 1, - clientCapabilities: { auth: { terminal: true } }, - }); - - expect(initializeResponse.authMethods).toContainEqual( - expect.objectContaining({ id: "claude-login" }), + describe("remote environment", () => { + const REMOTE_VARS = [ + "NO_BROWSER", + "SSH_CONNECTION", + "SSH_CLIENT", + "SSH_TTY", + "CLAUDE_CODE_REMOTE", + ] as const; + const capabilityStyles = [ + ["auth.terminal", { auth: { terminal: true } }], + ["_meta terminal-auth", { _meta: { "terminal-auth": true } }], + ] as const; + const cases = REMOTE_VARS.flatMap((variable) => + capabilityStyles.flatMap(([style, clientCapabilities]) => + [false, true].map((hide) => ({ variable, style, clientCapabilities, hide })), + ), ); - expect(initializeResponse.authMethods).not.toContainEqual( - expect.objectContaining({ id: "claude-ai-login" }), - ); - expect(initializeResponse.authMethods).not.toContainEqual( - expect.objectContaining({ id: "console-login" }), - ); - }); - it("CLAUDE_CODE_REMOTE falls back to single legacy login method", async () => { - const [agent] = await createAgentMock(); - vi.stubGlobal("process", { ...process, env: { ...process.env, CLAUDE_CODE_REMOTE: "1" } }); + /** + * With only one remote variable set, initialize offers the TUI login plus the + * console login, or only the console login under --hide-claude-auth. + */ + it.each(cases)( + "$variable ($style, hide-claude-auth=$hide) offers console-login", + async ({ variable, clientCapabilities, hide }) => { + const [agent] = await createAgentMock(); + const env: Record = { ...process.env }; + for (const name of REMOTE_VARS) delete env[name]; + env[variable] = "1"; + vi.stubGlobal("process", { + ...process, + argv: hide ? ["--hide-claude-auth"] : [], + env, + }); - const initializeResponse = await agent.initialize({ - protocolVersion: 1, - clientCapabilities: { auth: { terminal: true } }, - }); + const initializeResponse = await agent.initialize({ + protocolVersion: 1, + clientCapabilities, + }); - expect(initializeResponse.authMethods).toContainEqual( - expect.objectContaining({ id: "claude-login" }), - ); - expect(initializeResponse.authMethods).not.toContainEqual( - expect.objectContaining({ id: "claude-ai-login" }), - ); - expect(initializeResponse.authMethods).not.toContainEqual( - expect.objectContaining({ id: "console-login" }), - ); - }); - - it("remote environment respects hide-claude-auth", async () => { - const [agent] = await createAgentMock(); - vi.stubGlobal("process", { - ...process, - argv: ["--hide-claude-auth"], - env: { ...process.env, SSH_CONNECTION: "192.168.1.1 12345 192.168.1.2 22" }, - }); - - const initializeResponse = await agent.initialize({ - protocolVersion: 1, - clientCapabilities: { auth: { terminal: true } }, - }); - - expect(initializeResponse.authMethods).not.toContainEqual( - expect.objectContaining({ id: "claude-login" }), + expect((initializeResponse.authMethods ?? []).map((method) => method.id)).toEqual( + hide ? ["console-login"] : ["claude-login", "console-login"], + ); + const consoleLogin = initializeResponse.authMethods?.find( + (method) => method.id === "console-login", + ); + if ("_meta" in clientCapabilities) { + const terminalAuth = (consoleLogin?._meta as any)?.["terminal-auth"]; + expect(terminalAuth.args.slice(-4)).toEqual(["--cli", "auth", "login", "--console"]); + } + }, ); }); diff --git a/src/tests/v2/acp-v2.test.ts b/src/tests/v2/acp-v2.test.ts index 56b02c2f..0d0523cf 100644 --- a/src/tests/v2/acp-v2.test.ts +++ b/src/tests/v2/acp-v2.test.ts @@ -9379,6 +9379,38 @@ describe("ACP v2 auth", () => { expect(acpAuthUpdates).toEqual([status]); }); + /** + * In a remote environment with --hide-claude-auth, initialize offers the + * console login as a terminal method and hides the TUI subscription login. + */ + it("offers console-login in a remote environment with hide-claude-auth", async () => { + vi.stubGlobal("process", { + ...process, + argv: ["--hide-claude-auth"], + env: { ...process.env, SSH_CONNECTION: "192.168.1.1 12345 192.168.1.2 22" }, + }); + try { + const { app } = v2Client(); + const response = await app.connectWith(connectRouter(), (agent) => + agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: V2_CLIENT_INFO, + capabilities: { auth: { terminal: {} } }, + }), + ); + + expect(response.authMethods).toEqual([ + expect.objectContaining({ + methodId: "console-login", + type: "terminal", + args: ["--cli", "auth", "login", "--console"], + }), + ]); + } finally { + vi.unstubAllGlobals(); + } + }); + it("logs in to a gateway with auth/login and out with auth/logout", async () => { const { app, authUpdate } = v2Client(); await app.connectWith(connectRouter(), async (agent) => {