Skip to content

Commit 22bf19b

Browse files
ralyodioclaude
andcommitted
fix(login): use the device-code flow on SSH/headless shells
The loopback flow listens on 127.0.0.1 on the machine running the CLI, then asks a browser to redirect there. Over SSH that browser is on a different machine, so its 127.0.0.1 is the laptop's — the auth code is minted fine but lands nowhere, and login always times out. Pick the flow that can actually finish: `loginAuto()` routes to the existing device-code flow when SSH_CONNECTION/SSH_TTY is set or Linux has no DISPLAY/WAYLAND_DISPLAY. `--browser` / MOSHCODE_LOGIN=browser forces loopback back on; `--device` still forces the code. Also list /login and /whoami in the TUI help, which never mentioned them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 37904ed commit 22bf19b

3 files changed

Lines changed: 43 additions & 5 deletions

File tree

bin/moshcode.mjs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { runUpgrade } from "../src/upgrade.mjs";
1818
import { mcpCommand, skillCommand } from "../src/integrations.mjs";
1919
import { locate, tilde } from "../src/pwd.mjs";
2020
import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
21-
import { login, loginDevice, whoami, logout } from "../src/auth.mjs";
21+
import { loginAuto, whoami, logout } from "../src/auth.mjs";
2222
import { tui } from "../src/tui.mjs";
2323
import { moshcodeVersion } from "../src/ui.mjs";
2424

@@ -144,7 +144,8 @@ usage:
144144
moshcode prd [idea] publish the next numbered PRD (OpenPRD) to
145145
prd/NNNN-slug.md and hand it to an engine to
146146
author; no arg lists existing PRDs
147-
moshcode login [--device] authenticate this machine with app.moshcode.sh
147+
moshcode login [--device|--browser] authenticate this machine with app.moshcode.sh
148+
(device code over SSH/headless; --browser forces loopback)
148149
(browser OAuth+PKCE; --device = headless/CI
149150
code flow) so notify()/ask() reach you
150151
moshcode whoami | logout show / clear the logged-in account
@@ -284,8 +285,9 @@ async function main() {
284285
}
285286
if (cmd === "login") {
286287
const device = rest.includes("--device") || rest.includes("-d") || !process.stdin.isTTY;
288+
const browser = rest.includes("--browser") || rest.includes("-b");
287289
try {
288-
const { email } = device ? await loginDevice() : await login();
290+
const { email } = await loginAuto({ device, browser });
289291
console.log(`✓ logged in${email ? ` as ${email}` : ""} 🤘 — notify()/ask() will reach you now.`);
290292
} catch (e) { console.error(String(e.message || e)); process.exitCode = 1; }
291293
return;

src/auth.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ export function saveCreds(creds) {
2626
fs.chmodSync(credsPath, 0o600);
2727
}
2828

29+
/**
30+
* True when the loopback flow can't work: the browser that opens the authorize
31+
* URL is on a *different* machine than this process, so the callback to
32+
* 127.0.0.1 lands on that machine and never reaches our listener. SSH sessions
33+
* and headless boxes (a droplet, a container, CI) are exactly that case — there
34+
* the device-code flow is the only one that can finish.
35+
*/
36+
export function isRemoteShell() {
37+
const forced = String(process.env.MOSHCODE_LOGIN || "").toLowerCase();
38+
if (forced === "browser") return false;
39+
if (forced === "device") return true;
40+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) return true;
41+
// No display server on Linux → nothing here can open a browser for us.
42+
if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return true;
43+
return false;
44+
}
45+
2946
function openBrowser(url) {
3047
const [cmd, args] =
3148
process.platform === "darwin" ? ["open", [url]]
@@ -133,6 +150,22 @@ export async function loginDevice({ open = true } = {}) {
133150
}
134151
}
135152

153+
/**
154+
* Pick the login flow that can actually finish here. Over SSH or on a headless
155+
* box the loopback callback is undeliverable (it hits the *browser's* machine),
156+
* so fall back to the device code instead of stranding you on a dead
157+
* 127.0.0.1 URL. `device: true` / `browser: true` force one either way.
158+
*/
159+
export async function loginAuto({ device = false, browser = false } = {}) {
160+
const remote = !browser && (device || isRemoteShell());
161+
if (!remote) return login();
162+
if (!device) {
163+
console.log(`\n🖥 remote/headless shell — using the device-code flow.`);
164+
console.log(` (a loopback callback would open on the browser's machine, not this one.)`);
165+
}
166+
return loginDevice({ open: !isRemoteShell() });
167+
}
168+
136169
/** Print who is logged in (verified against the app). */
137170
export async function whoami() {
138171
const creds = loadCreds();

src/tui.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs";
1212
import { runUpgrade } from "./upgrade.mjs";
1313
import { locate, tilde } from "./pwd.mjs";
1414
import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
15-
import { login, loginDevice, whoami, logout } from "./auth.mjs";
15+
import { loginAuto, whoami, logout } from "./auth.mjs";
1616
import { runScript } from "./runtime.mjs";
1717
import { moshVocabulary } from "./commands.mjs";
1818
import { mcpCommand, skillCommand } from "./integrations.mjs";
@@ -156,6 +156,8 @@ function printHelp() {
156156
` ${acid("/shell [cmd]")} drop into $SHELL (exit → back to the pit); also ${acid("!cmd")}`,
157157
` ${acid("/prd [idea]")} publish a numbered PRD (OpenPRD), or list them with no arg`,
158158
` ${acid("/run <file.mosh>")} run a moshscript [--max N] [--dry-run]`,
159+
` ${acid("/login [--device]")} connect this machine to app.moshcode.sh (device code over SSH)`,
160+
` ${acid("/whoami")} who this machine is logged in as`,
159161
` ${acid("/help")} this`,
160162
` ${acid("/quit")} leave the pit (or Ctrl-D)`,
161163
"",
@@ -368,7 +370,8 @@ export async function tui() {
368370
if (cmd === "pwd" || cmd === "where") { printPwd(); continue; }
369371
if (cmd === "login") {
370372
const device = rest.includes("--device") || rest.includes("device") || rest.includes("-d");
371-
try { const { email } = device ? await loginDevice() : await login(); console.log(ok(`logged in${email ? ` as ${email}` : ""} 🤘`)); }
373+
const browser = rest.includes("--browser") || rest.includes("browser") || rest.includes("-b");
374+
try { const { email } = await loginAuto({ device, browser }); console.log(ok(`logged in${email ? ` as ${email}` : ""} 🤘`)); }
372375
catch (e) { console.log(err(String(e.message || e))); }
373376
continue;
374377
}

0 commit comments

Comments
 (0)