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:
Mark Tkachenko
2026-11-10 13:40:44 +01:00
committed by GitHub
parent d202748976
commit c09543d85a
7 changed files with 153 additions and 72 deletions

View File

@@ -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);
}

View File

@@ -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(
{

View File

@@ -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);
}
/**

View File

@@ -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 =

View File

@@ -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);
});
});

View File

@@ -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"]);
}
},
);
});

View File

@@ -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) => {