Skip to content

Commit c098d0d

Browse files
Justin Helmerclaude
andcommitted
fix: no loopback return listener when the browser is not on this machine
runCheckout started its 127.0.0.1 listener and handed Stripe its return URLs whenever the terminal could wait, including with --no-browser and inside SSH sessions. In both cases the browser runs elsewhere, so after paying the user landed on a connection-refused page for a port on the wrong machine; the terminal still recovered by polling the plan, but the landing was wrong. --no-browser now means what it says and SSH_CONNECTION / SSH_TTY / SSH_CLIENT mean the same thing: no listener, no return URLs, Stripe returns to the console billing page, and the plan poll is the only signal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 17d3607 commit c098d0d

3 files changed

Lines changed: 31 additions & 7 deletions

File tree

‎src/billing/checkout.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { isApiError } from '../errors/api';
55
import { CLIError } from '../errors/base';
66
import { Spinner } from '../output/progress';
77
import { openBrowser } from '../utils/browser';
8-
import { isInteractive } from '../utils/env';
8+
import { isInteractive, isRemoteTerminal } from '../utils/env';
99
import type { BillingCycle, CatalogPlan, WorkspacePlan } from './plans';
1010
import { startReturnListener, type ReturnListener, type ReturnOutcome } from './return-listener';
1111

@@ -95,7 +95,12 @@ function printCheckoutUrl(config: Config, url: string, noBrowser: boolean, deps:
9595
// the return URLs). Ctrl+C stops waiting and counts as "not now".
9696
export async function runCheckout(config: Config, opts: CheckoutOptions, deps: CheckoutDeps = defaultDeps): Promise<CheckoutOutcome> {
9797
const waits = deps.canWait(config);
98-
const listener = waits ? await deps.startListener() : null;
98+
// The loopback listener only helps when the browser runs on this machine.
99+
// --no-browser says it does not, and an SSH session means it cannot; in
100+
// both cases Stripe returns to the console page and the plan poll below
101+
// is the only signal.
102+
const browserIsHere = !opts.noBrowser && !isRemoteTerminal();
103+
const listener = waits && browserIsHere ? await deps.startListener() : null;
99104
// Registered before the checkout request so a return that lands during it
100105
// is already visible when the wait starts.
101106
let returned: ReturnOutcome | null = null;

‎src/utils/env.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,9 @@ export function shouldUseColor(noColor: boolean): boolean {
5252
if (process.env.FORCE_COLOR) return true;
5353
return isStdoutTTY();
5454
}
55+
56+
// A terminal reached over SSH cannot be the machine the browser runs on, so a
57+
// loopback listener there would never be reached by a redirect.
58+
export function isRemoteTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
59+
return Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
60+
}

‎test/billing-checkout.test.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,25 @@ describe('runCheckout', () => {
231231
assert.equal(h.out.join(''), 'file:///etc/passwd\n');
232232
});
233233

234-
it('honours --no-browser', async () => {
235-
const h = harness();
236-
const run = runCheckout(mockConfig({ output: 'text', nonInteractive: false }), { ...base, noBrowser: true }, h.deps);
237-
h.listener!.settle('cancel');
238-
await run;
234+
it('honours --no-browser: nothing opened, no loopback listener, Stripe returns to the console', async () => {
235+
const h = harness({ planIds: ['free', 'starter'] });
236+
const outcome = await runCheckout(mockConfig({ output: 'text', nonInteractive: false }), { ...base, noBrowser: true }, h.deps);
237+
assert.equal(outcome, 'upgraded');
239238
assert.deepEqual(h.opened, []);
239+
assert.deepEqual(h.bodies, [{ workspaceId: 'ws_1', plan: 'starter', billingCycle: 'monthly' }]);
240+
});
241+
242+
it('skips the loopback listener over SSH, where the browser is on another machine', async () => {
243+
const prior = process.env.SSH_CONNECTION;
244+
process.env.SSH_CONNECTION = '10.0.0.2 51000 10.0.0.1 22';
245+
try {
246+
const h = harness({ planIds: ['free', 'starter'] });
247+
assert.equal(await runCheckout(mockConfig({ output: 'text', nonInteractive: false }), base, h.deps), 'upgraded');
248+
assert.deepEqual(h.bodies, [{ workspaceId: 'ws_1', plan: 'starter', billingCycle: 'monthly' }]);
249+
} finally {
250+
if (prior === undefined) delete process.env.SSH_CONNECTION;
251+
else process.env.SSH_CONNECTION = prior;
252+
}
240253
});
241254

242255
it('wraps a checkout API error, keeps its exit code, and always says how to upgrade later', async () => {

0 commit comments

Comments
 (0)