mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 19:32:21 +00:00
fix: offer Anthropic Console login in remote environments (#1572)
## Problem In a remote environment (`SSH_CONNECTION`, `SSH_CLIENT`, `SSH_TTY`, `NO_BROWSER` or `CLAUDE_CODE_REMOTE` set), `initialize` advertised only `claude-login`, the TUI `claude /login`. A user with API billing had no Anthropic Console option. With `--hide-claude-auth`, the list was empty and there was no way to log in at all. There is a second bug. A `_meta["terminal-auth"]` command reruns the agent with its own flags, for example `--hide-claude-auth --cli auth login --console`. The `--cli` passthrough removed only `--cli`, so the Claude CLI failed with `unknown option '--hide-claude-auth'`. ## Fix - `terminalAuthMethods` (`src/auth-methods.ts`) now offers `console-login` in remote environments too. 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 subscription method stays as `claude-login` on a remote host and `claude-ai-login` locally. `--hide-claude-auth` hides it in both cases. The change is in the shared module, so it applies to v1 and v2. - New `cliArgs()` (`src/agent-command.ts`) removes `--cli` and `--hide-claude-auth` before the arguments go to the Claude CLI. ## Tests - `v1/authorization.test.ts`: an `it.each` over the five remote env vars × typed/`_meta` terminal auth × with/without `--hide-claude-auth`. - `v2/acp-v2.test.ts`: a remote host with `--hide-claude-auth` gets exactly one `console-login` terminal method. - `agent-command.test.ts`: `cliArgs` under Node, a compiled binary, and `--cli --version`. Manually checked in a JetBrains IDE through a local launch overlay with `SSH_CONNECTION` set and `--hide-claude-auth`. Only Anthropic Console was offered, the terminal login completed, and the chat worked afterwards.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string | undefined> = { ...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"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user